# Menu Zooms Into Each Course and Back Out

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/menu-zooms-into-each-course-and-back-out).

- Page, with a live preview: https://shadcn.exchange/r/weinc/menu-zooms-into-each-course-and-back-out
- Screenshot: https://shadcn.exchange/shot/weinc/menu-zooms-into-each-course-and-back-out/light.png
- Registry item: https://we.inc/r/menu-zooms-into-each-course-and-back-out.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/menu-zooms-into-each-course-and-back-out.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/menu-zooms-into-each-course-and-back-out.tsx`

```tsx
import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent } from "react";
import { useReducedMotion } from "framer-motion";

// Same styles as the plain HTML version; the comment at the top of STYLES names the values to change.
const STYLES = `
  /* Change these: --w6zom-navy, --w6zom-cream, --w6zom-brass (accent), and the 1.12 / .9 scales in the
     w6zom keyframes (how deep the zoom goes). */
  .w6zom { --w6zom-navy: #0f1a2b; --w6zom-cream: #f0e8da; --w6zom-brass: #d6b77f;
    height: 600px; display: flex; flex-direction: column; background: var(--w6zom-navy); color: var(--w6zom-cream);
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overflow: hidden; }
  .w6zom *, .w6zom *::before { box-sizing: border-box; }
  .w6zom-top { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 4px 20px; padding: 20px clamp(16px, 5vw, 56px); border-bottom: 1px solid #24324a; }
  .w6zom-brand { margin: 0; font-family: Georgia, "Iowan Old Style", serif; font-size: 1.25rem; }
  .w6zom-brand em { color: var(--w6zom-brass); }
  .w6zom-when { margin: 0; font-size: .8125rem; color: #b6bdc8; }
  .w6zom-stage { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; }
  .w6zom-view { padding: 28px clamp(16px, 5vw, 56px) 30px; }
  .w6zom-view[hidden] { display: none; }
  .w6zom-eye { margin: 0; font-size: .75rem; letter-spacing: .18em; text-transform: uppercase; color: var(--w6zom-brass); }
  .w6zom h2 { margin: 10px 0 0; font-family: Georgia, "Iowan Old Style", serif; font-weight: 400; font-size: clamp(1.6rem, 3.4vw, 2.4rem); line-height: 1.12; letter-spacing: -.01em; }
  .w6zom-menu h2 { font-size: clamp(2.2rem, 5vw, 3.4rem); }
  .w6zom h2:focus { outline: none; }
  .w6zom a:focus-visible, .w6zom h2:focus-visible { outline: 2px solid var(--w6zom-brass); outline-offset: 3px; }
  .w6zom-list { margin: 22px 0 0; padding: 0; list-style: none; border-top: 1px solid #2b3a54; }
  .w6zom-list a { display: grid; grid-template-columns: 5.5rem 1fr auto; align-items: baseline; gap: 6px 18px; padding: 18px 4px; color: inherit; text-decoration: none; border-bottom: 1px solid #2b3a54; border-radius: 4px; }
  .w6zom-list small { font-size: .75rem; letter-spacing: .14em; text-transform: uppercase; color: #aab3c1; }
  .w6zom-list b { font-family: Georgia, serif; font-weight: 400; font-size: clamp(1.1rem, 2.2vw, 1.45rem); }
  .w6zom-list span { font-size: .8125rem; color: var(--w6zom-brass); }
  .w6zom-list span::after { content: "  +"; }
  .w6zom-list a:hover b { text-decoration: underline; text-underline-offset: 5px; text-decoration-color: var(--w6zom-brass); }
  .w6zom-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px 24px; margin-top: 28px; }
  .w6zom-foot p { margin: 0; font-size: .9375rem; color: #c3cad5; }
  .w6zom-foot a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 22px; border-radius: 999px; background: var(--w6zom-brass); color: #16202f; font-size: .9375rem; font-weight: 600; text-decoration: none; }
  .w6zom-foot a:hover { background: #e3c891; }
  .w6zom-view img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 4px; background: #1c2a40; }
  .w6zom-txt { margin-top: 20px; }
  .w6zom-txt > p:not([class]) { margin: 12px 0 0; max-width: 30rem; font-size: 1rem; line-height: 1.6; color: #d3cbbd; }
  .w6zom-meta { margin: 14px 0 0; font-size: .8125rem; color: #aab3c1; }
  .w6zom-nav { display: flex; flex-wrap: wrap; gap: 6px 26px; margin: 18px 0 0; }
  .w6zom-nav a { display: inline-flex; align-items: center; min-height: 40px; color: var(--w6zom-brass); font-size: .9375rem; text-underline-offset: 5px; border-radius: 4px; }
  @media (max-width: 560px) { .w6zom-list a { grid-template-columns: 1fr; } .w6zom-list span { display: none; } }
  @media (min-width: 860px) {
    .w6zom-view:not(.w6zom-menu) { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 44px; align-items: center; padding-top: 40px; }
    .w6zom-view[hidden] { display: none; }
    .w6zom-txt { margin-top: 0; }
  }

  /* Opening a course zooms into the page (old grows and fades, new settles from slightly small);
     going back to the menu zooms out. Only the stage carries a name, and only during the change.
     Separate pages: add  @view-transition { navigation: auto; }  and name the main element on each page. */
  ::view-transition-group(w6zom-stage) { animation: none; overflow: clip; }
  ::view-transition-old(w6zom-stage), ::view-transition-new(w6zom-stage) { mix-blend-mode: normal; }
  ::view-transition-old(w6zom-stage) { animation: w6zom-past .3s cubic-bezier(.3, 0, .3, 1) both; }
  ::view-transition-new(w6zom-stage) { animation: w6zom-near .55s .08s cubic-bezier(.2, .8, .2, 1) both; }
  :root[data-w6zom-dir="out"]::view-transition-old(w6zom-stage) { animation-name: w6zom-away; }
  :root[data-w6zom-dir="out"]::view-transition-new(w6zom-stage) { animation-name: w6zom-back; }
  @keyframes w6zom-past { to { opacity: 0; transform: scale(1.12); } }
  @keyframes w6zom-near { from { opacity: 0; transform: scale(.9); } }
  @keyframes w6zom-away { to { opacity: 0; transform: scale(.9); } }
  @keyframes w6zom-back { from { opacity: 0; transform: scale(1.12); } }
  .w6zom[data-fb="in"] .w6zom-view:not([hidden]) { animation: w6zom-near .45s cubic-bezier(.2, .8, .2, 1) both; }
  .w6zom[data-fb="out"] .w6zom-view:not([hidden]) { animation: w6zom-back .45s cubic-bezier(.2, .8, .2, 1) both; }
  @media (prefers-reduced-motion: reduce) {
    ::view-transition-group(w6zom-stage), ::view-transition-old(w6zom-stage), ::view-transition-new(w6zom-stage) { animation: none; }
    .w6zom .w6zom-view { animation: none !important; }
  }
`;

const COURSES = [
  { label: "Start", name: "Crab cake, pea purée, crème fraîche", next: "Next course",
    src: "https://images.pexels.com/photos/23644633/pexels-photo-23644633.jpeg?auto=compress&cs=tinysrgb&w=1000",
    alt: "A small golden crab cake topped with cream on a bed of green purée, on a white plate",
    text: "Brown and white crab from the harbour boats, bound with a little potato and fried to order in brown butter.",
    meta: "With a glass of Picpoul, £8 · Contains shellfish, egg, milk" },
  { label: "Main", name: "Grilled prawns, carrot purée, pickled shallot", next: "Next course",
    src: "https://images.pexels.com/photos/24289165/pexels-photo-24289165.jpeg?auto=compress&cs=tinysrgb&w=1000",
    alt: "A grilled prawn on a mound of grains beside a swirl of orange purée, on a teal plate",
    text: "Prawns over charcoal with a sweet roast carrot purée, sharp shallot and toasted spelt to soak up the juices.",
    meta: "With a glass of Albariño, £9 · Contains shellfish, gluten" },
  { label: "Pudding", name: "Brown butter sponge, mango, dark chocolate", next: "Back to the start",
    src: "https://images.pexels.com/photos/34520947/pexels-photo-34520947.jpeg?auto=compress&cs=tinysrgb&w=1000",
    alt: "A small sponge pudding with cubes of mango and a stroke of chocolate sauce on a speckled plate",
    text: "A warm sponge baked in small tins, ripe mango and a spoon of bitter chocolate sauce poured at the table.",
    meta: "With a glass of late harvest Riesling, £7 · Contains gluten, egg, milk" },
];
const DEMO = [[700, 1], [1700, 2], [1700, 0], [2600, 3], [1700, 0]]; // idle demo: [wait ms, view]; [] turns it off
const useIso = typeof window !== "undefined" ? useLayoutEffect : useEffect;

export default function MenuZoomsIntoEachCourseAndBackOut() {
  const reduce = useReducedMotion();
  const [cur, setCur] = useState(0); // 0 is the menu, 1 to 3 are the courses
  const [fb, setFb] = useState("");
  const rootRef = useRef<HTMLDivElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const heads = useRef<(HTMLHeadingElement | null)[]>([]);
  const rows = useRef<(HTMLAnchorElement | null)[]>([]);
  const st = useRef({ cur: 0, busy: false, auto: DEMO.length > 0 });
  const afterCommit = useRef<null | (() => void)>(null);

  useIso(() => { const f = afterCommit.current; afterCommit.current = null; f?.(); }, [cur]);

  // Going to the menu zooms out; going to any course zooms in.
  const go = (i: number, person: boolean) => {
    const s = st.current;
    if (i === s.cur || s.busy) return;
    const dir = i ? "in" : "out", was = s.cur;
    s.cur = i;
    const landed = () => {
      if (stageRef.current) stageRef.current.scrollTop = 0;
      if (person) (i ? heads.current[i] : rows.current[was])?.focus({ preventScroll: true });
    };
    const doc = document as any;
    if (reduce || matchMedia("(prefers-reduced-motion: reduce)").matches) { afterCommit.current = landed; setCur(i); return; }
    if (doc.startViewTransition && stageRef.current) {
      s.busy = true;
      const stage = stageRef.current, html = document.documentElement;
      stage.style.setProperty("view-transition-name", "w6zom-stage");
      html.setAttribute("data-w6zom-dir", dir);
      doc.startViewTransition(() => new Promise<void>((done) => { afterCommit.current = () => { landed(); done(); }; setCur(i); })).finished.finally(() => {
        stage.style.removeProperty("view-transition-name"); html.removeAttribute("data-w6zom-dir"); s.busy = false;
      });
    } else { afterCommit.current = landed; setFb(dir); setCur(i); }
  };
  const goRef = useRef(go);
  goRef.current = go;

  // Idle demo: opens courses and returns to the menu while the piece is on screen and untouched.
  useEffect(() => {
    const root = rootRef.current;
    if (!root || reduce || !DEMO.length) return;
    let timer = 0, step = 0, seen = false;
    const tick = () => {
      timer = 0;
      if (!st.current.auto || !seen) return;
      goRef.current(DEMO[step][1], false);
      step = (step + 1) % DEMO.length;
      timer = window.setTimeout(tick, DEMO[step][0]);
    };
    // The demo waits for the page to finish loading, so the first thing anyone sees is the resting view.
    const kick = () => { if (seen && st.current.auto && !timer && document.readyState === "complete") timer = window.setTimeout(tick, DEMO[step][0]); };
    window.addEventListener("load", kick);
    const io = new IntersectionObserver((e) => {
      seen = e[0].isIntersecting;
      if (seen) kick(); else { clearTimeout(timer); timer = 0; }
    });
    io.observe(root);
    const stop = () => { st.current.auto = false; };
    root.addEventListener("pointerdown", stop);
    root.addEventListener("keydown", stop);
    return () => { io.disconnect(); clearTimeout(timer); window.removeEventListener("load", kick); root.removeEventListener("pointerdown", stop); root.removeEventListener("keydown", stop); };
  }, [reduce]);

  const link = (i: number) => (e: MouseEvent) => { e.preventDefault(); st.current.auto = false; go(i, true); };

  return (
    <div className="w6zom" ref={rootRef} data-fb={fb || undefined}>
      <style>{STYLES}</style>
      <header className="w6zom-top">
        <p className="w6zom-brand">Saltmarsh <em>Kitchen</em></p>
        <p className="w6zom-when">Dinner, Wednesday to Saturday</p>
      </header>
      <div className="w6zom-stage" ref={stageRef}>
        <section className="w6zom-view w6zom-menu" aria-labelledby="w6zom-h0" hidden={cur !== 0}>
          <p className="w6zom-eye">Set menu, three courses, £46</p>
          <h2 id="w6zom-h0" tabIndex={-1} ref={(el) => { heads.current[0] = el; }}>Tonight's menu</h2>
          <ol className="w6zom-list">
            {COURSES.map((c, k) => (
              <li key={c.label}>
                <a href="#" ref={(el) => { rows.current[k + 1] = el; }} onClick={link(k + 1)}><small>{c.label}</small><b>{c.name}</b><span>Open course</span></a>
              </li>
            ))}
          </ol>
          <div className="w6zom-foot"><p>Vegetarian set on request. Tables for up to eight, last orders at 9pm.</p><a href="#">Book a table</a></div>
        </section>
        {COURSES.map((c, k) => (
          <section key={c.label} className="w6zom-view" aria-labelledby={`w6zom-h${k + 1}`} hidden={cur !== k + 1}>
            <img src={c.src} alt={c.alt} width={600} height={400} />
            <div className="w6zom-txt">
              <p className="w6zom-eye">{c.label}</p>
              <h2 id={`w6zom-h${k + 1}`} tabIndex={-1} ref={(el) => { heads.current[k + 1] = el; }}>{c.name}</h2>
              <p>{c.text}</p>
              <p className="w6zom-meta">{c.meta}</p>
              <p className="w6zom-nav">
                <a href="#" onClick={link(0)}>Whole menu</a>
                <a href="#" onClick={link(((k + 1) % COURSES.length) + 1)}>{c.next}</a>
              </p>
            </div>
          </section>
        ))}
      </div>
    </div>
  );
}
```
