← Back to Yugen

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

Astro 5 · zero client frameworkall art inline SVG Shippori Mincho / Karla / Space Monolight + darkreduced-motion honest

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

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

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