[ Parable build guide ]
How Vitrine was built
A temporary exhibition, hung on the web. The signature is a pair: gallery-wall parallax — framed works drifting at different depths as you scroll, like walking past a hung wall — and an SVG floor plan that is also the navigation, with a visitor dot that keeps your place.
The idea
Museums already solved this layout problem on real walls: big type on the title wall, works in a deliberate order, a plan by the door, a label beside every frame. Vitrine borrows the whole system. The exhibition is fictional — eleven invented painters converging on one blue — but every convention is real: wall text written in curatorial voice, plaques with medium and lender, room numerals, timed entry.
The one thing a web page has that a wall doesn't is scroll. So scroll became the walk: works hang at different depths and drift past each other as you move, and the floor plan tracks which room you're standing in.
The stack
An Astro 5 project that renders to plain static HTML — no client framework, no islands.
The paintings are inline SVG (an Artwork.astro component with ten hand-drawn
variants), the plan is inline SVG, and all interactivity is one vanilla script inlined into
the page. Astro earns its keep as a templating layer: the works are a data array in
frontmatter, so a wall is a map() and a plaque is a component slot, and the
whole show rebuilds from data.
Type is Playfair Display — a didone with real wall-text authority — with Inter for body and Space Mono for everything a museum sets in small caps: dates, mediums, lenders, room names.
Signature technique — the wall that walks with you
Each framed work — and each room's giant watermark numeral, hung deepest of all — carries a
data-depth between −0.06 and +0.08. On scroll, the element is offset by its
distance from the viewport's centre times that depth — positive depths drift down as you
approach (hung close), negative drift up (hung deep). The trick that keeps
it honest: measure layout from offsetTop, not
getBoundingClientRect(). Rects include the transform you just applied,
which feeds back into the next frame; offset chains never do.
const measure = () => {
for (const w of works) {
let y = 0, n = w.el;
while (n) { y += n.offsetTop; n = n.offsetParent; }
w.mid = y + w.el.offsetHeight / 2; // layout truth — transforms can't pollute it
}
};
const drift = () => {
const mid = scrollY + innerHeight / 2;
for (const w of works)
w.el.style.transform =
'translate3d(0,' + ((mid - w.mid) * w.depth).toFixed(1) + 'px,0)';
}; Writes are batched through one requestAnimationFrame per scroll event, and the
set is re-measured on resize and after webfonts load (fonts change layout height).
The floor plan is the same idea in reverse. Each room on the SVG plan is a real
<a href="#room-i"> — keyboard-focusable, functional with JavaScript
disabled. JS upgrades it: smooth scroll, and an IntersectionObserver whose
rootMargin: '-35% 0px -55% 0px' collapses the viewport to a band across its
middle, so whichever room section crosses that band is "where you are". The visitor dot walks
the plan to match via a CSS transition on its transform.
Details that matter
- The paintings keep their pigment. Theme tokens flip the walls, frames and mats between the daylit gallery and the evening hang — but the SVG works use fixed colours. The lighting changes; the paintings don't.
- Content is never hidden without JS. Reveals and the hero rise are gated
behind a
.jsclass set synchronously in<head>. No JavaScript, no blank page — and the plan still navigates, because its rooms are anchors. - Reduced motion stands still. Under
prefers-reduced-motionthe parallax loop never starts, reveals resolve instantly, the dot's pulse ring is removed, and plan navigation scrolls without animation. Everything works; nothing drifts. - Base-path discipline. Every internal link goes through
import.meta.env.BASE_URL, so the site works at/vitrine/on GitHub Pages and at/in dev without edits. - Plaques are alt text done twice. The visible plaque says what a label
says (artist, title, medium, lender); each SVG's
aria-labelsays what a visitor sees. Different jobs, both done.
The timed-entry form is a demo — it validates and confirms in place but books nothing. Wire it to a ticketing service or your own endpoint before selling entry to an exhibition that exists.
Ship it on GitHub Pages
The Astro config sets base: '/vitrine' and builds into ./docs, so
Pages can serve straight from the main branch with no action or workflow.
npm run build # writes the static site to ./docs
git add -A && git commit -m "build"
gh repo create bswxyz/vitrine --public --source . --push
gh api --method POST /repos/bswxyz/vitrine/pages \
-f 'source[branch]=main' -f 'source[path]=/docs' The empty public/.nojekyll ships with the build and keeps Pages from running
the output through Jekyll.