Design Lounge
Install

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

Open full size
1280 × 800 · 26.2 KB · 459 lines

Use it

Free to use · credit appreciated

1 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.

Brief

332 lines

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 │
│                                               /\  /\                   │
│ 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 with justify-content: flex-end, side padding --pad, bottom padding 34px.
  • canvas#sky[aria-hidden] absolutely fills the hero at z-index: -2. div.grain[aria-hidden] sits above it at z-index: -1. .hero::after adds a bottom 30% fade to rgba(6,7,12,.72).
  • header.top (absolute): a.mark (inline SVG flame + span), nav with four links, p.clock with span.dot and time#clk.
  • div.meta.tl and div.meta.tr, each three spans.
  • h1.title starting with a visually hidden span.vh holding “Le Dernier Feu”, then two span.ln lines, both aria-hidden. Line 1: span.w (the mask) holding one span.ch per letter (the space becomes &nbsp; so it keeps its width). Line 2: span.fr[lang=fr] with a small line, then span.gl (the glow wrapper) around em.w (the mask) with per-letter spans.
  • div.foot: p.sub, then div.ctl with button.play[aria-pressed] and div.status[role=timer].
  • section.next#rule: p kicker and h2.

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):

ThingValue
Sky gradientvertical: 0 #03050a, .38 #0a1022, .66 #18234a, 1 #24305e
Milky Way washlinear from (.05W, 0) to (.5W, .7H), rgba(120,135,210,.05) at the middle, lighter
StarsW×H/2600 (≈394), y = rand^1.5 × .7H, size .35 to 2.05px; plus 260 faint stars on the diagonal band
Bright starssize > 1.4px get a 20px bone glow sprite at half their alpha
Moonr = max(26, min(W,H) × .052) ≈ 42px; centre (.76W, .19H)
Crescentdisc filled radial #fff8e6 → #e6d8b4, minus a circle of .9r offset (+.46r, −.24r)
Dark limbfull disc #0e1430 at .55 alpha, then #c8cdeb at .06 (earthshine)
Halo3 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 glowradial at (.5W, .95H), radius .6W, rgba(255,150,70,.11) → 0, lighter
Ridges129-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 fillfrom its highest point down .4H: #59628f 0, #2e366b .12, #1b2250 .4, #151b42 1
Moon washclipped to the back ridge: radial at the moon, radius .45W, rgba(255,228,190,.2) → 0
Snow facesridges 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)
Mistafter ridge i: band from (next base − .2H) to next base, transparent → rgba(90,105,160, .30 − .07i)
Valley lightsW/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 glowwarm lights with r > 1.05 get a 12r amber sprite at .32 × flicker
Huton 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
Embers30 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)
Waveformy .868H, sampled every 3px, rgba(255,176,74,.8), 1.2px, shadowBlur 8
Grain160px noise tile generated at runtime, opacity: .045

Typography

RoleFamilySizeWeightLine-heightLetter-spacingCase
“Le Dernier”Instrument Serifclamp(88px, 15.5vw, 232px)4000.82−0.04emTitle
“Feu”Instrument Serif italicsame4000.82−0.04emTitle, --amber
Subtitle “Refuge, 2412 m”Instrument Serif italic0.16em of title (≈32px)4001.10as written, 90% opacity
Subtitle small linemono10.5px4001.60.18emUPPER
WordmarkInstrument Serif22px40010Title
Nav, clockmono11px4001.60.14em (time 0.08em)UPPER, tabular nums
Corner readoutsmono10.5px4001.60.16emUPPER, tabular nums
Paragraphmono13.5px4001.650Sentence, max 420px
Paragraph emphasisInstrument Serif italic1.4em40010lower, bone
Sound button textmono11px60010.14emUPPER
Status titlemono11px6001.60.14emUPPER
Status linemono12px4001.60Sentence, tabular nums
Next h2Instrument Serifclamp(34px, 4.4vw, 58px)4001.04−0.02emSentence

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

ElementTriggerPropertyFrom → toDurationEasingDelay / repeatReduced motion
Title lettersloadtransformtranslateY(105%) → none1100ms--ease80ms + n × 60ms, counted across both words (“Feu” starts at letter 10, 680ms)shown at once
Subtitle blockloadopacity0 → .91200ms--std800msshown at once
Starsalwaysalphaa × (.55 + .45 sin(t × s + p)), s .4 to 2.2per framesineinfinitefixed at .8a
Moon, ridges, hut, stars, emberspointeroffsetlayer × (pointer − .5)per frame5% lerpstars 5, moon 16, ridges 8/18/32/54, embers +30 pxnone
Same layersscrolltranslateYscrollY × .08 stars, .22 moon, .04/.11/.18/.25 ridges, .3 waveformper framenonecapped at one hero heightnone
Snow facespointerlit sidefollows the moon’s x as it shiftsper framenonenonestatic
Valley lightsalwaysalpha.62 + .38 sin(t·.003 + f) sin(t·.0017 + f)per framesineinfinitefixed .8
Hut windowsalwaysalpha.78 + .22 sin(t·.011) sin(t·.0037 + 1)per framesineinfinitefixed .9
Embersalwaysalpha, x, y, sizerise, drift right, sway, shrink, sin² fade3 to 7s per embersineinfinitehidden
Waveformalwaysy5px ripple, ×3.5 within 25% of pointer x; live audio ×140 clamped ±40pxper framenoneinfinitestatic ripple
Live dot, status lampkitchen openbox-shadow ring0 → 10px, .7 → 0 alpha1600msease-outinfinitestatic red
Nav underlinehoverscaleX0 → 1 from left320ms--easenoneinstant
Sound pillhoverborder, disc fillline → amber 60%, bone → amber200ms--stdnoneinstant
Sound discactivescale1 → .94240ms--easenoneinstant
Sound gainclickgain0 → .6 / current → 02500ms / 800mslinear rampnonesame (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 --flame with the pulse ring, title “Kitchen open” in flame, “Last plates 22:00”.
  • Sound pill idle: 1px --line border, 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.ready anyway.

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> with datetime kept in sync. The status pill is role="timer" (implicitly not announced every second) with aria-atomic="true". Do not use aria-live="polite" on a ticking element.
  • The sound control is a real <button> with aria-pressed and visible text that changes (“Hear the fire” / “Sound off”). Its icons are aria-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 shadowBlur per 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.DateTimeFormat with timeZone, 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-pressed and 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 #ffb04a italic, 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

  1. 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/Lisbon and 22:00 to 02:00; a desert festival Asia/Dubai and 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 right lang.
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.

  1. Drawing light. Light is additive. Switch to globalCompositeOperation = 'lighter' for stars, halos, valley glow, lights, hut windows and embers; switch back to source-over for 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 and drawImage it; shadowBlur on 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);
  1. Timezone clock and countdown. Read the time from Intl, not from getHours() 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));
  1. 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 &nbsp;); 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
Designed bySusan AcharyaKathmandu

Studied

Haunted Boulder City

Pin the screen and let scroll turn the pages. Only the line you are on gets to shout.

All sources