The Prompt I Used to Build Lumina, an Interactive Light Gallery on Replit (Pull-Chain Physics, Drag-to-Dim, Hold-to-Ignite)

Published August 2026 · avivashishta.com

Overview

A fullscreen slider on pure black. Three photoreal lamps, one per slide, every one of them switched off. There is no button, no toggle, no "click here" — you turn each light on the way you would turn on the real object. You grab the pull chain hanging off the Edison pendant and yank it. You press and drag up the paper lantern to bring it from ember to daylight. You press and hold the neon tube until it sputters and catches.

That is the whole site. No copy, no nav bar, no scroll. It is a mood piece, and the entire effect rests on two things being right: the photos having no visible edge against the black, and each mechanic feeling like a physical object rather than a click handler with a delay on it.

It is a static single-page app — no backend, no database, no router — so it went into Replit Agent as one message. The prompt below is the entire build.

What it produces: a 100dvh black canvas with a Framer Motion cross-fade between three slides, a monospace 01 / 03 counter, and three completely different interaction models — a Matter.js rope with a brass knob you can physically drag, a drag-delta-to-brightness mapping with RGB interpolation from ember to warm white, and a 1.2-second hold-to-ignite with an abort state.

Stack: React + Vite + TypeScript, Tailwind, Framer Motion, Matter.js, and the Web Audio API. Every sound in the piece is synthesized at runtime from oscillators and filtered noise — there is not a single audio file in the bundle.

Generate the three images first

Do this before the agent starts wiring anything. The three photos go into attached_assets/ — either from Replit's own image generation or uploaded from wherever you make them. The exact prompts are in Step 1 of the build prompt, but the three rules that matter for all of them:

The prompt

Paste the whole thing at once.

Build **Lumina — an Awwwards-style interactive light gallery**: a single-page, fullscreen slider with three photoreal lamp "slides", each with its own physical interaction mechanic. The whole site is pure black; the product photos are edge-masked so they dissolve into that black with no visible image border. Every light is *off* until the user physically turns it on. This is a mood piece — restraint, real physics, zero UI chrome. **Stack — use exactly this, no substitutions:** - React + Vite + TypeScript - Tailwind CSS - Framer Motion (slide transitions, opacity/glow tweens) - Matter.js (pull-chain physics, slide 1 only) - Web Audio API for all sound — procedural oscillators/noise, **no audio files, no TTS/SFX APIs** - No backend, no database, no auth, no router. Fully static SPA. --- ## Step 1 — Assets Create `attached_assets/` and generate three photoreal images at 1024×1024 or larger. Each must be shot on a **pure black (#000) seamless background**, single subject, no props, no text, no hands, dramatic studio lighting, product-photography realism, light source rendered **OFF / unlit** (the glow is added in CSS, not baked into the photo). 1. `edison.png` — a bare Edison filament pendant bulb hanging from a black fabric cord, brass socket, visible coiled tungsten filament, unlit, hanging in the upper-right of frame with cord running to the top edge. Macro clarity on the filament. 2. `noguchi.png` — an Akari-style Noguchi paper lantern, ribbed washi paper over a bamboo frame, centered, unlit, soft matte paper texture. 3. `neon.png` — a raw bent glass neon tube, an abstract single-stroke curve, visible electrodes and clear glass, unlit and colorless, floating centered. Load them as Vite imports, not string paths. ## Step 2 — Gallery shell (`Gallery.tsx`) - Fullscreen (`100dvh`), `bg-black`, `overflow-hidden`, cursor stays default. - One slide visible at a time. Framer Motion `AnimatePresence` cross-fade (opacity + a 40px directional x-drift, ~0.7s, custom ease `[0.22, 1, 0.36, 1]`). No hard cuts. - Navigation: left/right chevron buttons (thin, low-opacity white, hover to full), `ArrowLeft`/`ArrowRight` keys, and touch swipe (>50px horizontal delta). No dots. - Counter bottom-left: `01 / 03` — monospace, tracking-widest, `text-white/40`. - Slide title + one-line caption bottom-right, small caps, `text-white/50`, fading in ~300ms after the slide lands. - **Nav must never be swallowed by a slide's pointer handlers** — give nav `z-50` and scope all interaction listeners to the slide's own overlay div (see Step 5). ## Step 3 — The edge mask (applies to every slide photo) Every photo fades into the background rather than sitting in a box: ```css -webkit-mask-image: linear-gradient(to right, transparent 0%, black 18%, black 82%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 18%, black 82%, transparent 100%); ``` Compose a second vertical mask (or a radial one) if a photo's top/bottom edge still shows. Glow layers sit *above* the photo using `mix-blend-mode: screen` with a soft radial gradient, opacity driven by the slide's lit state. ## Step 4 — Slide 1: Edison Pendant (Matter.js pull chain) A real hanging chain you can grab and yank to toggle the bulb. - Matter.js `Engine` + `MouseConstraint` on a transparent canvas layered over the photo, anchored to the pendant's socket in the upper-right. - Chain = 8–12 small circle bodies linked by stiff constraints, plus a heavier brass **knob** body at the bottom. Render the chain yourself over the canvas (thin brass line + knob circle) — no Matter renderer debug wireframes. - **Pre-settle:** step the engine ~120 ticks *before* first paint so the chain hangs still on mount. No spring-in jolt, ever. - **Damping:** high `frictionAir` (~0.05–0.08) so it swings like a real weighted chain, not a rubber band. - **Toggle rule:** fire the toggle when the knob is dragged past a threshold (~70–90px below rest) *while the pointer is down*. Then **disarm** — set an `armed = false` flag so the elastic rebound after release cannot fire a second toggle. Re-arm only once the knob has returned within ~15px of rest and the pointer is up. - On: warm amber glow composite over the bulb (`screen` blend, radial gradient `#ffb45a` → transparent), the filament brightens, and the whole scene gains a subtle warm vignette. Off: everything drops back to black over ~250ms. - Sound: a mechanical **click** (short filtered noise burst + 2kHz ping) plus a faint electrical **spark** on ignition. ## Step 5 — Slide 2: Noguchi Lantern (drag to dim) - Press and drag anywhere vertically: up = brighter, down = dimmer. Map drag delta to a `brightness` value 0→1, clamped, with smoothing (lerp toward target each frame). - Color follows brightness by **RGB interpolation**, deep ember → warm white: `rgb(120, 40, 15)` at 0 → `rgb(255, 244, 224)` at 1. Interpolate the channels, don't cross-fade two fixed layers. - Two stacked glow layers over the photo — an inner tight radial and an outer bloom — both `mix-blend-mode: screen`, opacity and blur scaling with brightness. The paper texture must stay readable at full brightness (cap glow opacity ~0.85). - Subtle ambient spill on the background: a huge low-opacity radial behind the lantern, same interpolated color. - No sound on this slide, or at most a near-silent filament hum — keep it quiet by design. ## Step 6 — Slide 3: Raw Neon (hold to ignite) - **Hold-to-ignite:** pointer down starts a 1.2s timer with a visible progress cue (the tube flickers/stutters faintly while charging). Release before 1.2s = abort. - Attach `pointerdown/up/leave` to a **scoped overlay div inside the slide**, never to `window` — otherwise nav clicks ignite the tube. - Once lit: the tube's hue **drifts slowly with vertical cursor position** (e.g. 180°–320° across the viewport height), eased so it never snaps. Glow = colored radial + a tight core stroke, both `screen` blend. - Sound, all procedural: - *ignite*: sputtering crackle — bursts of filtered white noise at randomized short intervals over ~600ms - *running*: soft 50Hz + 100Hz sine hum at very low gain, looping while lit - *abort*: a short electric fizzle (noise burst through a fast-decaying bandpass) ## Step 7 — Sound module (`sound.ts`) One shared `AudioContext`, lazily created **on first user gesture** (browser autoplay policy). Export `playClick()`, `playSpark()`, `playIgnite()`, `startHum()`, `stopHum()`, `playFizzle()`. Everything built from `OscillatorNode`, `BufferSource` white noise, `BiquadFilterNode`, and `GainNode` envelopes. Master gain ~0.15 — this should feel tactile, not loud. Ship a small mute toggle in the corner at `text-white/30`. ## Constraints & acceptance checks - 60fps: use `requestAnimationFrame` for physics/glow, never React state for per-frame values — drive glow via refs + direct style writes or Framer Motion `MotionValue`s. - Clean up every engine, rAF, listener and audio node on slide unmount. Switching slides ten times must not leak or stack hums. - Mobile: touch works for all three mechanics; on narrow screens scale the photo to `contain` and keep the interaction hit-areas generous. - Respect `prefers-reduced-motion` — skip the drift/flicker, keep the toggles. - No placeholder Lorem text, no framework badges, no cookie banner, no scrollbars. - **Verify before you finish:** chain hangs still on load; one pull = one toggle (never two); nav buttons work on the neon slide without igniting it; no visible rectangular edge on any photo; no audio files in the bundle.

Why the prompt is written this way

The first version of this was three sentences — "a black gallery of three lamps you can turn on, make it feel real" — and what came back was three photos in visible boxes with click-to-toggle opacity. Everything below is a clause that exists because its absence produced a specific, repeatable failure.

Name the mask, or you get a rectangle

This is the clause the whole aesthetic depends on, and it is the one a short prompt never produces. Ask for "a photo on a black background" and you get exactly that — an <img> on a black div. Even with a perfect #000 studio shot, JPEG ringing, the compressor's near-black clipping, and any hint of a floor shadow draw a faint but unmistakable rectangle. The eye finds it instantly, and the piece stops being an object floating in the dark and becomes a picture of one.

Hence the exact CSS in the prompt rather than a description of it:

-webkit-mask-image: linear-gradient(to right, transparent 0%, black 18%, black 82%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 18%, black 82%, transparent 100%);

The 18% / 82% stops are the tuning. Pull them inward and the subject itself starts fading; push them outward and the edge reappears. And the -webkit- prefix is not optional decoration — Safari still wants it, and without it the mask silently does nothing on exactly the browser most likely to be looking at a piece like this.

The follow-on clause — compose a second vertical mask if the top or bottom edge still shows — matters because a horizontal gradient only solves two of four sides. Say it up front and you avoid a round trip where you describe a line at the bottom of one photo and the agent rebuilds the whole slide.

Pre-settle the physics, or the chain snaps into place on mount

Step the engine ~120 ticks before first paint.

A Matter.js chain is built by placing bodies at their intended positions and linking them with constraints. Those constraints are never satisfied at t=0 — the bodies are approximately right, the solver has not run, and gravity has not been applied. So the first thing the user sees, every single load, is a chain visibly falling and bouncing into place. It reads as a loading glitch, not as a physical object that was already hanging there before you arrived.

A hundred and twenty synchronous steps before the first paint costs a few milliseconds and the chain is simply hanging when the slide appears. This applies to every rope, cloth, and pendulum you will ever build in a physics engine, and almost nobody writes it down.

The disarm flag — one pull, one toggle

This is the clause I would keep if I could keep only one:

Fire the toggle when the knob is dragged past a threshold while the pointer is down. Then disarm — set armed = false so the elastic rebound after release cannot fire a second toggle. Re-arm only once the knob has returned within ~15px of rest and the pointer is up.

The obvious implementation is "if knob.y > threshold, toggle". It works perfectly the first time you test it slowly, and it is broken. Let go of a real weighted chain and it does not stop at rest — it overshoots up, swings back down, and crosses your threshold again on the way through. Depending on how hard you yanked, the light toggles twice and ends up back off, or three times and ends up on, and the behaviour changes with every pull. You get a switch that appears to work intermittently, which is the worst possible outcome for a piece whose entire point is tactility.

The general pattern: any threshold crossing on a physical, oscillating value needs an arm/disarm latch, and the re-arm condition has to be a return to rest, not merely crossing back over the threshold. Two conditions on the re-arm — near rest and pointer up — because releasing mid-yank should not immediately re-arm either.

Scope the listeners to the slide, not to window

Attach pointerdown/up/leave to a scoped overlay div inside the slide, never to window.

Agents reach for window.addEventListener('pointerdown', ...) by reflex, because it always fires and it sidesteps every question about where in the tree the handler belongs. On this site that means pressing the "next slide" chevron holds the neon tube's ignite timer down. Click the arrow, hold a beat too long, and the tube lights as the slide leaves. Then the slide unmounts mid-timer and the hum never stops.

The paired clause is give nav z-50. Two separate failure modes, same root cause: the interaction surface and the chrome both want the whole viewport. State the stacking order and the event scope explicitly and both go away.

Refs and rAF, never React state, for per-frame values

Never React state for per-frame values — drive glow via refs + direct style writes or Framer Motion MotionValues.

The default shape for "drag to dim" in React is const [brightness, setBrightness] = useState(0) updated on every pointer move. That is a full render — reconciliation, effects, the lot — sixty times a second, on a component tree that also has a canvas and two blurred radial gradients in it. It is smooth on a desktop and visibly chunky on a phone, which is the worst way to find out.

The brightness value has no business in React's render cycle: nothing about the component's structure depends on it, only two style properties do. Keep it in a ref, write it to the element in the rAF loop, and React renders once. Framer Motion's MotionValue is the same idea with a nicer API — it deliberately lives outside state for exactly this reason.

Interpolate the channels, don't cross-fade two layers

Interpolate the RGB channels, don't cross-fade two fixed layers.

Asked for "ember at low brightness, warm white at high", the natural implementation is two absolutely-positioned glow divs with opposing opacities. It looks wrong in the middle, and it looks wrong in a way that is hard to name until you see the fix: at 50% you are not looking at a mid-temperature light, you are looking at a translucent orange light stacked on a translucent white one. Two ghosts, not one lamp.

Interpolating rgb(120, 40, 15) to rgb(255, 244, 224) channel by channel gives you a single light source that genuinely changes colour temperature, which is what a dimmer physically does — the filament gets hotter and the spectrum shifts. One layer, three lerps.

The 0.85 opacity cap is the other half of it. Let the glow reach 1.0 and the lantern's washi texture blows out to a flat white blob — you have turned your photoreal paper lantern into a circle. The light has to stay behind the paper.

Hold-to-ignite needs a visible charge and a real abort

A 1.2-second hold with no feedback is indistinguishable from a broken button. The user presses, nothing happens, they let go at 800ms, and they conclude the neon slide does not work. So the tube flickers and stutters while charging — that is the progress bar, disguised as the thing a real tube does before it strikes.

The abort state carries the same weight. Releasing early is not "nothing happened", it is a short electric fizzle — the sound of a tube failing to catch. It confirms the input was received and tells you what to do differently, without a single word of UI.

Synthesize the audio, and create the context on a gesture

No audio files, no TTS/SFX APIs. One shared AudioContext, lazily created on first user gesture.

Ban the files and the agent has to actually build the sounds: filtered noise bursts for the click, a randomized crackle envelope for the ignite, two low sines for the running hum. They come out better than stock samples for this — procedural sound is parametric, so the click can be tied to the knob's velocity and the crackle is different every time. It also keeps the bundle tiny and sidesteps licensing entirely.

The lazy context is a hard browser requirement, not a nicety. Construct an AudioContext at module load and Chrome creates it suspended; every sound after that is silently dropped, with no error, until something resumes it. The symptom is "audio works sometimes" — specifically, it works after a hot reload where you had already clicked, and not on a fresh load. Create it inside the first gesture handler and it is running from the start.

startHum() / stopHum() as an explicit pair, rather than a single fire-and-forget, is what makes the cleanup clause enforceable — the hum is the one node with an unbounded lifetime, and it is the one that stacks.

Acceptance checks the agent can actually run

The last block is a list of things to verify, and every item is observable rather than aspirational:

"Make it feel polished" is not checkable and the agent will assert it is done. "One pull = one toggle, never two" is checkable, and it maps directly onto the bug that clause was written to prevent. Ending a build prompt with the specific failures restated as pass/fail tests is worth more than any amount of adjectives earlier in it.

Knobs to turn first

If it comes out wrong

Recap

If you want more prompts in this shape, there is a punch-through ice wall in Three.js, a kinetic wind wall of 2,000 metal blades, and a full-stack hook generator built in Emergent, and a single floor lamp that lights an entire page.

← Portfolio · Blog