The Prompt I Used to Build the Luma Floor Lamp Hero on Replit (A Brass Switch That Lights the Whole Page)
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 lamp must be a transparent PNG cutout, not a photo on a
black background. A black-backed JPEG has a rectangle in it —
compression ringing and near-black clipping draw a visible box the
moment it sits on
#000. Cut it out properly, then check the alpha edge for a white or grey fringe from a light-background matte; a fringe reads as a halo around the wrong part of the lamp. - The shade must be photographed unlit. Everything the ON state does is additive. If the photo already has a glowing shade, nothing in CSS can turn it off, and the OFF state will look like a lamp that is on behind a dark filter — which is exactly what it would be.
- Shoot the lamp square-on, full length, base included. The floor bounce is positioned relative to the base. Crop the base out and the light lands on nothing.
- The switch is one photo of the ON state. The OFF variant is derived from it in the prompt — see Step 5 — so you only need to supply one, but it has to be a clean straight-on shot with the rocker clearly in one position.
The prompt
Paste the whole thing at once.
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:
tscpasses and the console is clean- the lamp is clearly visible in the OFF state
- no rectangular edge or light fringe around the cutout
- the halo sits behind the shade, not over it
- toggling ten times leaves no stacked audio nodes
- 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
"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
- OFF brightness (0.55). The single most consequential number on the page. Below ~0.4 the lamp disappears and the layout breaks; above ~0.7 the switch barely appears to do anything.
- Halo inner opacity (0.55) and blur (40px). Together these decide whether you are looking at a lit shade or a lens flare. Raise the blur before you raise the opacity.
- Floor bounce opacity (0.18). The most commonly overcooked layer in the build. It should be the thing you notice third, after the shade and the halo — never first.
-
Headline OFF colour (
#4a443c). The line between "unlit room" and "disabled text". Keep a little warmth in it; a neutral grey reads as greyed-out UI. - Breathing range (1.0–1.06) and period (6s). Widen either and the lamp develops a heartbeat. If in doubt, lengthen the period rather than shrinking the range.
- Cursor light opacity (0.10) and size (700px). Big and faint is the whole trick. Smaller and brighter turns ambience into a torch.
- Transition duration (700ms–1000ms). The halo fading a beat slower than the lamp dims is deliberate — light leaves a room more slowly than a filament cools. Keep the offset when you retune these.
- Grain opacity (0.05). Editorial texture at 0.05, visible noise at 0.12. There is no middle ground worth having.
- Master gain (0.14). Deliberately quiet. Nobody expects a product page to make a sound; it should be discoverable, not startling.
If it comes out wrong
-
The lamp turns on but nothing else changes. The glow
layers were built and the
filteron theimgwas not. This is the default failure and it is why that clause is stated separately from the halo. -
A bright disc is pasted on the front of the shade. The
halo is above the lamp in the stacking order. It belongs at
z-20, under the image. - The lamp vanishes when switched off. OFF brightness is too low. Raise it until the shade's pattern is readable.
- Turning it off looks like a CSS opacity transition on some text. One shared opacity on the copy wrapper instead of per-element tiers.
- A faint rectangle or pale fringe around the lamp. The asset, not the CSS. Either it is a black-backed JPEG rather than a transparent PNG, or the cutout has a light matte fringe on the alpha edge. Sample a corner pixel before touching any code.
-
The switch photo shimmers as it changes state. One
image being filtered instead of two cross-fading. Stack them and
animate
opacity. - The switch resizes mid-transition. The two layers have different intrinsic dimensions. Fix the box, not the images.
- The headline washes out as the cursor passes over it. The cursor light is above the copy. It has to be at the very bottom of the stack.
-
A glowing blob stuck at the edge of the page. No
pointerleavehandler. Fade the cursor light to 0 when the pointer exits the container. - Moving the mouse feels heavy or janky on a laptop. Pointer position is in React state and every move is a full render. Move it to a ref and write the transform in the rAF loop.
- The lamp visibly throbs. The breathing amplitude is too wide. 1.0 → 1.06 over 6s, and no more.
-
No click sound until you reload. The
AudioContextwas created at module load and is suspended. Create it inside the click handler, orresume()from one. - The page is on when it should be off, in parts. Duplicated per-component state. Collapse to one boolean at the top.
Recap
- A light turning on changes the lit object, not just the air around it. Name the filter on the image as its own clause or you get a glow div next to an unchanged photo.
- The off state still has to be visible. "Less light" is not "no light", and an agent asked for darkness will give you a silhouette.
- Dim in tiers. Small type disappears before large type does — that gradient of loss is what makes an unlit room read as unlit rather than as a faded div.
- Cross-fade two images for a two-state photograph. Filtering one cannot move a mechanism, and it shimmers while it interpolates.
- Write the stacking order out. When six full-viewport layers exist, the difference between spill and applied effect is entirely z-index.
- Give ambient motion literal numbers. "Subtle" survives into implementations that throb; 1.0 → 1.06 over 6s does not.
- Per-frame values belong in refs and rAF. Nothing structural depends on a cursor position.
- Specify the exit condition for anything that follows the pointer, or it freezes at the edge of the window.
- Ban the asset category and the agent has to synthesize. "No audio files" is what gets you real Web Audio instead of a package wrapping three MP3s.
- One state variable, derived everywhere. Duplicated flags are how half a page ends up lit.
- End with observable pass/fail checks, each one a bug you already know the prompt will otherwise produce.
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.