[ Parable build guide ]
How Yugen was built
An izakaya you enter, not scroll past. The signature is a noren curtain that parts as you scroll — four fabric panels at different parallax depths, split like a doorway curtain — and a shime bowl that assembles itself from named SVG layers as you pick what goes in it.
The idea
Yūgen (幽玄) names the beauty of what's partly hidden — exactly what a noren does to a doorway. So the site makes you do what the restaurant makes you do: push through the curtain. The hero hangs four indigo fabric panels off a rod. Scroll, and they part sideways, lift at different depths, and swing a couple of degrees — the page walks you into the room. The house crest is split across the two centre panels, so the act of scrolling literally opens the mon.
Below the curtain, the room keeps the same rules: everything you see — the bowl, the alley, the lanterns, the shelf, the cat — is inline SVG in the site's own ink. No photography, no image requests, nothing that couldn't ship in a single HTML file.
The stack — why Astro
- Content is data. The menu's fifteen plates, the bowl's thirteen
ingredients, and the weekly hours are typed arrays in
index.astro's frontmatter, rendered to plain HTML at build time. Changing the menu is editing a list, not markup. - Components without a runtime.
Bowl.astroandRoomScene.astroare author-time components — they cost nothing in the shipped bundle. The only JavaScript on the page is one inline script: theme, curtain, reveals, bowl, clock, demo form. - The type does the atmosphere. Shippori Mincho is a Japanese-designed Mincho serif — the brushy contrast carries the lantern-light mood. Karla stays warm and plainspoken for body copy; Space Mono runs the ledger: prices, kcal, hours, captions.
Signature technique #1 — the noren parallax
Each panel is a stretched SVG of fabric (a gradient, a weave pattern, a wavy hem, a dashed
stitch line) with two data attributes: data-depth for how fast it rises, and
data-part for which way and how hard it swings open. One rAF-throttled scroll
handler does the walking:
const drift = () => {
ticking = false;
const heroH = hero ? hero.offsetHeight : innerHeight;
const y = clamp(scrollY, 0, heroH);
for (const p of panels) {
const depth = +p.dataset.depth; // how deep in the doorway this panel hangs
const part = +p.dataset.part; // negative parts left, positive parts right
const tx = part * y * 0.24; // the curtain opens...
const ty = -y * depth; // ...and lifts, each panel at its own rate
const rot = part * Math.min(y * 0.005, 2);
p.style.transform =
'translate3d(' + tx + 'px,' + ty + 'px,0) rotate(' + rot + 'deg)';
}
noren.style.opacity = String(1 - (y / heroH) * 0.55);
};
addEventListener('scroll', () => {
if (!ticking) { ticking = true; requestAnimationFrame(drift); }
}, { passive: true }); Three nested wrappers keep the transforms from fighting: the outer .panel belongs
to the scroll handler, a middle .panel-open runs the load-in (the curtain starts
drawn together and parts on a 1.4s --ease-noren curve), and the inner
.panel-cloth carries an idle CSS sway — a draft through the doorway — on
desynchronised 7–8s loops.
Signature technique #2 — the bowl that builds itself
Every ingredient in the shime bowl is a named SVG group — #l-base-udon,
#l-top-egg, and so on — hidden by default and revealed with an .on
class that lands it with a small overshoot (--ease-pop). The form inputs carry
price, kcal, and their layer's id, so the whole builder is one loop:
const build = () => {
let total = 0, kcal = 0;
const names = [];
bowlForm.querySelectorAll('input').forEach((inp) => {
const layer = document.getElementById(inp.dataset.layer);
if (inp.checked) {
total += +inp.dataset.price;
kcal += +inp.dataset.kcal;
names.push(inp.dataset.name);
if (layer) layer.classList.add('on'); // the ingredient lands
} else if (layer) {
layer.classList.remove('on');
}
});
priceEl.textContent = '$' + total.toFixed(2);
}; The controls are native radios and checkboxes styled as cards — keyboard and screen-reader
behaviour comes free — and the running total sits in an aria-live region. The
default bowl ships pre-assembled in the HTML, so with JavaScript off you still see dinner,
not an empty dish.
Details that matter
- Reduced motion is a real design, not an afterthought. Under
prefers-reduced-motionthe curtain hangs still and parted, the sway and steam stop at one honest frame, ingredients appear instantly, counters print their final number, and the "open now" lantern dot stops pulsing. - Nothing hides without JavaScript. Reveals and the hero's clipped-line rise
are gated behind a
.jsclass set synchronously in<head>. No script, no blank page. - Both rooms, one switch. Dark is the room at night; light is the washi menu
before service. Every colour is a token on
:root[data-theme], persisted tolocalStorage("yugen-theme")and bootstrapped inline before first paint — no flash. - The clock knows the hours. A tiny weekly table drives the "open now" pill and highlights today's row — closed Mondays, because the grill rests.
- Zero image requests. The washi grain is one inline
feTurbulencedata-URI; everything else is SVG ridingcurrentColor, so the art re-inks itself when the theme flips.
The reservation form is a demo — it validates and confirms in place but sends nothing. Wire it to a real endpoint (Formspree, a serverless function, the counter's phone) before promising anyone a seat.
Ship it on GitHub Pages
The whole trick is three lines of config: build into ./docs and let Pages serve
the main branch. Every internal link goes through import.meta.env.BASE_URL, so the
site works at /yugen/ without a single hardcoded path.
// astro.config.mjs
export default defineConfig({
site: 'https://bswxyz.github.io',
base: '/yugen',
outDir: './docs', // Pages serves main + /docs — no Actions needed
}); npm run build # emits ./docs
git push # Pages: main branch, /docs folder ← Back to Yugen