The Prompt I Used to Build the Luma Floor Lamp Hero on Replit (A Brass Switch That Lights the Whole Page)

Published August 2026 · avivashishta.com

Overview

One lamp, one switch, one black stage. An aged bronze floor lamp with a tapered floral linen shade stands nearly the full height of the viewport, dead centre, on pure black. To its right sits a brass inline switch — the kind on a real lamp cord. Click it and the lamp comes on: the shade blooms, a warm halo swells behind it, a soft pool of light lands on the floor at the base, the headline and the specs on the left lift out of the dark, and a wash of warm light starts following your cursor around the page as if you were carrying a bit of the lamp with you.

Click it again and the room goes dark. The lamp is still there — you can see it, dimly — but nothing else is lit.

It is a product hero, not a toy. The layout is editorial: an eyebrow, a large serif wordmark, a short product story, and two spec lines in monospace. The interaction is the only thing on the page, and it earns its place because it demonstrates the product. This is a static single-page app with two supplied photographs and no backend, so it went into Replit Agent as one message.

What it produces: a full-viewport black stage with a film-grain overlay, a transparent-cutout lamp at ~90vh, a two-layer warm halo behind the shade, a floor bounce under the base, a crossfading two-state brass switch with a synthesized click, a copy column that dims in tiers when the lamp goes off, and a cursor-follow light pool layered behind everything.

Stack: React + Vite + TypeScript, Tailwind, and the Web Audio API. Two image assets, no icon library, no audio files.

Supply the two photographs first

Both assets go into attached_assets/ before the agent wires anything, because the entire illusion is downstream of them. The lamp and the switch are photographs; everything that makes them look lit is CSS.

The prompt

Paste the whole thing at once.

Build **Luma — an editorial product hero for a single floor lamp**. Pure black cinematic stage, one photoreal lamp at the centre, and a real brass inline switch that turns it on. When the lamp is on, the *page* is lit: the shade glows, the floor catches a warm bounce, the copy on the left lifts out of the dark, and a pool of warm light follows the cursor. When it is off, the lamp is still visible but subdued and everything else recedes. Restraint over spectacle — this should look like a print ad that happens to work. **Stack — use exactly this, no substitutions:** - React + Vite + TypeScript - Tailwind CSS - Web Audio API for the switch click — synthesized, **no audio files** - No backend, no database, no router, no icon library. Fully static SPA. - Must pass `tsc` with no errors and run with a clean console. --- ## Step 1 — Assets Two files in `attached_assets/`, imported as Vite modules (not string paths): 1. `lamp.png` — **transparent PNG cutout** of an aged bronze floor lamp with a tapered floral/bird-motif linen shade, shot square-on, full length including the base, shade **unlit**. No baked glow, no background, no white fringe on the alpha edge. 2. `switch-on.png` — a photo of a brass inline lamp-cord switch, rocker in the ON position, straight-on. Do not generate replacements for these. If either is missing, stop and say so rather than substituting a drawn SVG. ## Step 2 — Stage - `100dvh` × `100vw`, `bg-black`, `overflow-hidden`, `position: relative`. - A **film-grain overlay** on top of everything at `pointer-events: none`: a tiny tiling SVG `feTurbulence` noise data-URI as `background-image`, `opacity: 0.05`, `mix-blend-mode: overlay`. It must be subtle enough that you only notice it if you look for it. No animated grain. - Explicit stacking order, lowest to highest: cursor light (`z-0`) → floor bounce (`z-10`) → halo (`z-20`) → lamp image (`z-30`) → copy column and switch (`z-40`) → grain (`z-50`). - One piece of state for the whole page: `const [on, setOn] = useState(true)`. Everything else derives from it. ## Step 3 — The lamp - The lamp `img` centred horizontally, bottom-aligned, `height: 90vh`, `object-fit: contain`, `user-select: none`, `draggable={false}`. - **The lamp image itself changes when lit** — not just the layers around it. Apply a CSS `filter` transition on the `img`: - ON: `brightness(1.18) saturate(1.12) contrast(1.04) sepia(0.06)` - OFF: `brightness(0.55) saturate(0.75) contrast(0.95)` - transition `filter 900ms cubic-bezier(0.22, 1, 0.36, 1)` - OFF must leave the lamp clearly **visible** — a dark silhouette is a bug, not a mood. Tune brightness until you can still read the shade's pattern. ## Step 4 — The light (three layers, all off the same state) **Halo** — behind the shade, `z-20`, sized ~1.6× the shade's width, positioned over the shade's centre: - Two stacked radial gradients: a tight inner one (`rgba(255, 196, 128, 0.55)` → transparent, ~35% stop) and a wide diffuse outer one (`rgba(255, 170, 90, 0.22)` → transparent). - `mix-blend-mode: screen`, `filter: blur(40px)` on the outer layer. - ON: opacity 1. OFF: opacity 0 over **1000ms** — the light should die a beat slower than the lamp dims. **Floor bounce** — under the base, `z-10`: - A wide, flat elliptical radial gradient (`radial-gradient(ellipse 60% 40% at 50% 50%, rgba(255, 170, 90, 0.18), transparent 70%)`), roughly 55vw wide and 18vh tall, `blur(30px)`, `screen` blend. - Deliberately restrained — this is bounce light off a dark floor, not a spotlight. If it reads as a second light source, halve the opacity. **Breathing** — while lit, the halo runs a slow **6s** `ease-in-out infinite alternate` animation that moves brightness between `1.0` and `1.06` and drifts the outer layer's `translate` by ~6px. That is the entire amplitude. It must be almost subliminal; a visible pulse looks like a fault, not a filament. ## Step 5 — The switch (right side, `z-40`) - Two absolutely-positioned `img` elements in the same box, identical dimensions, cross-fading on `opacity` over 700ms — **not one image being filtered**. - ON layer: `switch-on.png` as supplied. - OFF layer: the same file with a derived CSS filter — `brightness(0.62) saturate(0.7)` — plus a `scaleY(-1)` on just the rocker area if you can isolate it; if you cannot, the filter alone is enough. Do not attempt to redraw the switch. - Label above it: `Illumination`, small caps, `tracking-[0.25em]`, `text-white/45`. - Status line below it: `ACTIVE` when on, `OFF` when off — monospace, `text-[0.7rem]`, `tracking-[0.2em]`, amber-tinted when active (`#ffc07a`) and `text-white/30` when off. Cross-fade the text, do not pop it. - The whole switch is a real `<button>` with `aria-pressed={on}` and an accessible label. Cursor `pointer`. Keyboard-operable. - **Click sound:** on toggle, play a synthesized brass click — a short filtered-noise burst (~18ms) through a bandpass around 2.2kHz, plus a fast-decaying 1.1kHz sine ping, master gain ~0.14. One shared `AudioContext`, **created lazily inside the first click handler**, never at module load. ## Step 6 — The copy column (left, `z-40`) Stacked, left-aligned, vertically centred: 1. Eyebrow: `Collection No. 01` — mono, uppercase, `tracking-[0.3em]`, tiny. 2. Headline: `Luma` — large serif (`font-family: 'Cormorant Garamond', Georgia, serif`), ~clamp(4rem, 9vw, 8rem), tight leading. 3. Body: two or three sentences of real product story about the shade, the bronze, and the light it throws. No Lorem. 4. Specs: two mono lines — `Material / Aged bronze, linen` and `Shade / Hand-printed floral` — separated by a hairline `border-white/10`. **The copy dims with the lamp, in tiers — this is the part that sells the whole thing.** Do not apply one shared opacity to the column. Each element gets its own ON and OFF treatment, because light falls off: - Headline ON: `#f6ead8` with `text-shadow: 0 0 40px rgba(255, 180, 110, 0.35), 0 18px 40px rgba(0, 0, 0, 0.9)` — a warm glow for lift and a deep shadow underneath it. - Headline OFF: `#4a443c` — muted, still shadowed, still legible. - Body ON `text-white/70` → OFF `text-white/22`. - Specs and eyebrow ON `text-white/45` → OFF `text-white/12` — these recede **further** than the body. - All of it transitions over ~800ms with the same ease as the lamp. ## Step 7 — Cursor light - A single large radial gradient div, ~700px square, `radial-gradient(circle, rgba(255, 180, 110, 0.10), transparent 65%)`, `blur(60px)`, `screen` blend, `pointer-events: none`, at `z-0` — **behind the lamp, the copy and the switch**, so it looks like ambient spill rather than a cursor decoration. - Follow the pointer via a **ref and a direct `transform` write inside `requestAnimationFrame`**, lerping toward the target position. Never React state — this must not re-render the tree on pointer move. - Visible only while `on` is true. Fade to 0 on `pointerleave` on the container, and fade back in on re-entry. It must never freeze mid-page as a bright blob after the cursor leaves the window. - Hidden entirely on touch / no-hover devices (`@media (hover: hover)`). ## Step 8 — Motion and a11y - Every state transition is eased, 700ms–1000ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Nothing snaps. - Under `prefers-reduced-motion: reduce`: drop the breathing animation, the halo drift and the cursor light entirely; keep the on/off transitions but shorten them to ~200ms. The switch must still work. - Responsive: below ~900px stack the copy above the lamp, scale the lamp to `70vh`, keep the switch reachable, drop the cursor light. ## Constraints & acceptance checks - One state variable drives everything. No duplicated `isOn` flags per component. - No per-frame React state anywhere. Clean up the rAF loop and every listener on unmount. - No placeholder text, no framework badge, no cookie banner, no scrollbars, no visible focus-outline suppression (style the focus ring, don't remove it). - **Verify before you finish:** `tsc` passes and the console is clean; the lamp is clearly visible in the OFF state; no rectangular edge or light fringe around the lamp cutout; the halo sits *behind* the shade, not over it; toggling ten times does not stack audio nodes or leave a hum; the cursor light does not appear when the lamp is off; leaving the window does not leave a frozen glow behind; the headline is still readable when off.

Why the prompt is written this way

The first version of this was one line — "a black page with a photo of a lamp and a switch that turns it on" — and what came back was technically that: a lamp image whose opacity went from 0.4 to 1 when you clicked a checkbox. Nothing was wrong with the code. It just did not look like a lamp turning on. It looked like an image fading in.

Every clause below exists because its absence produced a specific, repeatable failure of that kind — the build works, and the illusion does not.

Light the image, not just the air around it

The lamp image itself changes when lit — not just the layers around it.

This is the clause I would keep if I could keep only one. Ask for "a glow when the lamp turns on" and you get a glow: a radial gradient div that appears next to an unchanged photograph. The result is uncanny, and it takes a second to work out why. A real lamp coming on does not add light to the room while remaining unlit itself. Its own shade goes from a dull printed surface to a warm translucent one, the bronze picks up specular highlights, and the colour of the whole object shifts toward amber.

So the ON state is three things at once, and the prompt names all three separately: a filter on the img (brightness, saturate, a touch of sepia for warmth), the halo behind the shade, and the bounce on the floor. Drop any one and the other two look like an effect. Keep all three and the eye reads it as a light source.

The sepia(0.06) is doing more work than its size suggests. brightness alone makes a photo brighter, which is a camera operation, not a lighting one. A tungsten filament shifts the spectrum warm; six percent of sepia is what turns "brighter" into "warmer", and it is the difference between a photo under a stronger flash and a lamp that is on.

The OFF state has to stay visible

OFF must leave the lamp clearly visible — a dark silhouette is a bug, not a mood.

The reflex implementation of "off" is to take brightness most of the way down, because that is literally what less light means. It kills the page. The lamp becomes a black shape on a black background, the composition collapses to a column of text with nothing next to it, and the switch now appears to delete the product rather than turn it off.

In a real dark room you can still see an unlit lamp — there is always some ambient light, and your eyes adapt. brightness(0.55) with the saturation pulled back is the version of that which still lets you read the pattern on the shade. Saying "the lamp is still visible but subdued" up front, and then repeating it as an acceptance check, is what stops the agent from optimising toward darkness.

Dim the copy in tiers, not with one opacity

Do not apply one shared opacity to the column. Each element gets its own ON and OFF treatment, because light falls off.

This is the clause that surprised me most in how much it mattered. The obvious implementation is one wrapper div going from opacity: 1 to opacity: 0.3. It is one line, it is correct, and it reads instantly as a CSS transition on some text.

Giving each element its own destination changes what the effect appears to be. The headline goes to a muted warm-grey (#4a443c) and keeps its deep shadow, so it still has physical presence — a large object in a dark room. The body copy drops to white/22. The eyebrow and specs, which are small, go further down to white/12 and nearly disappear. That gradient of loss is what unlit type actually looks like: big things remain discernible, small things become unreadable first. One shared opacity cannot express it, because it dims everything by the same ratio and the hierarchy stays perfectly intact — which is precisely the tell.

The headline's ON treatment is the same idea in reverse: a warm text-shadow glow plus a deep dark shadow beneath it. The glow is the lamp's light reaching the type; the dark shadow is what lifts it off the black so it does not look pasted on. Two shadows, one element, and it is worth stating both in the prompt because asking for "a glow on the headline" gets you only the first one and the type sits flat.

Cross-fade two images; don't filter one

Two absolutely-positioned img elements, identical dimensions, cross-fading on opacity — not one image being filtered.

You supply one photo of the switch, in the ON position, so it is tempting to let a single img carry both states with a filter. Two things go wrong. The obvious one is that a filter cannot move a rocker, so the switch never changes position and the only feedback is brightness — the control looks broken. The subtler one is that a filter transition on a photograph with fine metal detail visibly re-renders: the brass banding shimmers as the values interpolate.

Two stacked layers cross-fading on opacity is compositor-only, so it is smooth, and it gives you a slot for a genuinely different OFF frame later — a second photo with the rocker actually flipped — without touching any other code. The derived filter is the placeholder in that slot, not the mechanism.

The "identical dimensions" part is not padding. Two images of different intrinsic sizes in the same absolutely-positioned box will cross-fade with a visible size jump in the middle, and it is a genuinely confusing bug to look at because neither end state is wrong.

State the stacking order explicitly

Cursor light z-0 → floor bounce z-10 → halo z-20 → lamp z-30 → copy and switch z-40 → grain z-50.

Six layers that all cover most of the viewport and all need to be in one specific order. Leave the order unstated and you get whatever DOM order the agent happened to write, which in practice means the halo renders over the shade — a bright disc pasted on the front of the lamp instead of light escaping from behind it — and the cursor light renders over the headline, washing the type out as you move the mouse across it.

Both are the same class of bug: a layer that should be spill has become a layer that is applied. The cursor light in particular only works because it is at the very bottom. At z-0 it is ambience you notice peripherally. One step up, over the text, it is a flashlight cursor effect from 2014.

This is cheap to specify — six tokens on one line — and it removes an entire category of round trip where you describe a visual symptom and the agent guesses at which element to reparent.

The breathing amplitude is the whole clause

A slow 6s animation between brightness 1.0 and 1.06, with ~6px of drift. That is the entire amplitude.

"Make the light feel alive" produces a pulse you can count along with. An agent has no default sense of how much is too much for an ambient effect, so it picks values that are legible — which for a breathing light means wrong. A 6% brightness swing over six seconds is at the edge of perceptible, and that is the point: you cannot see it happening, you can only tell that the lamp is not a static image.

Naming the period, the range and the drift distance as literal numbers is the only reliable way to get this. Adjectives like "subtle", "gentle" and "barely perceptible" all survive into implementations that throb, because they are unfalsifiable at the point where the agent is writing a keyframe.

Cursor light: refs, rAF, and a real exit

Follow the pointer via a ref and a direct transform write inside requestAnimationFrame, lerping toward the target. Never React state.

The default shape for cursor-follow in React is onMouseMove={e => setPos({x: e.clientX, y: e.clientY})}. That is a full re-render of the tree — including a lamp image, a blurred halo, a blurred floor gradient and a grain overlay — on every pointer move. It is fine on a desktop and it stutters on anything else, which is the worst way to discover a performance decision.

Nothing structural depends on the cursor position; two transform values do. Keep it in a ref, write the transform in the rAF loop, and React renders once. The lerp is what makes it feel like a body of light with mass rather than a div nailed to the pointer.

The pointerleave clause is the one that only shows up in real use: without it, moving the cursor out of the window leaves a bright 700px blob frozen at the edge of the page, permanently. It looks like a rendering artefact, and it is the kind of bug that never appears while you are actively testing the thing you just built, because your cursor is always on it.

Ban the audio files; create the context on the click

A synthesized brass click. One shared AudioContext, created lazily inside the first click handler, never at module load.

Ban the files and the agent has to build the sound: a short filtered noise burst for the mechanical snap, a fast-decaying sine for the metallic ring. About fifteen lines, no licensing, no bundle weight, and it can be tuned in place — the click is the only audio feedback on the page and it has to sound like brass rather than like a UI beep.

The lazy context is a browser requirement, not a preference. Construct an AudioContext at module load and Chrome hands you a suspended one; every sound after that is silently dropped with no error until something resumes it. The symptom is "the click works sometimes" — specifically, it works after a hot reload where you had already clicked, and never on a fresh load. Same lesson as the Lumina gallery, and it is worth restating in every prompt that makes a noise.

One state variable

One state variable drives everything. No duplicated isOn flags per component.

With seven things reacting to the same boolean — lamp filter, halo, floor, switch, status text, copy tiers, cursor light — the natural drift is for each component to keep its own copy, synced by props or, worse, by an effect. Then one of them transitions at a different duration, or misses a toggle, and you get a page where the lamp is on and the headline is still dark. Every element derives from the single on boolean and the whole page changes state atomically.

Acceptance checks the agent can actually run

The last block is a list of observable pass/fail conditions, each one restating a bug the prompt was written to prevent:

"Make it feel cinematic" is not checkable and the agent will report it done. "The halo sits behind the shade, not over it" is checkable in one screenshot. Ending a build prompt with the specific failures restated as 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 an interactive light gallery with pull-chain physics, a hover-reveal spaceship scanner, and a punch-through ice wall in Three.js.

← Portfolio · Blog