Hotel Reverie the night ledger

Back to the hotel

Colophon · how this was built

The Night Ledger

Hotel Reverie is one fragment shader, four hexes, two typefaces, and about nine hundred words of copy. Nothing else was permitted through the front door. This page is the register of how it was made, kept honestly.

Page one

The brief, as received

“A hotel that exists only at the edge of sleep. Full-bleed GLSL sky.” The signature demanded: a fullscreen fragment-shader cloudscape of layered, domain-warped FBM; a slow dusk↔dawn drift; rooms as floors of the sky, with scroll descending through cloud density; copy in a stationary column behind a soft veil that samples the palette; film grain in the shader, not an overlay. The thing to prove: a single full-screen shader plus typography is enough for a complete, expensive-feeling brand site. Restraint elsewhere mandatory.

  • dawn lilac #b9a7d6
  • peach #f2c9a8
  • deep dusk #2a2440
  • ivory #f6f1e9

Type: Cormorant Garamond, italic, for anything that dreams; Outfit for anything that must be read at the front desk. Both vendored locally as latin woff2 subsets. Code on this page is set in your system’s own mono — the hotel does not keep a third typeface.

Technique one

Clouds from arithmetic

The entire sky is one WebGL2 fragment shader on a fullscreen triangle. Value noise is layered five octaves deep into FBM, then the FBM is fed its own output twice — domain warping — which is what makes the clouds curdle and marble instead of looking like static. Three layers of it drift at different scales and speeds; the nearest gets the full double warp.

float fbm(vec2 p){
  float v = 0.0, a = 0.5;
  for(int i = 0; i < 5; i++){
    v += a * vnoise(p);
    p = ROT * p * 2.03;
    a *= 0.53;
  }
  return v;
}

/* domain-warped cloud field: f( p + w·f( p + w·f(p) ) ) */
float cloud(vec2 p, float t, float w){
  vec2 q = vec2(fbm(p + 0.015 * t),
                fbm(p + vec2(5.2, 1.3) - 0.012 * t));
  vec2 r = vec2(fbm(p + w * q + vec2(1.7, 9.2) + 0.021 * t),
                fbm(p + w * q + vec2(8.3, 2.8) - 0.017 * t));
  return fbm(p + w * r);
}

js/sky.js — the noise kit. The rotation matrix between octaves keeps the grid from ever showing.

Time of day is one scalar, uTod, cycling dusk→dawn→dusk over ninety seconds. Every colour in the sky — zenith, horizon, cloud faces, cloud shadows, the glow beneath — is a mix() between a dusk value and a dawn value of the four house hexes. Film grain is a per-fragment hash added in the last two lines of the shader. A meteor crosses roughly every nineteen seconds, but only while the stars are out, and never for reduced-motion guests: it is gated by an uAnim uniform so it cannot freeze mid-streak in a still sky.

Technique two

The descent

Each floor of the hotel declares its own weather as data attributes — cloud coverage and horizon glow. At boot (and whenever fonts settle or the window resizes) the floors are measured into normalized scroll keyframes, and the smoothed scroll value is resampled through them with a smoothstep between neighbours. The scroll itself is lerped each frame, so the descent has the lag of a real lift.

<section class="floor" id="deepwing" data-floor
         data-cov="0.88" data-glow="0.22" …>
stops = sections.map(s => ({
  x: Math.min(Math.max((s.offsetTop + s.offsetHeight * 0.5
       - vh * 0.5) / span, 0), 1),
  cov: parseFloat(s.dataset.cov),
  glow: parseFloat(s.dataset.glow),
})).sort((a, b) => a.x - b.x);

// each frame:
scrollTarget = scrollY / Math.max(docH - vh, 1);
scrollS += (scrollTarget - scrollS) * 0.085;

js/sky.js — floors as weather keyframes. Floor Three (the Deep Wing) is the densest; the Ground Floor breaks out beneath the deck.

The lift panel on the right rail rides the same machinery in reverse: it reads the raw scroll position (the indicator must never lie; the smoothing belongs to the sky alone), lights the floor you are passing, and flips the page chrome to ink — html.daylight — when the lift opens onto the terrace.

Technique three

The veil

Copy sits on a feathered pane of dusk that blurs and darkens the sky behind it. The pane is one ::before with a radial mask sized closest-side, so the mask reaches zero before every box edge — which is the difference between thickened air and a glass card with seams. Its colour is not fixed: the shader’s own time-of-day value is written out as a CSS triplet every frame, and the veil thickens as dawn brightens.

.copy::before{
  background:
    radial-gradient(120% 95% at 28% 18%,
      rgb(var(--veil) / calc(.32 * var(--veil-boost,1))), transparent 66%),
    radial-gradient(150% 125% at 52% 54%,
      rgb(var(--veil) / calc(.62 * var(--veil-boost,1))), transparent 72%);
  backdrop-filter:blur(14px) saturate(1.06);
  mask-image:radial-gradient(closest-side at 50% 48%,
    #000 36%, rgb(0 0 0 / .5) 62%, transparent 94%);
}
const VEIL_DUSK = [30, 26, 52], VEIL_DAWN = [56, 42, 66];
function syncPalette(tod){
  const v = VEIL_DUSK.map((c, i) =>
    Math.round(c + (VEIL_DAWN[i] - c) * tod));
  root.style.setProperty('--veil', `${v[0]} ${v[1]} ${v[2]}`);
  root.style.setProperty('--veil-boost', (1 + 0.45 * tod).toFixed(3));
}

css/reverie.css + js/sky.js — the veil samples the sky it floats in front of.

Housekeeping

Provenance

Imagery
Every pixel is procedural: the sky, clouds, stars, moon, meteor and grain are computed live in one GLSL fragment shader; everything else is CSS. No photography, no stock, no AI-generated media, no raster assets of any kind. The favicon is an inline SVG data URI.
Type
Cormorant Garamond (300/400/500 italic + 400 roman) and Outfit (variable weight), from the fontsource distributions of the Google Fonts originals, vendored locally as latin-subset woff2. No CDN requests, no external requests at all.
Libraries
None. Vanilla HTML, CSS and three small ES modules. WebGL2, raw. Three.js was available but the sky did not need it.
Data
None consulted. The hotel’s registers are fictional; the mathematics is not.
Reduced motion
Honoured in both worlds: CSS kills the transitions and the scroll cue; the shader stops animating and re-draws a still sky only when the page moves. The turndown switch still works — it re-draws once. Verified by hand-audit of the media query and the stillFrame() path.

The register of complaints

Critique log — three passes, kept honestly

Each pass: render real screenshots (desktop top/mid/bottom, mobile, a second time of day), read them like a hostile design director, fix everything found, then add one deliberate complexity.

Pass one the glass card problem

The sky held up; the veils did not. Their masks faded too late, so every copy panel showed straight edges — exactly the floaty glass-card default this project bans. Mobile had two widows in the hero (“goes down.” dangling; “OF SLEEP” alone on line three), and the scroll cue was ivory on light lilac, nearly invisible. Fixed the masks, rebuilt the hero line breaks (text-wrap:balance on mobile, nowrap segments in the meta line), shadowed the cue. Upgrade: the lift panel — a floor indicator on the right rail that rides the sky’s own progress hook and doubles as navigation.

Pass two the indicator lied

The new lift panel lit “Reception” while the viewport showed the Baths: it was reading the smoothed scroll value, which lags by design. Rule extracted: smoothing belongs to the atmosphere, never to instruments. It now reads raw scrollY with an unconditional scroll listener. Upgrade: turndown service — a masthead switch that dims the sky (a uDim uniform that was wired but unused) and slows the drift of the weather to a fifth of its pace, with a little moon that fills as you press it.

Pass three dawn tells the truth

Shooting the second time of day (?t=45, full dawn) exposed everything the dusk had been hiding: the veil boxes were still faintly traceable because a radial mask sized over 100% never actually reaches zero inside the box — replaced with closest-side sizing, which guarantees a zero-alpha perimeter. The cue and lift numerals washed out against the peach, so a brightsky class now flips the low chrome to ink past 72% dawn, and the veils thicken with the light. The resting page-end view had terrace copy colliding with the masthead — the colophon now takes a third of a viewport of open sky first. Upgrade: a meteor, roughly every nineteen seconds, only while the stars are out, for whoever is still awake.