← Back to Vitrine

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

Astro 5 · static outputzero client frameworkinline SVG paintingsPlayfair Display / Inter / Space Monolight + darkreduced-motion aware

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

← Back to Vitrine