[ Parable build guide ]
How Nightcap was built
A speakeasy that never tells you where it is, rendered as a website that shows you everything. The signature is a build-a-drink pour engine: three SVG rects clipped inside a lowball glass, poured layer by layer with requestAnimationFrame, then named, measured and given a poem.
The idea
A speakeasy sells exactly one thing: the feeling of being let in. So the site behaves like the bar. The door in the hero is unmarked and lit from the other side. The menu reads like verse and reshuffles itself, because the room rearranges nightly. And the centrepiece hands you the ritual itself — pick three true things, watch the pour, get a drink with your name on it (well, its name — the bar keeps yours out of it). Dark theme is the room at candle height; light theme is the morning after, shutters open.
The stack — why Astro
- Astro 5, no client framework. The cocktails, build options and house facts are typed arrays in the page frontmatter, rendered to plain HTML at build time. The shipped page carries one script — theme, reveals, the FLIP shuffle and the pour engine — and nothing else.
- Components without a runtime. The door and the glass are
.astrocomponents: reusable at author time, free at run time. No external images anywhere — every visual is inline SVG or CSS. - Cinzel / Karla / Space Mono. Cinzel is engraved-brass-plaque lettering, which is exactly what a bar with no sign would splurge on inside. Karla stays legible at candle height. Space Mono does the ledger work: specs, prices, knock codes, ABV.
Signature technique — the pour engine
The glass is one SVG. Its interior is a <clipPath>, and inside it sit three
rects — spirit, modifier, bitters — each anchored to the glass floor. Pouring a layer means
animating its height up while pinning y = floor − height, so the liquid
rises instead of stretching. Each layer's "floor" is the surface of the one before it, so the
drink stacks the way a layered pour actually would:
const BOTTOM = 238; // inner floor of the glass, SVG units
const HEIGHTS = { spirit: 92, modifier: 44, bitters: 9 };
const setLayer = (rect, floor, h) => {
rect.setAttribute('y', floor - h); // liquid grows upward
rect.setAttribute('height', Math.max(0, h));
};
const slide = (dur, fn, token) => new Promise((done) => {
const t0 = performance.now();
const ease = (t) => 1 - Math.pow(1 - t, 3); // the slow pour
const step = (now) => {
if (token !== pourToken) return done(false); // a newer pour took over
const p = Math.min(1, (now - t0) / dur);
fn(ease(p));
p < 1 ? requestAnimationFrame(step) : done(true);
};
requestAnimationFrame(step);
});
// one drink = three awaited pours, each stacking on the last
let floor = BOTTOM;
for (const part of ['spirit', 'modifier', 'bitters']) {
const f0 = floor;
stream.setAttribute('fill', POUR[part][sel[part]].color);
await slide(DURS[part], (t) => {
const h = HEIGHTS[part] * t;
setLayer(layers[part], f0, h); // the layer rises
stream.setAttribute('height', f0 - h - 10); // the stream shortens
}, token);
floor -= HEIGHTS[part];
} The token is the part that makes it feel solid: every pour increments a counter,
and any in-flight animation frame that notices a newer token quietly abandons itself. Change
your mind mid-pour and the glass drains and starts over — no queued animations, no fighting
layers. The readout is arithmetic, not theatre: ABV is the real weighted average of the three
parts plus 15 ml of melt, and the name and three-line poem are assembled from words each
ingredient owns.
Details that matter
- The first pour waits for you. An IntersectionObserver holds the opening pour until the glass is actually on screen — the flourish never plays to an empty room.
- The stream knows its weight. It narrows with every stage — six SVG units of spirit, four and a half of vermouth, two and a half of bitters — because a dash is not a pour, and the glass should say so.
- Reduced motion still gets the drink. Under
prefers-reduced-motionthe layers land fully poured in one static frame, the stream never renders, and the menu shuffle reorders without gliding. Same information, no motion. - The menu reshuffle is FLIP. Measure every card, shuffle the DOM, then play each card from its old position to its new one with the Web Animations API — layout does the thinking, transforms do the moving.
- Two brasses, not one. Bright brass (
#c69a4c) reads 7:1 on the oxblood-black room but fails on smoke, so the light theme swaps in a deep brass (#7e5c22, 5:1). Same metal, different light. - Content is never hidden without JS. Reveals and the hero rise are gated
behind a
.jsclass set synchronously in<head>; kill JavaScript and the whole page reads, poems included. - The pour card is
aria-live="polite", so screen readers hear the new drink's name, spec and poem after each pour without being interrupted mid-sentence.
The reservation form is a demo — it validates and confirms in place but sends nothing and holds no table. Wire it to your own endpoint (Formspree, a serverless function, an email service) before promising anyone a banquette.
Ship it on GitHub Pages
Astro builds static HTML, so Pages serves it straight from the repo — no Actions workflow. Two config lines do the work:
// astro.config.mjs
export default defineConfig({
site: 'https://bswxyz.github.io',
base: '/nightcap', // project-site path prefix
outDir: './docs', // Pages serves main + /docs
}); Every internal link goes through import.meta.env.BASE_URL so nothing 404s under
the /nightcap/ prefix, and a .nojekyll in public/ keeps
Pages from mangling the output. Then:
npm run build # emits ./docs
git add -A && git commit -m "last call"
gh repo create bswxyz/nightcap --public --source . --push
gh api --method POST /repos/bswxyz/nightcap/pages \
-f 'source[branch]=main' -f 'source[path]=/docs' Nightcap is a design-showcase concept — the bar, the drinks, Marguerite and the door are all fictional. The README maps what a production version would need.
← Back to Nightcap