Nº 529 of 531
Moonlit ridge hero
A place at an hour: a canvas night with a haloed moon, snow-lit Alpine ridges, a lit hut with rising embers, a giant serif name, a live clock and a countdown.
Preview
Use it
Free to use · credit appreciated1 Once, in your project · Node 22.20+
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Moonlit ridge hero" (piece moonlit-ridge-hero) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 529 · "Moonlit ridge hero" · www.designlounge.live -->
# Moonlit ridge hero
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A full-viewport night hero for **Le Dernier Feu**, a fictional mountain hut above Chamonix that serves dinner only after the last lift closes. The sky is drawn with real light on one `<canvas>`: a cold indigo gradient, about 650 stars, a crescent moon with a three-layer halo, four sharp Alpine ridgelines receding in blue with mist pooled between them, snow faces lit on the side that turns toward the moon, a small hut with glowing windows, embers rising from its chimney and flickering valley lights. Over it sits a giant overlapping serif name ("Le Dernier" in bone, "*Feu*" in italic amber), a small French subtitle tucked beside it, corner readouts (coordinates, altitude, local time), a live Chamonix clock and a pill counting down to the kitchen opening. A small "Hear the fire" button starts a quiet WebAudio fire-and-wind bed made from filtered noise, which also drives the waveform line.
Use it when the product is tied to **a place and an hour**: a label, a venue, a festival, a hotel, a late show, a night market, an observatory, a ferry. Reach for it when the person asks for spectacle. What makes it worth copying is that the place and the time are both live: the clock is that place's clock, the countdown is to the hour that matters, and the light is layered (halo, haze, glow, lit faces) rather than flat.
## Reference behaviour
1. First frame at 1280×800: a deep indigo sky, darkest at the top (`#03050a`), brightening to `#24305e` at the horizon. A crescent moon with a soft warm halo sits at 76% across and 19% down. Four ridgelines fill the lower half: the back one tall, jagged and snow-lit, the next three darker, with blue mist pooled in each valley.
2. On the two back ridges, every slope that turns toward the moon carries a pale snow highlight that fades downward; slopes turned away carry a faint shadow. Faces left of the moon light up when they descend to the right, faces right of the moon when they descend to the left, so the light reads as coming from the moon.
3. A 76px header sits on top: flame mark + "Le Dernier Feu" wordmark at left, four mono links in the middle (The Rule, Menu, The Walk, Book), and "● CHAMONIX 18:47:20" at right.
4. Corner readouts sit at 96px from the top: left "45.9052° N / 6.8915° E / ALT. 2,412 M", right "MOUNTAIN HUT / LAST LIFT 16:45 / 24 COVERS". The header clock is the third readout, top right.
5. The title fills the left two-thirds: "Le Dernier" at 198px (15.5vw), then "*Feu*" in amber italic, pulled up by 0.2em so its ascenders cut into "Le Dernier", indented by 24vw. A soft amber glow surrounds "Feu". The italic subtitle "Refuge, / 2412 m" (French, `lang="fr"`) sits in the indent under "Le Dernier", with "DINNER AFTER THE LAST LIFT" in 10.5px mono caps below it.
6. On load the title letters rise out of a mask one by one (1.1s each, 60ms stagger, "Le Dernier" first then "Feu"). The subtitle block fades in at 800ms.
7. A small hut silhouette sits on the third ridge at 63% across, two amber windows glowing through a 60px sprite. About 30 embers rise from its chimney, drift right on the wind, sway, shrink and fade.
8. Bottom row: a 3-line paragraph at left (with "*by firelight*" in serif italic), and at right a "Hear the fire" pill plus the status pill "Kitchen opens at 19:30 / in 01:12:40".
9. A thin amber waveform line crosses the full width at 86.8% of the height, just above the bottom row. At rest it ripples gently and swells near the pointer's x position.
10. The clock and countdown tick every second, aligned to the real second boundary, in **Europe/Paris** time regardless of the visitor's timezone (daylight saving handled by `Intl`).
11. Between 19:30 and 21:59 Paris time the pill flips: the lamp turns flame red and pulses, the title reads "Kitchen open" in red, and the second line reads "Last plates 22:00". The dot next to the header clock pulses red too.
12. Moving the pointer over the hero shifts the layers: stars 5px, moon 16px, ridges 8/18/32/54px (the hut rides on ridge 3), embers 30px more than the hut, all eased (5% per frame). Scrolling down pushes deeper layers down faster, so the scene sinks with depth.
13. Valley lights flicker independently on the two front ridges.
14. Clicking "Hear the fire" starts the fire-and-wind bed, fading in over 2.5s. The pill turns amber, the text becomes "Sound off", the icon becomes a square, and the waveform line now draws the live audio signal. A second click fades it out over 0.8s and the text returns. Nothing makes sound before that first click.
15. Below the hero, a short dark band ("01 The Rule" + one serif sentence) gives the scroll parallax somewhere to go.
## Structure
```
1280 × 800
┌────────────────────────────────────────────────────────────────────────┐
│ Δ Le Dernier Feu THE RULE MENU THE WALK BOOK ● CHAMONIX 18:47:20 │ 76px header
│ 45.9052° N MOUNTAIN HUT │ meta @ top 96
│ 6.8915° E ( ☾ ) LAST LIFT 16:45 │ moon .76W .19H
│ ALT. 2,412 M halo r×9 24 COVERS │
│ /\ /\ │
… 292 more lines. Copy or download for the full brief. Loading the source…Brief
332 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A full-viewport night hero for Le Dernier Feu, a fictional mountain hut above Chamonix that serves dinner only after the last lift closes. The sky is drawn with real light on one <canvas>: a cold indigo gradient, about 650 stars, a crescent moon with a three-layer halo, four sharp Alpine ridgelines receding in blue with mist pooled between them, snow faces lit on the side that turns toward the moon, a small hut with glowing windows, embers rising from its chimney and flickering valley lights. Over it sits a giant overlapping serif name (“Le Dernier” in bone, “Feu” in italic amber), a small French subtitle tucked beside it, corner readouts (coordinates, altitude, local time), a live Chamonix clock and a pill counting down to the kitchen opening. A small “Hear the fire” button starts a quiet WebAudio fire-and-wind bed made from filtered noise, which also drives the waveform line.
Use it when the product is tied to a place and an hour: a label, a venue, a festival, a hotel, a late show, a night market, an observatory, a ferry. Reach for it when the person asks for spectacle. What makes it worth copying is that the place and the time are both live: the clock is that place’s clock, the countdown is to the hour that matters, and the light is layered (halo, haze, glow, lit faces) rather than flat.
Reference behaviour
- First frame at 1280×800: a deep indigo sky, darkest at the top (
#03050a), brightening to#24305eat the horizon. A crescent moon with a soft warm halo sits at 76% across and 19% down. Four ridgelines fill the lower half: the back one tall, jagged and snow-lit, the next three darker, with blue mist pooled in each valley. - On the two back ridges, every slope that turns toward the moon carries a pale snow highlight that fades downward; slopes turned away carry a faint shadow. Faces left of the moon light up when they descend to the right, faces right of the moon when they descend to the left, so the light reads as coming from the moon.
- A 76px header sits on top: flame mark + “Le Dernier Feu” wordmark at left, four mono links in the middle (The Rule, Menu, The Walk, Book), and ”● CHAMONIX 18:47:20” at right.
- Corner readouts sit at 96px from the top: left “45.9052° N / 6.8915° E / ALT. 2,412 M”, right “MOUNTAIN HUT / LAST LIFT 16:45 / 24 COVERS”. The header clock is the third readout, top right.
- The title fills the left two-thirds: “Le Dernier” at 198px (15.5vw), then “Feu” in amber italic, pulled up by 0.2em so its ascenders cut into “Le Dernier”, indented by 24vw. A soft amber glow surrounds “Feu”. The italic subtitle “Refuge, / 2412 m” (French,
lang="fr") sits in the indent under “Le Dernier”, with “DINNER AFTER THE LAST LIFT” in 10.5px mono caps below it. - On load the title letters rise out of a mask one by one (1.1s each, 60ms stagger, “Le Dernier” first then “Feu”). The subtitle block fades in at 800ms.
- A small hut silhouette sits on the third ridge at 63% across, two amber windows glowing through a 60px sprite. About 30 embers rise from its chimney, drift right on the wind, sway, shrink and fade.
- Bottom row: a 3-line paragraph at left (with “by firelight” in serif italic), and at right a “Hear the fire” pill plus the status pill “Kitchen opens at 19:30 / in 01:12:40”.
- A thin amber waveform line crosses the full width at 86.8% of the height, just above the bottom row. At rest it ripples gently and swells near the pointer’s x position.
- The clock and countdown tick every second, aligned to the real second boundary, in Europe/Paris time regardless of the visitor’s timezone (daylight saving handled by
Intl). - Between 19:30 and 21:59 Paris time the pill flips: the lamp turns flame red and pulses, the title reads “Kitchen open” in red, and the second line reads “Last plates 22:00”. The dot next to the header clock pulses red too.
- Moving the pointer over the hero shifts the layers: stars 5px, moon 16px, ridges 8/18/32/54px (the hut rides on ridge 3), embers 30px more than the hut, all eased (5% per frame). Scrolling down pushes deeper layers down faster, so the scene sinks with depth.
- Valley lights flicker independently on the two front ridges.
- Clicking “Hear the fire” starts the fire-and-wind bed, fading in over 2.5s. The pill turns amber, the text becomes “Sound off”, the icon becomes a square, and the waveform line now draws the live audio signal. A second click fades it out over 0.8s and the text returns. Nothing makes sound before that first click.
- Below the hero, a short dark band (“01 The Rule” + one serif sentence) gives the scroll parallax somewhere to go.
Structure
1280 × 800
┌────────────────────────────────────────────────────────────────────────┐
│ Δ Le Dernier Feu THE RULE MENU THE WALK BOOK ● CHAMONIX 18:47:20 │ 76px header
│ 45.9052° N MOUNTAIN HUT │ meta @ top 96
│ 6.8915° E ( ☾ ) LAST LIFT 16:45 │ moon .76W .19H
│ ALT. 2,412 M halo r×9 24 COVERS │
│ /\ /\ │
│ L E D E R N I E R (bone, 198px) /\/\ /░░\/░░\/\ │ ridge 0 (lit faces)
│ /░░ \/ \/\ │
│ Refuge, F E U (amber italic, ~~~~ mist ~~~~ │ ridge 1 (faint snow)
│ 2412 m indent 24vw, up .2em) /\/\ ⌂ · · │ ridge 2 + hut + lights
│ DINNER AFTER THE LAST LIFT · · · · · · │ ridge 3 + lights
│ ~~~~~~~~~~~~~~~~~~~~~~~~~ amber waveform @ .868H ~~~~~~~~~~~~~~~~~~~~~~ │
│ A mountain hut above… ( ▶ HEAR THE FIRE ) ( ● KITCHEN OPENS AT 19:30 )│ foot, 34px bottom pad
└────────────────────────────────────────────────────────────────────────┘
then: section.next, 84px top / 96px bottom padding
section.hero,height: 100vh; min-height: 560px, flex column, content pushed to the bottom withjustify-content: flex-end, side padding--pad, bottom padding 34px.canvas#sky[aria-hidden]absolutely fills the hero atz-index: -2.div.grain[aria-hidden]sits above it atz-index: -1..hero::afteradds a bottom 30% fade torgba(6,7,12,.72).header.top(absolute):a.mark(inline SVG flame + span),navwith four links,p.clockwithspan.dotandtime#clk.div.meta.tlanddiv.meta.tr, each threespans.h1.titlestarting with a visually hiddenspan.vhholding “Le Dernier Feu”, then twospan.lnlines, botharia-hidden. Line 1:span.w(the mask) holding onespan.chper letter (the space becomes so it keeps its width). Line 2:span.fr[lang=fr]with asmallline, thenspan.gl(the glow wrapper) aroundem.w(the mask) with per-letter spans.div.foot:p.sub, thendiv.ctlwithbutton.play[aria-pressed]anddiv.status[role=timer].section.next#rule:pkicker andh2.
Tokens
:root {
/* colour */
--ink: #06070c; /* page, nearest ridge, pill fills */
--indigo: #18234a; /* low sky, back ridge base */
--bone: #ece6d6; /* "Le Dernier", text, stars */
--bone-dim: rgba(236,230,214,.62); /* readouts, paragraph, nav */
--line: rgba(236,230,214,.16); /* pill borders, hairlines */
--amber: #ffb04a; /* "Feu", hut windows, embers, lights, waveform, playing state */
--flame: #e8442e; /* live dot and lamp, only while the kitchen is open */
--sky-top: #03050a; --sky-mid: #0a1022; --sky-low: #18234a; --sky-horizon: #24305e;
--ridge-1: #141b3c; --ridge-2: #0b0f24; --ridge-3: #06070d;
--snow: #eef0ff; --snow-shade: #05081a;
/* type */
--serif: "Instrument Serif", "Times New Roman", serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--fs-title: clamp(88px, 15.5vw, 232px);
--fs-sub-title: .16em; /* of the title */
--fs-mark: 22px;
--fs-sub: 13.5px;
--fs-readout: 10.5px;
--fs-nav: 11px;
/* layout */
--pad: clamp(20px, 4.4vw, 56px);
--header-h: 76px;
--play-h: 56px;
--play-disc: 40px;
/* motion */
--ease: cubic-bezier(.16, 1, .3, 1); /* expo out: letter rise, hovers */
--std: cubic-bezier(.2, .7, .2, 1); /* colour changes, fades */
--t-rise: 1100ms; --t-stagger: 60ms;
--t-sub-title: 1200ms; --t-sub-title-delay: 800ms;
--t-fade-in: 2500ms; --t-fade-out: 800ms;
}
Scene numbers (canvas CSS pixels, W×H = hero size):
| Thing | Value |
|---|---|
| Sky gradient | vertical: 0 #03050a, .38 #0a1022, .66 #18234a, 1 #24305e |
| Milky Way wash | linear from (.05W, 0) to (.5W, .7H), rgba(120,135,210,.05) at the middle, lighter |
| Stars | W×H/2600 (≈394), y = rand^1.5 × .7H, size .35 to 2.05px; plus 260 faint stars on the diagonal band |
| Bright stars | size > 1.4px get a 20px bone glow sprite at half their alpha |
| Moon | r = max(26, min(W,H) × .052) ≈ 42px; centre (.76W, .19H) |
| Crescent | disc filled radial #fff8e6 → #e6d8b4, minus a circle of .9r offset (+.46r, −.24r) |
| Dark limb | full disc #0e1430 at .55 alpha, then #c8cdeb at .06 (earthshine) |
| Halo | 3 radials centred (−.35r, +.15r) from the moon: .6r→9r at .16, .6r→3r at .20, .7r→1.6r at .26, warm rgba(255,222,176,a), lighter |
| Valley glow | radial at (.5W, .95H), radius .6W, rgba(255,150,70,.11) → 0, lighter |
| Ridges | 129-point midpoint displacement, then height = t^sharp; base .64/.72/.81/.90 H; height .36/.17/.09/.05 × A, where A = H × min(1, .55 + .4W/H) so portrait screens don’t get needles; roughness .70/.64/.55/.46; sharp 1.6/1.35/1.1/1; seeds 11/23/37/53 |
| Back ridge fill | from its highest point down .4H: #59628f 0, #2e366b .12, #1b2250 .4, #151b42 1 |
| Moon wash | clipped to the back ridge: radial at the moon, radius .45W, rgba(255,228,190,.2) → 0 |
| Snow faces | ridges 0 and 1, only above 28% of the ridge height; per segment, f = slope × sign(moonX − segmentX); f > .04 lit #eef0ff, f < −.04 shade #05081a; depth 3 + min(1,|f|) × .16 × height × altitude; vertical gradient opaque → 0 over 1.3 × depth; alpha min(1, 1.5|f|) × altitude ramp × (.9 lit on ridge 0, .36 on ridge 1, .45 shade) |
| Mist | after ridge i: band from (next base − .2H) to next base, transparent → rgba(90,105,160, .30 − .07i) |
| Valley lights | W/5 (≈256); 22% on ridge 2 at .55 strength, the rest on ridge 3 down to the bottom; 82% #ffb04a, 18% #dfe8ff; radius .4 to 1.5px |
| Light glow | warm lights with r > 1.05 get a 12r amber sprite at .32 × flicker |
| Hut | on ridge 2 at .63W, scale s = clamp(.8, H/800, 1.4); 20s wide walls, 7s tall, roof peak 17s with 3s eaves, 3×6s chimney; filled with the ridge colour; two 3.4×3.6s windows #ffc56e; 60s amber sprite at .5 × flicker |
| Embers | 30 from the chimney top; cycle 3 to 7s; rise .16 to .40H; drift 20 to 110px right; sway 6 to 22px; sprite 6 to 16px shrinking 60%; 1.4px core #ffe2a8; alpha sin²(π·life) × (1 − life/2) |
| Waveform | y .868H, sampled every 3px, rgba(255,176,74,.8), 1.2px, shadowBlur 8 |
| Grain | 160px noise tile generated at runtime, opacity: .045 |
Typography
| Role | Family | Size | Weight | Line-height | Letter-spacing | Case |
|---|---|---|---|---|---|---|
| “Le Dernier” | Instrument Serif | clamp(88px, 15.5vw, 232px) | 400 | 0.82 | −0.04em | Title |
| “Feu” | Instrument Serif italic | same | 400 | 0.82 | −0.04em | Title, --amber |
| Subtitle “Refuge, 2412 m” | Instrument Serif italic | 0.16em of title (≈32px) | 400 | 1.1 | 0 | as written, 90% opacity |
| Subtitle small line | mono | 10.5px | 400 | 1.6 | 0.18em | UPPER |
| Wordmark | Instrument Serif | 22px | 400 | 1 | 0 | Title |
| Nav, clock | mono | 11px | 400 | 1.6 | 0.14em (time 0.08em) | UPPER, tabular nums |
| Corner readouts | mono | 10.5px | 400 | 1.6 | 0.16em | UPPER, tabular nums |
| Paragraph | mono | 13.5px | 400 | 1.65 | 0 | Sentence, max 420px |
| Paragraph emphasis | Instrument Serif italic | 1.4em | 400 | 1 | 0 | lower, bone |
| Sound button text | mono | 11px | 600 | 1 | 0.14em | UPPER |
| Status title | mono | 11px | 600 | 1.6 | 0.14em | UPPER |
| Status line | mono | 12px | 400 | 1.6 | 0 | Sentence, tabular nums |
| Next h2 | Instrument Serif | clamp(34px, 4.4vw, 58px) | 400 | 1.04 | −0.02em | Sentence |
Load one Google family: Instrument+Serif:ital@0;1. The mono is the system stack. The paragraph has text-shadow: 0 1px 14px rgba(6,7,12,.95) so it reads over the lights.
Motion
| Element | Trigger | Property | From → to | Duration | Easing | Delay / repeat | Reduced motion |
|---|---|---|---|---|---|---|---|
| Title letters | load | transform | translateY(105%) → none | 1100ms | --ease | 80ms + n × 60ms, counted across both words (“Feu” starts at letter 10, 680ms) | shown at once |
| Subtitle block | load | opacity | 0 → .9 | 1200ms | --std | 800ms | shown at once |
| Stars | always | alpha | a × (.55 + .45 sin(t × s + p)), s .4 to 2.2 | per frame | sine | infinite | fixed at .8a |
| Moon, ridges, hut, stars, embers | pointer | offset | layer × (pointer − .5) | per frame | 5% lerp | stars 5, moon 16, ridges 8/18/32/54, embers +30 px | none |
| Same layers | scroll | translateY | scrollY × .08 stars, .22 moon, .04/.11/.18/.25 ridges, .3 waveform | per frame | none | capped at one hero height | none |
| Snow faces | pointer | lit side | follows the moon’s x as it shifts | per frame | none | none | static |
| Valley lights | always | alpha | .62 + .38 sin(t·.003 + f) sin(t·.0017 + f) | per frame | sine | infinite | fixed .8 |
| Hut windows | always | alpha | .78 + .22 sin(t·.011) sin(t·.0037 + 1) | per frame | sine | infinite | fixed .9 |
| Embers | always | alpha, x, y, size | rise, drift right, sway, shrink, sin² fade | 3 to 7s per ember | sine | infinite | hidden |
| Waveform | always | y | 5px ripple, ×3.5 within 25% of pointer x; live audio ×140 clamped ±40px | per frame | none | infinite | static ripple |
| Live dot, status lamp | kitchen open | box-shadow ring | 0 → 10px, .7 → 0 alpha | 1600ms | ease-out | infinite | static red |
| Nav underline | hover | scaleX | 0 → 1 from left | 320ms | --ease | none | instant |
| Sound pill | hover | border, disc fill | line → amber 60%, bone → amber | 200ms | --std | none | instant |
| Sound disc | active | scale | 1 → .94 | 240ms | --ease | none | instant |
| Sound gain | click | gain | 0 → .6 / current → 0 | 2500ms / 800ms | linear ramp | none | same (audio is not motion) |
Pause the animation loop with an IntersectionObserver when the hero leaves the viewport. Never run a loop while reduced motion is on, except while the sound plays (so the live waveform still works).
States
- Kitchen closed (22:00 to 19:29 Paris): grey lamp
#3a3f5c, bone title “Kitchen opens at 19:30”, “in hh:mm:ss”. Header dot at 40% bone. - Kitchen open (19:30 to 21:59 Paris): lamp and header dot
--flamewith the pulse ring, title “Kitchen open” in flame, “Last plates 22:00”. - Sound pill idle: 1px
--lineborder,rgba(6,7,12,.5)fill with 10px backdrop blur, bone 40px disc with a dark play triangle, text “Hear the fire”. - Sound pill hover: border amber at 60%, disc turns amber.
- Sound pill playing (
aria-pressed="true"): amber border,rgba(255,176,74,.1)fill, amber disc with a 10px rounded square, text “Sound off”. This is the visible off control. - Nav links: bone-dim, hover to bone plus a 1px amber underline growing from the left.
- Wordmark hover: the flame mark lifts 3px and scales to 1.08 over 800ms.
- Focus-visible everywhere: 2px amber outline, 3px offset, 4px radius.
- Fonts loading: the canvas does not depend on fonts; redraw once on
document.fonts.readyanyway.
Accessibility
- The canvas and grain are decorative:
aria-hidden="true". Nothing in them is interactive. - The h1 starts with a visually hidden “Le Dernier Feu” and both visual lines are
aria-hidden, so screen readers hear the name once instead of letter spans and the subtitle. - The subtitle block has
lang="fr". - The clock is a
<time>withdatetimekept in sync. The status pill isrole="timer"(implicitly not announced every second) witharia-atomic="true". Do not usearia-live="polite"on a ticking element. - The sound control is a real
<button>witharia-pressedand visible text that changes (“Hear the fire” / “Sound off”). Its icons arearia-hidden. - Sound starts only after a click and stops on the second click. Never autoplay.
- Contrast: bone on the darkest sky is about 17:1, bone-dim readouts about 9:1, amber “Feu” on indigo about 9:1. The paragraph keeps 4.5:1 or better thanks to its dark text-shadow over the lights.
- Hit targets: sound pill 56px tall, nav links at least 24px with 6px vertical padding.
- Tab order: wordmark, four nav links, sound button.
Responsive rules
- ≥ 1280: as described; the title caps at 232px.
- 1024: same layout. The title is 159px. The moon stays at .76W, clear of “Le Dernier”.
- ≤ 900: the nav and the subtitle block hide, “Feu” indents only 10vw, and the bottom row stacks (paragraph above the controls).
- < 640: the right corner readout hides, the title becomes clamp(60px, 20vw, 120px) so “Le Dernier” fits on one line at 360, the controls wrap onto two rows. Ridge heights shrink with the aspect ratio (the A factor above). Rebuild the canvas on resize (debounced 120ms) so stars, ridges, lights and the hut refill the new size.
- Cap the canvas device pixel ratio at 2.
Acceptance checklist
Always
- The whole sky is one canvas with the scene numbers above; no image files.
- The moon has three stacked radial halos drawn with
lighter, and a dark limb that blocks the halo behind it. - At least three ridgelines, each darker than the one behind, with a mist gradient pooled at the base of each.
- Lights and embers glow through a pre-rendered radial sprite, not
shadowBlurper dot. - Pointer moves layers by different amounts (nearest moves most), eased at 5% per frame; scroll sinks deeper layers faster.
- The title letters rise through a mask with 60ms stagger; the amber word’s glow is not clipped into a rectangle.
- The clock shows the place’s time from
Intl.DateTimeFormatwithtimeZone, not the visitor’s local time. - The countdown pill switches state and copy when the window opens and when it closes.
- No sound before a click; second click fades out in 0.8s;
aria-pressedand the visible text stay in sync. - Reduced motion: no letter rise, no twinkle, no embers, no parallax; one complete static frame.
- Focus rings visible on every link and the button; at most two Google font families; demo under 40 KB.
This demo
- Title reads “Le Dernier” in bone and “Feu” in
#ffb04aitalic, overlapping by 0.2em. - Subtitle reads “Refuge, / 2412 m” with “DINNER AFTER THE LAST LIFT” under it; only Instrument Serif is loaded.
- Ridges are sharp and Alpine, with snow faces lit on the side facing the moon.
- Clock reads “CHAMONIX hh:mm:ss” in Europe/Paris time.
- Closed copy: “Kitchen opens at 19:30 / in hh:mm:ss”; open (19:30 to 22:00): “Kitchen open / Last plates 22:00”.
- Corner readouts show 45.9052° N, 6.8915° E, Alt. 2,412 m and Mountain hut, Last lift 16:45, 24 covers.
- “Hear the fire” plays crackling fire and wind made from filtered noise; “Sound off” stops it.
Implementation notes
- Re-skinning for another place. Keep the layer stack (sky, light source, 3 to 4 silhouettes, mist, lights, drifting warm lights, waveform) and swap the silhouette generator. Ridges come from midpoint displacement (roughness .45 to .7) raised to a power for sharpness (1 for rolling hills, 1.6 for Alpine peaks). For a skyline, replace it with stepped rectangles of random width 20 to 80px and height, and put lights in a grid inside them as windows. For a sea, draw 3 flat bands with a slow sine edge (amplitude 2 to 6px) and reflect the moon as a vertical column of short amber dashes. For dunes, use the same displacement with roughness .35, sharpness 1 and a warm sky, and drop the snow faces. Then change three constants: the timezone string, the hour window and the copy. A Lisbon fado bar might use
Europe/Lisbonand 22:00 to 02:00; a desert festivalAsia/Dubaiand sunset. If the place has a local name in another script, set it in the subtitle slot with a matching script face as the second font family, with the rightlang.
function ridge(seed, base, amp, rough, sharp) {
const R = rng(seed), n = 129, p = new Array(n).fill(0);
p[0] = R(); p[n - 1] = R();
for (let step = n - 1, sc = 1; step > 1; step /= 2, sc *= rough)
for (let k = step / 2; k < n; k += step)
p[k] = (p[k - step / 2] + p[k + step / 2]) / 2 + (R() - .5) * sc;
const lo = Math.min(...p), hi = Math.max(...p);
return p.map((v) => base - Math.pow((v - lo) / (hi - lo), sharp) * amp);
}
const rng = (s) => () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
Use a seeded PRNG so the horizon is the same on every visit. Math.random() is fine for flicker and audio only.
- Drawing light. Light is additive. Switch to
globalCompositeOperation = 'lighter'for stars, halos, valley glow, lights, hut windows and embers; switch back tosource-overfor the moon body, ridges and snow faces. Stack halos of different radii instead of one big gradient: a wide faint one (9r) gives atmosphere, a medium one (3r) gives the glow, a tight one (1.6r) gives the corona. Offset them toward the lit side of the crescent. Pre-render one radial sprite per colour anddrawImageit;shadowBluron hundreds of dots will drop frames.
function sprite(r, rgb, core) {
const c = document.createElement('canvas'); c.width = c.height = r * 2;
const g = c.getContext('2d'), gr = g.createRadialGradient(r, r, 0, r, r, r);
gr.addColorStop(0, `rgba(${rgb},1)`);
gr.addColorStop(core, `rgba(${rgb},.35)`);
gr.addColorStop(1, `rgba(${rgb},0)`);
g.fillStyle = gr; g.fillRect(0, 0, r * 2, r * 2); return c;
}
const halo = (r0, r1, a) => {
const g = x.createRadialGradient(hx, hy, r0, hx, hy, r1);
g.addColorStop(0, `rgba(255,222,176,${a})`);
g.addColorStop(.4, `rgba(255,210,160,${a * .32})`);
g.addColorStop(1, 'rgba(255,210,160,0)');
x.fillStyle = g; x.fillRect(hx - r1, hy - r1, r1 * 2, r1 * 2);
};
x.globalCompositeOperation = 'lighter';
halo(mr * .6, mr * 9, .16); halo(mr * .6, mr * 3, .2); halo(mr * .7, mr * 1.6, .26);
Depth comes from mist: after each ridge, fill a band that fades from transparent to a pale blue at the next ridge’s base, then draw the next ridge over it. Clip the back ridge and paint a radial wash at the moon’s position so the peaks nearest the moon catch light. Then add the snow faces: walk the ridge segment by segment, multiply the slope by the sign of (moon x − segment x) so the lit side flips as you pass under the moon, and paint a short quad under each facing segment with a vertical gradient that fades to nothing. Hard-edged quads look like shards; the gradient makes them read as snow.
const f = (p[i + 1] - p[i]) / dx * Math.sign(mcx - x0 - dx / 2);
if (Math.abs(f) < .04) continue;
const g = x.createLinearGradient(0, y0, 0, y0 + d * 1.3);
const c = f > 0 ? '238,240,255' : '5,8,26';
g.addColorStop(0, `rgba(${c},1)`); g.addColorStop(1, `rgba(${c},0)`);
x.globalAlpha = Math.min(1, Math.abs(f) * 1.5) * altitudeRamp * (f > 0 ? snow : .45);
- Timezone clock and countdown. Read the time from
Intl, not fromgetHours()plus an offset (offsets break with daylight saving; Paris moves between UTC+1 and UTC+2). Align the interval to the second boundary so the display never skips.
const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
const OPEN = 19 * 3600 + 30 * 60, CLOSE = 22 * 3600;
function tick() {
const p = Object.fromEntries(fmt.formatToParts(new Date()).map((x) => [x.type, x.value]));
const sec = (+p.hour % 24) * 3600 + +p.minute * 60 + +p.second;
const open = sec >= OPEN && sec < CLOSE;
status.classList.toggle('open', open);
title.textContent = open ? 'Kitchen open' : 'Kitchen opens at 19:30';
line.textContent = open ? 'Last plates 22:00' : `in ${hms((OPEN - sec + 86400) % 86400)}`;
}
tick();
setTimeout(() => { tick(); setInterval(tick, 1000); }, 1000 - (Date.now() % 1000));
- Gesture-gated fire and wind. Build the graph inside the click handler, never at load. Fill one 2-second buffer with white noise and reuse it for everything. Wind: loop it through a 420Hz bandpass (Q .9) at gain .07, with one LFO sweeping the cutoff ±260Hz at 0.09Hz and another moving the gain ±.04 at 0.13Hz. Fire bed: loop it through a 240Hz lowpass at gain .16. Crackles: every 200ms, schedule 0 to 3 short slices of the buffer through a 1.8 to 4.8kHz highpass, peaking at .08 to .28 within 2ms and decaying exponentially over 10 to 50ms; 12% of them are pops instead (700Hz bandpass, peak .5, 90ms). Fade the master in and out; suspend the context after the fade so it costs nothing.
function noise(dest, type, f, q, g) {
const s = ac.createBufferSource(), fl = ac.createBiquadFilter(), gn = ac.createGain();
s.buffer = buf; s.loop = true; fl.type = type; fl.frequency.value = f; fl.Q.value = q; gn.gain.value = g;
s.connect(fl); fl.connect(gn); gn.connect(dest); s.start(0, Math.random() * 2);
return { fl, gn };
}
function crackle() {
for (let i = 0, c = Math.floor(Math.random() * 4); i < c; i++) {
const t = ac.currentTime + .02 + Math.random() * .18;
const s = ac.createBufferSource(), hp = ac.createBiquadFilter(), g = ac.createGain();
s.buffer = buf; hp.type = 'highpass'; hp.frequency.value = 1800 + Math.random() * 3000;
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(.08 + Math.random() * .2, t + .002);
g.gain.exponentialRampToValueAtTime(.0001, t + .01 + Math.random() * .04);
s.connect(hp); hp.connect(g); g.connect(out); s.start(t, Math.random() * 1.9, .12);
}
}
btn.onclick = () => {
const on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', on);
if (!ac) buildAudio();
const now = ac.currentTime, my = ++token;
out.gain.cancelScheduledValues(now); out.gain.setValueAtTime(out.gain.value, now);
if (on) { ac.resume(); out.gain.linearRampToValueAtTime(.6, now + 2.5); timer = setInterval(crackle, 200); }
else { out.gain.linearRampToValueAtTime(0, now + .8); clearInterval(timer);
setTimeout(() => my === token && ac.suspend(), 900); }
};
While playing, route the master through an AnalyserNode and draw the waveform from an.getByteTimeDomainData(), scaled ×140 and clamped to ±40px.
Common mistakes: putting text-shadow on text inside an overflow: hidden reveal mask (the glow gets clipped into a visible rectangle; put a filter: drop-shadow() on a wrapper outside the mask instead); letting a space inside a split word collapse to zero width (render it as ); drawing the dark side of the crescent as transparent, so the halo shows through the moon; lighting the same side of every peak regardless of where the moon is; using the visitor’s local time; announcing the ticking countdown with aria-live; creating the AudioContext on load (browsers block it and some log warnings); and racing a restart against the delayed suspend() (guard it with a token).
Details
- Palette
- Type
- Instrument Serif
- Motion
- Rich motion
- Build
- Multi-step
- Tags
Studied
Haunted Boulder CityPin the screen and let scroll turn the pages. Only the line you are on gets to shout.
All sources