Off Hours

Back to the lodge

FGField guide

How this page was built.

OFF HOURS is a fictional device-free lodge, and its website keeps the house rule: there is no JavaScript on it. None deferred, none inlined, none for analytics. Every interaction you met on the way here is CSS and HTML doing their own work. This guide shows the seams on purpose.

Script tags
0
Console errors
0
Stylesheets
1
Review passes
3

01The brief

The constraint is the concept.

A retreat that takes your phone away should not greet you with a megabyte of tracking scripts. So the site ships none, and the footer says so where a spa site would put its Instagram. The rest of the brief: Owens Valley high desert, hard light instead of soft wellness, real numbers instead of adjectives, and modern CSS carrying every moment that would normally get a framework: a sun that crosses the sky on scroll, a room chooser, a folding itinerary, a form that argues back politely.

Palette, strict: bone, terracotta, juniper, high-noon gold. Type: Cormorant Garamond over Karla. Everything on this page is built from those six ingredients plus geometry. When the demo browser is Chromium the timelines run; in browsers without scroll-driven animations the page holds a composed mid-morning still and loses nothing it needs.

bone#efe9df
terracotta#b4552d
juniper#2e4034
high noon#d9a441

Cormorant Garamond, 600, for anything that speaks.

Karla, 400 and 700, for anything that informs. Numerals stay oldstyle in the serif: 104, 4300, 6:58.

02Plate one

The sun runs on a scroll timeline.

The hero is a 260vh section with a sticky 100vh sky inside it. The section carries a named view timeline, and its contain range maps exactly to the stretch where the sky is pinned: progress 0 is dawn, progress 1 is high noon. The sun's waypoints live in custom properties so the arc can be re-aimed per breakpoint (on phones it stays left of the masthead). Sky, ridge, valley and sun colors are registered with @property, so the same timeline retints the whole scene while the disc travels.

Scrubbed animations here run linear on purpose: scroll position is the clock, and a progress mapping that eases would lie about where you are. The shaping lives in the keyframe values instead. One private joke rides along: the lodge's one lit window turns off by mid-morning, on the same timeline.

styles.css · the day, verbatim
.hero { height: 260vh; view-timeline: --day block; }
.sky  { position: sticky; top: 0; height: 100vh; }

.sun {
  animation-name: sun-arc;
  animation-fill-mode: both;
  animation-timeline: --day;
  animation-range: contain 0% contain 90%;
}

@keyframes sun-arc {
  0%   { translate: var(--sun-dawn); scale: 1.22; --sun-core: #eeb35e; }
  35%  { translate: var(--sun-mid);  scale: 1.12; }
  70%  { translate: var(--sun-late); scale: 1.04; }
  100% { translate: var(--sun-noon); scale: 1;    --sun-core: #f8eec9; }
}

03Plate two

The rooms run on :has().

Three radio inputs, visually hidden but fully focusable, sit above the layout. Labels anywhere in the grid select them, and :has() lets the form react at a distance: the matching panel fades up with its rate and specs, the photograph pans to a different crop of the same morning, the caption swaps. Radios were the honest choice because the browser already knows how to arrow-key through them; the CSS only decorates state it gets for free.

styles.css · pick a room, the panel and rate change
.rooms:has(#r-cabin:checked) .panel-cabin {
  opacity: 1; visibility: visible; translate: 0 0;
}
.rooms:has(#r-cabin:checked) label[for="r-cabin"] .r-name {
  color: var(--ink); translate: 6px 0;
}
.rooms:has(#r-cabin:checked) .rooms-photo img {
  object-position: 50% 82%;   /* same photo, later light */
}
.rooms:has(#r-cabin:focus-visible) label[for="r-cabin"] {
  outline: 2px solid var(--terra); outline-offset: 3px;
}

04Plate three

The days fold like a map.

The itinerary is three native details elements sharing a name, which gives exclusive-accordion behavior with no listeners. The unfold is two layers: ::details-content interpolates its block size (via interpolate-size: allow-keywords), while the timetable inside tips forward from a slight rotateX, the way a folded map relaxes when you open it. Browsers that cannot animate the height simply open instantly, which is what paper does when you are in a hurry.

styles.css · the unfold
:root { interpolate-size: allow-keywords; }

.day::details-content {
  block-size: 0; overflow: clip;
  transition: block-size 0.6s var(--e-out),
              content-visibility 0.6s allow-discrete;
}
.day[open]::details-content { block-size: auto; }

.day[open] .day-body {
  animation: unfold 0.8s var(--e-out) both;
  transform-origin: 50% 0;
}
@keyframes unfold {
  from { transform: perspective(1100px) rotateX(-7deg); opacity: 0.35; }
}

05Plate four

The form argues politely.

Validation is the platform's: :user-invalid waits until you have actually touched a field, each field carries its own error copy in a data-error attribute that attr() prints, and the submit button's label changes when form:valid flips, from "Send the inquiry" to a promise that it is the last screen for a while. Submission is a plain mailto:. No endpoint, no queue, no cookie banner downstream.

styles.css · state without listeners
.field input:user-invalid {
  border-color: var(--terra);
  box-shadow: 0 1.5px 0 0 var(--terra);
}
.field:has(:user-invalid)::after {
  content: attr(data-error);   /* each field brings its own apology */
}
.field:has(:user-valid) label::after { content: "\2713"; }

.inq-form:valid .bt-idle  { display: none; }
.inq-form:valid .bt-ready { display: inline; }

06Plate five

The sundial is the whole page.

Bottom right, a small dial repeats the hero's conceit at the scale of the entire document: a gold dot rides an offset-path arc, driven by scroll(root), so the page itself becomes one day. Dawn at the masthead, sunset at the colophon. It is also the back-to-top link, because the day starts over. Progress meters do not get easing; this one is linear and honest.

The rest of the scenery is procedural too: ridgelines are two four-point-per-mile SVG paths with a long-wavelength second set for phones, the stars are circles with staggered twinkle, the paper grain is one feTurbulence tile in a data URI, and the elevation profile in The Land draws itself on a view timeline when it enters, with survey gridlines that only appear on hover.

styles.css · one day, root to footer
.dial-dot {
  offset-path: path("M10 38 A 36 30 0 0 1 82 38");
  animation-name: dial-run;
  animation-timing-function: linear;  /* a progress meter must not lie */
  animation-timeline: scroll(root);
}
@keyframes dial-run {
  from { offset-distance: 0%; }
  to   { offset-distance: 100%; }
}

07Stillness

Composed first, moving second.

Every scroll-driven effect sits behind @supports (animation-timeline: ...) and prefers-reduced-motion: no-preference, and every animated element has a designed resting pose underneath: the sun holds a mid-morning position, reveals are simply visible, the dial excuses itself entirely. Reduced motion is not a degraded page here; it is the same page, photographed instead of filmed.

Layout never depends on the animations. Firefox and Safari get the full composition, the room chooser, the folding days and the form states, all of which are plain selector support. The one thing they wait for is the moving sun, and the still one is set at a good hour.

08Provenance

Four photographs, disclosed.

The retreat is fictional, and so is its photography: the four images below were AI-generated with Nano Banana Pro via Fal for this project, then treated as the lodge's archive. They are the only raster assets on the site. Everything else you saw, ridgelines, sun, stars, grain, the elevation profile, is CSS or inline SVG drawn by hand.

  • Adobe lodge at golden hour, sagebrush in front, dark mountains behind.lodge.webp · the premise
  • Small bedroom in morning light with a wool blanket and a wooden chair.interior.webp · the rooms
  • Lone juniper on a rocky ridge under a deep blue sky.juniper.webp · the land
  • Stone-lined hot spring steaming at dusk under a terracotta sky.spring.webp · the water

09The passes

What review caught.

  1. Pass one. A flexbox figcaption was reordering its own sentence fragments; the sun wore a faint square halo from a filter shadow; at 390px the ridge paths compressed into sawteeth and the noon sun parked behind the masthead. Fixed with block captions, circular box-shadows, a second long-wavelength ridge path set, and per-breakpoint sun waypoints. Upgrade shipped: the paper grain.
  2. Pass two. Interaction proof under a real browser: the chooser, the exclusive accordion, trusted-input form errors, reduced-motion stills. The resting sun revealed a pale gap ring between disc and glow; the section backgrounds stepped where they should ramp. Upgrade shipped: the sundial.
  3. Pass three. Continuity pass end to end at 1440, 1024 and 390, widow hunt on every display line, and the receipts below re-run from scratch. Upgrade shipped: the self-drawing elevation profile with hover gridlines.

10Receipts

Trust, then verify.

run against the deployed folder
$ grep -ri "<script" sites/offhours/
(no results, and this page is inside that folder)

$ shot /offhours/ at 1440x900 and 390x844
{"errors": []}   at 0, 700, 1400, and every section below

The footer of the lodge says it plainly and this page holds to it: no scripts here either. Walk back out into the light.