# QR Table Menu Template

> A phone-first digital menu made to open from a QR code at the table, with a sticky category bar that crossfades between sections and dietary chips that filter every dish at once.

Canonical: https://gsapvault.com/templates/qr-menu-template
Live demo: https://gsapvault.com/demos/qr-menu-template/index.html

| Property | Value |
|----------|-------|
| Type | template |
| Tier | free |
| Price | Free |
| Difficulty | intermediate |
| Plugins | ScrollTrigger |
| Techniques | tabs, filter, click-toggle, scroll-reveal, stagger |
| Uses Lenis | Yes |

## Overview

A complete, deployable digital menu for a cafe or small eatery, built to be scanned from a QR code and read on a phone. It is a functional menu artifact rather than a marketing site: a compact header with hours and Wi-Fi, a sticky category bar, dietary filters, four menu sections, and a footer with the address and allergen note.

The signature is the category bar. Tapping Brunch, Small Plates, Sweets or Drinks crossfades to that section and staggers its dishes in, and the Veg / Vegan / GF chips filter every section at once, fading out the dishes that do not qualify. It is designed at 375px first and widens to two columns on a larger screen.

The whole menu is readable with JavaScript switched off: every section stacks into one document and the category bar degrades to plain in-page anchor links, which is the fallback that matters most for something a hungry guest opens at a table. Making it yours is editing plain list markup, dish by dish, and changing six colour tokens to reskin.

## Features

- Complete deployable one-page menu: compact header with hours and Wi-Fi, sticky category bar, dietary filters, four sections and a footer
- Designed phone-first for a QR scan at 375px, widening to a two-column dish grid on larger screens
- Category bar led by an All view that shows the whole menu with every section titled; tapping a single category crossfades to just that section and staggers its dishes in
- Dietary filter chips (Veg / Vegan / GF) that filter every section at once and AND together, so Veg + GF shows only dishes that are both
- A filtered-out dish fades and leaves the list, and a section emptied by the filters shows a short note in its place
- Tap any dish to reveal its calories and allergens: the details sit in the markup and show plainly with no JavaScript, then collapse behind an accessible disclosure button when it runs
- Dishes carry their own diet tokens in markup, so a vegan dish counts as vegetarian without a hand-kept second list
- Full menu readable with JavaScript off: sections stack and the category bar degrades to anchor links
- Proper tablist keyboard support: arrow keys, Home and End, roving tabindex
- Two-token type system, Yeseva One over Mukta, and a six-value colour block that reskins the whole menu
- Reduced-motion path that switches categories and filters instantly, with every dish left visible
- Lenis smooth scrolling wired in and fully optional
- No images and no build step: designed in CSS and one inline SVG mark, deployable as static files

## Use Cases

- Cafes, coffee shops and all-day eateries putting a QR code on the table
- Bars, bakeries and delis that want a menu guests read on their own phone
- Pop-ups, market stalls and supper clubs that change dishes often and need to edit list markup, not a design
- Any small food business replacing a laminated card or a PDF menu with something legible on a phone
- Venues that need dietary filtering front and centre for guests with allergies or preferences

## How It Works

### Structure

One script runs every section inside a single gsap.context with gsap.matchMedia, keyed only on reduced motion so exactly one branch runs on any device. Each module guards its own lookups, so a buyer can delete a whole category or a single dish and the rest still initialises. The plugin is registered inside the same guard that adds the has-js class, so a partial CDN failure degrades to the full stacked menu rather than a page with every section but one collapsed.

### The category bar

The bar opens on All, which shows every category stacked with its title as a divider, the same layout a no-JS visitor gets. Tapping a single category toggles a class on the document root that collapses the menu to that one section and crossfades its dishes in; tapping All brings the whole menu back. Tabs are paired to sections by name, not index, so deleting a category never shifts the rest out of step.

### Dietary filters

Each dish carries its diet tokens in a data attribute, and a vegan dish is tagged vegetarian too since it qualifies. A chip is a toggle, and several active chips AND together. A dish that no longer matches fades out and is removed from the flow so the list closes up, and the filter state persists as you move between categories. A section left with nothing showing displays a short note in place of an empty list.

### Dish details

Each dish carries a calories-and-allergens block in its markup. With no JavaScript it just shows, like the rest of the menu. Under JavaScript the dish name becomes a disclosure button, with aria-expanded, aria-controls and full keyboard support, and the block collapses and opens on a tap anywhere on the row. It is pre-collapsed in CSS before the script runs so nothing flashes, and GSAP animates it open and shut, or toggles it instantly under reduced motion. The calorie and allergen values are placeholders a buyer edits dish by dish.

### Reduced motion

Under prefers-reduced-motion the same tabs and filters still work, but instantly: no crossfade, no stagger, no fade on a dish leaving. Every dish that should be visible is visible with no tween having to finish first, covered by both the CSS media query and the matchMedia branch.

### Without JavaScript

This is the fallback that matters for a menu. With scripting off, nothing adds the has-js class, so every section stays stacked and visible and the category bar is a set of in-page anchor links that jump to each section. The dietary chips are simply inert, and every dish shows in full, calories and allergens included, because a dish and its details are only ever hidden by a class that JavaScript adds.

## Documentation

A phone-first digital menu made to be opened from a QR code at the table. A compact header with hours and Wi-Fi, a sticky category bar that crossfades between sections, dietary filter chips, four menu sections where any dish taps open to show its calories and allergens, and a footer with the address and allergen note. Built as static files with no build step: one HTML file, one stylesheet, one script, and GSAP from a CDN.

The demo brand is **Sorrel**, a fictional all-day cafe. Replace the copy, dishes and prices with your own.

## Quick Start

1. Upload the whole `qr-menu-template` folder to any static host.
2. Point your QR code at `index.html` (or the folder URL).
3. Edit the header, the dishes and the footer in `index.html`.
4. To reskin, change the six colour tokens in `assets/style.css` (see **Palette**).

That is the entire setup. The GSAP and Lenis `<script>` tags near the bottom of `index.html` load from a CDN; leave them as they are.

## Editing the menu

Everything is plain list markup. Each category is a `<section class="panel">` and each dish is one `<li class="item">`.

**Add or edit a dish** inside any `.dishes` list:

```html
<li class="item" data-item data-diet="veg gf">
  <div class="item__head">
    <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Dish name</button></h3>
    <span class="item__leader" aria-hidden="true"></span>
    <span class="item__price">£0.00</span>
  </div>
  <p class="item__desc">One line describing the dish.</p>
  <ul class="diet">
    <li class="diet__tag diet__tag--v">Veg</li>
    <li class="diet__tag diet__tag--gf">GF</li>
  </ul>
  <div class="item__more" data-more>
    <span class="item__kcal">000 kcal</span>
    <span class="item__allergen">Contains …</span>
  </div>
</li>
```

- **`data-diet`** drives the filter chips. Use the tokens `veg`, `vegan`, `gf`, space-separated. Tag a vegan dish `veg vegan` so it also shows under the Veg filter. A dish with no dietary tokens gets `data-diet=""` and appears only when no filter is active.
- **`.diet__tag`** is the visible badge on the card. Variants: `--v` (Veg, green), `--vg` (Vegan, green with a tick), `--gf` (GF, tomato). These are just labels; the filtering is driven by `data-diet`, so keep the two in step.
- **`.item__more`** holds the calories and allergens revealed when the dish is tapped. It shows plainly with no JavaScript; with JS it collapses and the name (`.item__toggle`, marked `data-toggle`) becomes the disclosure button. Edit the `.item__kcal` and `.item__allergen` text per dish. To drop the detail for one dish, delete its `.item__more` block: that dish keeps its name as plain static text, with no chevron and nothing to open.
- **`.item__leader`** is the decorative dotted line between the name and the price. Leave it in place.

**Rename a category** by editing both the tab and its section:

```html
<!-- the tab, in the category bar -->
<a class="tab" data-tab="brunch" href="#panel-brunch">Brunch</a>

<!-- its section -->
<section class="panel" id="panel-brunch" data-panel="brunch"> … </section>
```

The `data-tab` on the tab must match the `data-panel` on the section, and the `href` must match the section `id`. That anchor link is also the no-JS fallback, so keep it correct.

**Add or remove a category** by copying a full tab plus its `<section>`, or deleting both. Sections are independent; nothing else needs changing. The `<p class="panel__empty" data-empty hidden>` line inside each section is the note shown when the filters empty that category.

## Palette

The menu runs on a sage-tint ground with near-black ink, one herb green and one warm tomato. To reskin, change these six tokens and their `--*-rgb` triples in `:root`; every translucent value (rules, tints, dotted leaders) resolves from a triple, so it is a six-value edit.

```css
--ground: #E7ECE1;   --ground-rgb: 231, 236, 225;   /* page */
--paper:  #F3F6EF;   --paper-rgb:  243, 246, 239;   /* header, chips, pill text */
--ink:    #23291F;   --ink-rgb:    35, 41, 31;       /* names, body */
--ink-soft: #55604D; --ink-soft-rgb: 85, 96, 77;     /* descriptions */
--herb:   #3F6B47;   --herb-rgb:   63, 107, 71;      /* prices, active tab, Veg pills */
--tomato: #C24B33;   --tomato-rgb: 194, 75, 51;      /* highlights, GF pills */
```

**Measured contrast (WCAG, composited over the surface each colour sits on):**

| Text | Surface | Ratio | Use |
|---|---|---|---|
| ink `#23291F` | ground `#E7ECE1` | 12.4:1 | body, dish names |
| ink `#23291F` | paper `#F3F6EF` | 13.2:1 | header, cards |
| ink-soft `#55604D` | ground | 5.5:1 | descriptions |
| ink-soft `#55604D` | paper | 6.1:1 | descriptions |
| herb `#3F6B47` | ground | 5.1:1 | prices, active tab, footer labels |
| paper `#F3F6EF` | herb `#3F6B47` | 6.1:1 | text on filled green pills |
| paper `#F3F6EF` | tomato `#C24B33` | 4.8:1 | text on filled tomato pills |

**Tomato is a highlight and a mark, not small body text.** On the ground it measures 4.0:1 and on paper 4.4:1, both below the 4.5:1 floor for normal-size text. It is used only as a filled pill (white text on tomato passes at 4.8:1) and for large or bold accents. If you re-colour, keep tomato-as-text off any surface and check the two pill pairs above still clear 4.5:1.

## Accessibility

- **Reads with JavaScript off.** The whole menu is one stacked document with every dish visible, and the category bar degrades to in-page anchor links. Nothing is hidden behind a control that needs JS.
- **Reduced motion.** With `prefers-reduced-motion: reduce`, the tabs and filters still work but switch instantly, with no crossfade, stagger or fade. Handled by both the CSS media query and the script.
- **Keyboard.** The category bar is a proper ARIA tablist: arrow keys, Home and End move between categories, with roving tabindex. The filter chips are real `<button>`s with `aria-pressed`. Each dish's details open from a real `<button>` with `aria-expanded` and `aria-controls`, operable with Enter or Space. A skip link jumps straight to the menu.
- **Colour is never the only signal.** Dietary information is carried by the badge text (Veg / Vegan / GF), not by colour alone.

## Dependencies

- [GSAP](https://gsap.com/) 3.14.2 with ScrollTrigger (CDN)
- [Lenis](https://github.com/darkroomengineering/lenis) 1.3.17 smooth scroll (CDN, optional; remove the block in `script.js` and its `<script>` tag to drop it)

Yeseva One and Mukta load from Google Fonts.

## Source Code

This effect is free. The complete source is included below.

### index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
  <meta name="description" content="Sorrel, an all-day cafe and kitchen on Larkfield Row. Brunch, small plates, sweets and drinks.">
  <meta name="theme-color" content="#E7ECE1">
  <title>Sorrel · All-Day Menu</title>
  <link rel="stylesheet" href="assets/style.css">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Yeseva+One&family=Mukta:wght@300;400;500;600;700&display=swap" rel="stylesheet">
    <!-- Add has-js before first paint so reveal start-states never flash (visible then hide). data-cfasync stops Rocket Loader deferring it. -->

    <script data-cfasync="false">document.documentElement.classList.add('has-js')</script>

</head>
<body>
  <a class="skip-link" href="#menu">Skip to menu</a>

  <!-- ============ STICKY HEADER ============ -->
  <header class="site-header">
    <div class="site-header__inner">
      <div class="brand">
        <span class="brand__leaf" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
            <path d="M12 21c0-6 0-9 6-13 0 7-2 11-6 13z"></path>
            <path d="M12 21c0-5 0-8-5-11 0 6 1 9 5 11z"></path>
            <path d="M12 21v-9"></path>
          </svg>
        </span>
        <div class="brand__text">
          <span class="brand__name">Sorrel</span>
          <span class="brand__tag">All-day cafe &amp; kitchen · Larkfield Row</span>
        </div>
      </div>
      <dl class="hours">
        <div><dt>Open</dt><dd>Tue&ndash;Sun · 8&ndash;4</dd></div>
        <div><dt>Wi-Fi</dt><dd>sorrel&nbsp;/&nbsp;greenhouse</dd></div>
      </dl>
    </div>
  </header>

  <main id="menu" class="menu">
    <!-- ============ CATEGORY BAR + DIETARY FILTERS ============ -->
    <div class="controls">
      <nav class="tabs" aria-label="Menu categories" data-tabs>
        <a class="tab is-active" data-tab="all"     href="#panel-brunch">All</a>
        <a class="tab"           data-tab="brunch"  href="#panel-brunch">Brunch</a>
        <a class="tab"           data-tab="plates"  href="#panel-plates">Small&nbsp;Plates</a>
        <a class="tab"           data-tab="sweets"  href="#panel-sweets">Sweets</a>
        <a class="tab"           data-tab="drinks"  href="#panel-drinks">Drinks</a>
      </nav>

      <div class="filters" data-filters>
        <span class="filters__label" id="filters-label">Dietary</span>
        <div class="filters__chips" role="group" aria-labelledby="filters-label">
          <button type="button" class="chip" data-diet="veg"   aria-pressed="false">Veg</button>
          <button type="button" class="chip" data-diet="vegan" aria-pressed="false">Vegan</button>
          <button type="button" class="chip" data-diet="gf"    aria-pressed="false">GF</button>
        </div>
      </div>
    </div>

    <!-- ============ BRUNCH ============ -->
    <section class="panel is-active" id="panel-brunch" data-panel="brunch">
      <h2 class="panel__title">Brunch</h2>
      <p class="panel__note">Served 8am until 3pm. Add a flat white for £2.</p>
      <ul class="dishes">
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Sorrel Green Shakshuka</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£11.50</span>
          </div>
          <p class="item__desc">Two eggs baked in herb-braised greens, a slick of chilli oil, grilled sourdough.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">540 kcal</span>
            <span class="item__allergen">Contains egg, gluten, milk</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg vegan">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Smashed Avocado &amp; Dukkah</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£9.50</span>
          </div>
          <p class="item__desc">Lemon, cherry tomato, toasted seeds and dukkah on charred rye.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">430 kcal</span>
            <span class="item__allergen">Contains gluten, sesame, nuts</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Buttermilk Pancakes</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£10.00</span>
          </div>
          <p class="item__desc">Burnt honey, poached rhubarb and a spoon of crème fraîche.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">620 kcal</span>
            <span class="item__allergen">Contains gluten, egg, milk</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Field Mushroom Toast</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£10.50</span>
          </div>
          <p class="item__desc">Garlic butter, thyme and a soft poached egg. Ask for gluten-free bread.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li><li class="diet__tag diet__tag--gf">GF*</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">380 kcal</span>
            <span class="item__allergen">Contains egg, gluten</span>
          </div>
        </li>
        <li class="item" data-item data-diet="">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>The Full Sorrel</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£13.50</span>
          </div>
          <p class="item__desc">Cumberland sausage, smoked bacon, egg, beans, roast tomato, buttered toast.</p>
          <div class="item__more" data-more>
            <span class="item__kcal">890 kcal</span>
            <span class="item__allergen">Contains gluten, egg</span>
          </div>
        </li>
      </ul>
      <p class="panel__empty" data-empty hidden>No brunch dishes match those filters.</p>
    </section>

    <!-- ============ SMALL PLATES ============ -->
    <section class="panel" id="panel-plates" data-panel="plates">
      <h2 class="panel__title">Small Plates</h2>
      <p class="panel__note">From noon. Three or four between two is about right.</p>
      <ul class="dishes">
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Padrón Peppers</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£6.00</span>
          </div>
          <p class="item__desc">Blistered in olive oil, sea salt and a dust of smoked paprika.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">120 kcal</span>
            <span class="item__allergen">No listed allergens</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Whipped Feta &amp; Honey</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£7.50</span>
          </div>
          <p class="item__desc">Pistachio, cracked pepper and warm flatbread for dipping.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">340 kcal</span>
            <span class="item__allergen">Contains milk, gluten, nuts</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Crispy New Potatoes</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£5.50</span>
          </div>
          <p class="item__desc">Smashed and fried golden, with a garlic aioli and chives.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">290 kcal</span>
            <span class="item__allergen">Contains mustard</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Charred Hispi Cabbage</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£7.00</span>
          </div>
          <p class="item__desc">Grilled hard, brushed with miso butter and toasted sesame.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">210 kcal</span>
            <span class="item__allergen">Contains milk, soya, sesame</span>
          </div>
        </li>
        <li class="item" data-item data-diet="gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Buttermilk Chicken</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£8.50</span>
          </div>
          <p class="item__desc">Crisp thigh, hot honey and house pickles.</p>
          <ul class="diet"><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">460 kcal</span>
            <span class="item__allergen">Contains milk</span>
          </div>
        </li>
      </ul>
      <p class="panel__empty" data-empty hidden>No small plates match those filters.</p>
    </section>

    <!-- ============ SWEETS ============ -->
    <section class="panel" id="panel-sweets" data-panel="sweets">
      <h2 class="panel__title">Sweets</h2>
      <p class="panel__note">Baked in-house each morning. Ask what has just come out.</p>
      <ul class="dishes">
        <li class="item" data-item data-diet="veg gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Basque Cheesecake</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£6.50</span>
          </div>
          <p class="item__desc">Burnt top, soft middle, a pour of single-origin espresso alongside.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">480 kcal</span>
            <span class="item__allergen">Contains egg, milk</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Salted Chocolate Cookie</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£3.50</span>
          </div>
          <p class="item__desc">Served warm, dark chocolate and a flake of sea salt.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">320 kcal</span>
            <span class="item__allergen">Contains gluten, egg, milk, soya</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Sticky Date Pudding</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£7.00</span>
          </div>
          <p class="item__desc">Warm toffee sauce and a scoop of Jersey clotted cream.</p>
          <ul class="diet"><li class="diet__tag diet__tag--v">Veg</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">550 kcal</span>
            <span class="item__allergen">Contains gluten, egg, milk</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg vegan">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Seasonal Fruit Galette</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£6.50</span>
          </div>
          <p class="item__desc">Free-form pastry, whatever is ripe, coconut cream on the side.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">410 kcal</span>
            <span class="item__allergen">Contains gluten</span>
          </div>
        </li>
      </ul>
      <p class="panel__empty" data-empty hidden>No sweets match those filters.</p>
    </section>

    <!-- ============ DRINKS ============ -->
    <section class="panel" id="panel-drinks" data-panel="drinks">
      <h2 class="panel__title">Drinks</h2>
      <p class="panel__note">Beans from Curve, London. Oat milk is on the house.</p>
      <ul class="dishes">
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Flat White</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£3.20</span>
          </div>
          <p class="item__desc">House blend, dense and short. Oat or whole milk.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">120 kcal</span>
            <span class="item__allergen">Oat milk as standard; whole milk on request</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Filter, Batch Brew</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£3.00</span>
          </div>
          <p class="item__desc">A rotating single-origin, brewed light. Free refill before noon.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">5 kcal</span>
            <span class="item__allergen">No listed allergens</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Matcha Latte</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£4.20</span>
          </div>
          <p class="item__desc">Ceremonial grade, whisked, over oat milk and ice if you like.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">140 kcal</span>
            <span class="item__allergen">No listed allergens</span>
          </div>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name"><button class="item__toggle" type="button" data-toggle>Blood Orange Soda</button></h3>
            <span class="item__leader" aria-hidden="true"></span>
            <span class="item__price">£3.80</span>
          </div>
          <p class="item__desc">Made in-house, gently bitter, not too sweet.</p>
          <ul class="diet"><li class="diet__tag diet__tag--vg">Vegan</li><li class="diet__tag diet__tag--gf">GF</li></ul>
          <div class="item__more" data-more>
            <span class="item__kcal">90 kcal</span>
            <span class="item__allergen">No listed allergens</span>
          </div>
        </li>
      </ul>
      <p class="panel__empty" data-empty hidden>No drinks match those filters.</p>
    </section>
  </main>

  <!-- ============ FOOTER ============ -->
  <footer class="site-footer">
    <p class="site-footer__note" data-note>14 Larkfield Row, London E17 · Walk-ins only</p>
    <p class="site-footer__note" data-note>Prices include service. Card and contactless only.</p>
    <p class="site-footer__note site-footer__note--fine" data-note>Please tell us about any allergies before you order. Dishes marked GF* can be made gluten-free on request; we prepare food in a kitchen that handles gluten and nuts.</p>
  </footer>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
  <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>
  <script src="assets/script.js"></script>
</body>
</html>
```

### assets/style.css

```css
/* ============================================================
   SORREL, QR TABLE MENU
   A digital menu opened from a QR code at the table, so it is
   designed for a 375px phone first and widened from there.

   COLOUR, measured as used (WCAG, composited over the surface
   the text actually sits on):

     ink       #23291F on ground #E7ECE1  → 12.4:1   body + names
     ink       #23291F on paper  #F3F6EF  → 13.2:1   header, cards
     ink-soft  #55604D on ground #E7ECE1  →  5.5:1   descriptions
     ink-soft  #55604D on paper  #F3F6EF  →  6.1:1   descriptions
     herb      #3F6B47 on ground #E7ECE1  →  5.1:1   prices, active tab
     white     #F3F6EF on herb   #3F6B47  →  6.1:1   filled chips / pills

   Tomato #C24B33 is a HIGHLIGHT and a MARK, never small body text:
   on the ground it measures 4.0:1 and on paper 4.4:1, both below the
   4.5:1 floor for text under 18.66px bold. It is used only as filled
   pills (white on tomato → 4.8:1, passes) and for large/bold accents.
   Do not set tomato as a small text colour on any surface here.

   To reskin: change the six colour tokens below and their matching
   --*-rgb triples. Every translucent value resolves from a triple, so
   a single edit carries through rules, tints and the dotted leaders.
   ============================================================ */

:root {
    --ground: #E7ECE1;
    --paper:  #F3F6EF;
    --ink:    #23291F;
    --ink-soft: #55604D;
    --herb:   #3F6B47;
    --tomato: #C24B33;

    --ground-rgb: 231, 236, 225;
    --paper-rgb:  243, 246, 239;
    --ink-rgb:    35, 41, 31;
    --ink-soft-rgb: 85, 96, 77;
    --herb-rgb:   63, 107, 71;
    --tomato-rgb: 194, 75, 51;

    --line: rgba(var(--ink-rgb), 0.14);
    --line-strong: rgba(var(--ink-rgb), 0.22);
    --shadow: 0 1px 0 rgba(var(--ink-rgb), 0.04), 0 8px 24px rgba(var(--ink-rgb), 0.06);

    --font-display: 'Yeseva One', Georgia, serif;
    --font-sans: 'Mukta', system-ui, -apple-system, sans-serif;

    --ease: cubic-bezier(0.22, 1, 0.36, 1);
    --wrap: 40rem;
}

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
    margin: 0;
    background: var(--ground);
    color: var(--ink);
    font-family: var(--font-sans);
    font-weight: 400;
    line-height: 1.5;
    font-size: 16px;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
    /* Soft grain of two herb washes so the ground is not a flat fill. */
    background-image:
        radial-gradient(60rem 40rem at 12% -10%, rgba(var(--herb-rgb), 0.10), transparent 60%),
        radial-gradient(50rem 40rem at 108% 4%, rgba(var(--tomato-rgb), 0.06), transparent 55%);
    background-attachment: fixed;
}

::selection { background: rgba(var(--herb-rgb), 0.24); color: var(--ink); }

.skip-link {
    position: absolute;
    left: 0.75rem;
    top: -3rem;
    z-index: 60;
    background: var(--herb);
    color: var(--paper);
    padding: 0.5rem 0.9rem;
    border-radius: 0.5rem;
    text-decoration: none;
    font-weight: 600;
    transition: top 0.2s var(--ease);
}
.skip-link:focus { top: 0.75rem; }

:where(a, button):focus-visible {
    outline: 2px solid var(--herb);
    outline-offset: 2px;
    border-radius: 0.4rem;
}

/* ============ HEADER ============
   In normal flow, not sticky: on a phone the pinned category bar below
   is what needs to stay in view, and a second pinned bar would eat the
   screen. The header scrolls away and the tabs take the top. */
.site-header {
    background: rgba(var(--paper-rgb), 0.9);
    border-bottom: 1px solid var(--line);
    padding-top: env(safe-area-inset-top);
}

.site-header__inner {
    max-width: var(--wrap);
    margin: 0 auto;
    padding: 1.1rem 1.25rem;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
}

.brand { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.brand__leaf {
    color: var(--herb);
    display: grid;
    place-items: center;
    flex: none;
}
.brand__text { display: flex; flex-direction: column; min-width: 0; }
.brand__name {
    font-family: var(--font-display);
    font-size: 1.6rem;
    line-height: 1;
    letter-spacing: 0.01em;
}
.brand__tag {
    font-size: 0.72rem;
    color: var(--ink-soft);
    letter-spacing: 0.02em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.hours {
    margin: 0;
    display: flex;
    gap: 1rem;
    text-align: right;
    flex: none;
}
.hours dt {
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--herb);
    font-weight: 600;
}
.hours dd {
    margin: 0.1rem 0 0;
    font-size: 0.78rem;
    color: var(--ink);
    font-weight: 500;
    white-space: nowrap;
}

/* ============ CONTROLS: TABS + FILTERS ============ */
.menu { max-width: var(--wrap); margin: 0 auto; padding: 0 1.25rem 2rem; }

.controls {
    position: sticky;
    top: 0;
    z-index: 30;
    margin: 0 -1.25rem;
    padding: calc(0.6rem + env(safe-area-inset-top)) 1.25rem 0.7rem;
    background: rgba(var(--ground-rgb), 0.92);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--line);
}

.tabs {
    display: flex;
    gap: 0.4rem;
    overflow-x: auto;
    scrollbar-width: none;
    -ms-overflow-style: none;
    scroll-snap-type: x proximity;
    margin-bottom: 0.55rem;
    padding-bottom: 0.1rem;
}
.tabs::-webkit-scrollbar { display: none; }

.tab {
    flex: none;
    scroll-snap-align: start;
    text-decoration: none;
    color: var(--ink);
    font-weight: 600;
    font-size: 0.92rem;
    padding: 0.42rem 0.9rem;
    border-radius: 999px;
    border: 1px solid var(--line-strong);
    background: rgba(var(--paper-rgb), 0.7);
    white-space: nowrap;
    transition: background 0.2s var(--ease), color 0.2s var(--ease), border-color 0.2s var(--ease);
    cursor: pointer;
}
.tab:hover { border-color: var(--herb); }
.tab.is-active {
    background: var(--herb);
    color: var(--paper);
    border-color: var(--herb);
}

.filters {
    display: flex;
    align-items: center;
    gap: 0.55rem;
    flex-wrap: wrap;
}
.filters__label {
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--ink-soft);
    font-weight: 600;
}
.filters__chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }

.chip {
    font-family: inherit;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--ink);
    background: transparent;
    border: 1px solid var(--line-strong);
    border-radius: 999px;
    padding: 0.28rem 0.72rem;
    cursor: pointer;
    transition: background 0.18s var(--ease), color 0.18s var(--ease), border-color 0.18s var(--ease);
}
.chip:hover { border-color: var(--herb); }
.chip.is-on {
    background: var(--herb);
    border-color: var(--herb);
    color: var(--paper);
}

/* ============ PANELS ============ */
.panel { padding-top: 1.6rem; }
.panel + .panel { border-top: 1px solid var(--line); }

.panel__title {
    font-family: var(--font-display);
    font-size: clamp(1.9rem, 7vw, 2.6rem);
    font-weight: 400;
    line-height: 1.05;
    margin: 0 0 0.2rem;
}
.panel__note {
    margin: 0 0 1.1rem;
    color: var(--ink-soft);
    font-size: 0.9rem;
    font-style: italic;
}

.dishes { list-style: none; margin: 0; padding: 0; }

.item { padding: 0.95rem 0; border-bottom: 1px dashed var(--line); }
.item:last-child { border-bottom: 0; }

.item__head {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
}
.item__name {
    font-family: var(--font-display);
    font-weight: 400;
    font-size: 1.08rem;
    line-height: 1.2;
    margin: 0;
    color: var(--ink);
}
.item__leader {
    flex: 1 1 auto;
    align-self: flex-end;
    height: 0.62em;
    border-bottom: 1.5px dotted var(--line-strong);
    min-width: 1.5rem;
}
.item__price {
    flex: none;
    font-weight: 700;
    font-size: 1rem;
    color: var(--herb);
    font-variant-numeric: tabular-nums;
}
.item__desc {
    margin: 0.3rem 0 0;
    color: var(--ink-soft);
    font-size: 0.9rem;
    max-width: 34rem;
}

.diet { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.55rem 0 0; padding: 0; }
.diet__tag {
    font-size: 0.64rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--paper);
    padding: 0.16rem 0.5rem;
    border-radius: 999px;
    line-height: 1.4;
}
.diet__tag--v  { background: var(--herb); }
.diet__tag--vg { background: var(--herb); }
.diet__tag--vg::before { content: "\2713\00a0"; }
.diet__tag--gf { background: var(--tomato); }

/* ============ DISH DETAILS (calories + allergens) ============
   Each dish carries a details block. With no JS it just shows, like
   everything else on the page. Under .has-js the dish name becomes a
   disclosure button and the block collapses, opening on tap. */
.item__toggle {
    font: inherit;
    color: inherit;
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    text-align: left;
    /* No JS (or a dish with no details): the button is inert, so it must
       not look tappable. .has-details is added by the script only where a
       working disclosure was wired. */
    cursor: default;
}
.has-js .item.has-details .item__toggle { cursor: pointer; }

/* Chevron affordance, only where JS wired a working disclosure. */
.has-js .item.has-details .item__toggle::after {
    content: "";
    display: inline-block;
    width: 0.4em;
    height: 0.4em;
    margin-left: 0.5em;
    border-right: 1.5px solid var(--ink-soft);
    border-bottom: 1.5px solid var(--ink-soft);
    transform: translateY(-0.15em) rotate(45deg);
    transition: transform 0.25s var(--ease);
    vertical-align: middle;
}
.has-js .item.has-details.is-open .item__toggle::after {
    transform: translateY(0.05em) rotate(-135deg);
}
.item__toggle:focus-visible {
    outline: 2px solid var(--herb);
    outline-offset: 3px;
    border-radius: 3px;
}

/* No-JS resting layout: the spans sit directly in .item__more and just
   show. With JS the script wraps them in .item__more-inner and .item__more
   becomes a clip container whose height animates. */
.item__more {
    padding-top: 0.55rem;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.25rem 0.85rem;
    font-size: 0.82rem;
    color: var(--ink-soft);
}
.item__kcal {
    font-weight: 700;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
}
.item__allergen { font-style: italic; }

/* Under JS: .item__more is the clip container (no padding of its own, so
   height:0 closes it completely - no stray gap), and the inner wrapper the
   script inserts carries the spacing and flow. Pre-collapsed here before
   the script runs (has-js is set in the head, so nothing flashes). GSAP
   animates the container height/opacity and clears them on reset. */
.has-js .item__more {
    display: block;
    padding: 0;
    height: 0;
    opacity: 0;
    overflow: hidden;
}
.has-js .item__more-inner {
    padding-top: 0.55rem;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.25rem 0.85rem;
}

.panel__empty {
    margin: 0.5rem 0 0;
    color: var(--ink-soft);
    font-style: italic;
    font-size: 0.9rem;
}

/* A filtered-out dish leaves the flow entirely so the list closes up.
   Gated on nothing: the class is only ever added by JS, so with the
   chips absent every dish shows. */
.item.is-filtered { display: none; }

/* ============ NO-JS / PROGRESSIVE ENHANCEMENT ============
   Default (no has-js): every category is stacked and visible, and the
   tabs are ordinary anchor links that jump to their section. When JS
   has registered GSAP it adds .has-js, which turns the category bar
   into a tab set showing one section at a time. Nothing is hidden
   unless a working tab can bring it back. */
.has-js .panel { display: none; }
.has-js .panel.is-active { display: block; }
.has-js .panel__title {
    /* The active tab already names the section; keep the heading for
       structure but let the tab do the shouting. */
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0; border: 0;
    clip: rect(0 0 0 0); overflow: hidden;
}
.has-js .panel { border-top: 0; padding-top: 1.4rem; }

/* "All" view: show every category at once, each broken up by its title and
   a rule, the same layout a no-JS visitor gets. The tab bar toggles the
   .menu-all class on <html>. */
.has-js.menu-all .panel { display: block; }
.has-js.menu-all .panel + .panel { border-top: 1px solid var(--line); padding-top: 1.6rem; }
.has-js.menu-all .panel__title {
    position: static;
    width: auto; height: auto;
    margin: 0 0 0.2rem; padding: 0;
    clip: auto; overflow: visible;
}

/* ============ FOOTER ============ */
.site-footer {
    max-width: var(--wrap);
    margin: 0 auto;
    padding: 2rem 1.25rem 3rem;
    padding-bottom: calc(3rem + env(safe-area-inset-bottom));
    border-top: 1px solid var(--line);
    text-align: center;
}
.site-footer__note { margin: 0 0 0.5rem; font-size: 0.86rem; color: var(--ink); }
.site-footer__note--fine { color: var(--ink-soft); font-size: 0.78rem; max-width: 30rem; margin-left: auto; margin-right: auto; }

/* ============ WIDER SCREENS ============ */
@media (min-width: 34rem) {
    .brand__name { font-size: 1.85rem; }
    .brand__tag { font-size: 0.8rem; }
    .item__name { font-size: 1.18rem; }
    .item__price { font-size: 1.08rem; }
    .dishes {
        /* Two dishes across once there is room; each stays a full row. */
        display: grid;
        grid-template-columns: 1fr 1fr;
        column-gap: 2.5rem;
    }
    .item { border-bottom: 1px dashed var(--line); }
}

@media (min-width: 48rem) {
    .menu, .site-header__inner, .site-footer { padding-left: 2rem; padding-right: 2rem; }
    .controls { margin-left: -2rem; margin-right: -2rem; padding-left: 2rem; padding-right: 2rem; }
}

/* ============ REDUCED MOTION ============
   The JS matchMedia branch already switches tabs and filters instantly
   under reduced motion; this mirror covers CSS transitions so nothing
   eases, slides or fades for a visitor who asked it not to. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
        scroll-behavior: auto !important;
    }
}
```

### assets/script.js

```js
/**
 * Sorrel, QR Table Menu Template
 *
 * A digital menu made to be opened from a QR code at the table. The
 * signature is the sticky category bar: tapping Brunch, Small Plates,
 * Sweets or Drinks crossfades to that section and staggers its dishes
 * in, and the dietary chips (Veg / Vegan / GF) filter every section at
 * once, fading the dishes that do not match out of the list.
 *
 * With JavaScript off the whole menu is one readable document: every
 * section stacked, every dish shown, and the category bar degrades to
 * plain in-page anchor links. The script adds a class that upgrades the
 * bar into a tab set showing one section at a time; nothing is ever
 * hidden behind a control that cannot run.
 *
 * Sections are independent. Every lookup is guarded, so a buyer can
 * delete a whole category or a single dish and the rest still works.
 *
 * @plugins ScrollTrigger
 * @techniques tabs, filter, click-toggle, scroll-reveal, stagger
 */

/* Registering the plugin FIRST, inside the guard, is load-bearing: the
   has-js class gates the tab-panel layout, so a partial CDN failure
   (gsap present, ScrollTrigger missing) must stop BEFORE the class is
   added, leaving the full stacked menu rather than a page with every
   section but one collapsed and no working tabs to reach them. */
if (typeof gsap !== 'undefined' && typeof ScrollTrigger !== 'undefined') {
    gsap.registerPlugin(ScrollTrigger);
    document.documentElement.classList.add('has-js');
} else {
    // CDN blocked: GSAP never loaded, so undo the pre-paint hide from
    // the head script and let the plain document show.
    document.documentElement.classList.remove('has-js');
}

/* Runs immediately if the DOM is already parsed (a late or deferred
   script, e.g. Cloudflare Rocket Loader) and waits for DOMContentLoaded
   otherwise. A bare listener silently never fires under deferral. */
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initSorrel() {

    /* OPTIONAL: Lenis smooth scroll. Remove this block and the CDN tag to drop it. */
    let lenis = null;
    if (typeof Lenis !== 'undefined') {
        lenis = new Lenis({ autoRaf: true, anchors: true });
    }

    const ctx = gsap.context(function gsapContextCallback() {
        const mm = gsap.matchMedia();

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function matchMediaCallback(context) {
            const isMotion = context.conditions.isMotion;
            const handlers = new Map();
            const resets = [];

            function listen(el, type, fn) {
                el.addEventListener(type, fn);
                const entry = handlers.get(el) || {};
                entry[type] = fn;
                handlers.set(el, entry);
            }

            /* Filter state is shared: chips filter every section, and a
               dish stays hidden while you switch categories, so it lives
               out here rather than inside either module. */
            const activeDiets = [];

            /* ============================================
               DIETARY FILTER CHIPS
               Each dish carries its own diet tokens in data-diet
               (a vegan dish is tagged veg too, since it qualifies).
               A chip is a toggle; several active chips AND together,
               so Veg + GF shows only dishes that are both.

               A hidden dish gets .is-filtered, which sets display:none
               in CSS, so with the chips absent (no JS) every dish shows.
               ============================================ */
            (function initFilters() {
                const bar = document.querySelector('[data-filters]');
                if (!bar) return;
                /* Scope the chips to the filter bar. The dishes also carry a
                   data-diet (that is what the chips test against), so a bare
                   '[data-diet]' selector would wire a chip toggle onto every
                   dish - and a tap on a dish would push its whole diet string
                   ("veg gf") as an active filter that matches nothing, hiding
                   the entire menu. */
                const chips = gsap.utils.toArray(bar.querySelectorAll('[data-diet]'));
                const items = gsap.utils.toArray('[data-item]');
                if (!chips.length || !items.length) return;

                const empties = gsap.utils.toArray('[data-empty]');

                function matches(item) {
                    const diet = item.dataset.diet || '';
                    return activeDiets.every(function (d) {
                        return diet.split(/\s+/).indexOf(d) !== -1;
                    });
                }

                /* A category whose every dish was filtered out shows a
                   short note in place of an empty list. */
                function updateEmpties() {
                    empties.forEach(function (note) {
                        const panel = note.closest('[data-panel]');
                        if (!panel) return;
                        const rows = Array.prototype.slice.call(panel.querySelectorAll('[data-item]'));
                        const anyShown = rows.some(function (r) {
                            return !r.classList.contains('is-filtered');
                        });
                        note.hidden = anyShown || rows.length === 0;
                    });
                }

                function apply() {
                    items.forEach(function (item) {
                        const show = matches(item);
                        const hidden = item.classList.contains('is-filtered');

                        if (show && hidden) {
                            item.classList.remove('is-filtered');
                            if (isMotion) {
                                gsap.fromTo(item,
                                    { opacity: 0, y: 6 },
                                    { opacity: 1, y: 0, duration: 0.3, ease: 'power2.out' }
                                );
                            } else {
                                gsap.set(item, { clearProps: 'opacity,transform' });
                            }
                        } else if (!show && !hidden) {
                            if (isMotion) {
                                gsap.to(item, {
                                    opacity: 0,
                                    duration: 0.22,
                                    ease: 'power1.out',
                                    onComplete: function () {
                                        item.classList.add('is-filtered');
                                        gsap.set(item, { clearProps: 'opacity,transform' });
                                        updateEmpties();
                                    }
                                });
                            } else {
                                item.classList.add('is-filtered');
                            }
                        }
                    });
                    if (!isMotion) updateEmpties();
                    /* Sections change height as dishes leave, so anything
                       pinned below has moved. */
                    ScrollTrigger.refresh();
                }

                chips.forEach(function (chip) {
                    listen(chip, 'click', function () {
                        const diet = chip.dataset.diet;
                        const i = activeDiets.indexOf(diet);
                        const on = i === -1;
                        if (on) activeDiets.push(diet);
                        else activeDiets.splice(i, 1);
                        chip.classList.toggle('is-on', on);
                        chip.setAttribute('aria-pressed', on ? 'true' : 'false');
                        apply();
                    });
                });

                resets.push(function () {
                    activeDiets.length = 0;
                    items.forEach(function (item) {
                        item.classList.remove('is-filtered');
                        gsap.set(item, { clearProps: 'opacity,transform' });
                    });
                    chips.forEach(function (chip) {
                        chip.classList.remove('is-on');
                        chip.setAttribute('aria-pressed', 'false');
                    });
                    empties.forEach(function (note) { note.hidden = true; });
                });
            })();

            /* ============================================
               SIGNATURE: THE CATEGORY BAR
               Anchor links in the markup, so with no JS they jump to
               the stacked sections. Here they become a tab set: one
               section shows at a time, and switching crossfades the
               incoming section and staggers its (unfiltered) dishes in.

               No CSS transform start state is set on the dishes, so the
               fromTo below is the only thing touching their transform
               and nothing survives between switches.
               ============================================ */
            (function initTabs() {
                const nav = document.querySelector('[data-tabs]');
                const tabs = gsap.utils.toArray('[data-tab]');
                const panels = gsap.utils.toArray('[data-panel]');
                if (!nav || !tabs.length || !panels.length) return;

                nav.setAttribute('role', 'tablist');

                /* Pair each tab with its panel by name, not by index, so
                   deleting one category cannot shift the rest out of step. */
                const pairs = [];
                tabs.forEach(function (tab) {
                    const name = tab.dataset.tab;
                    const panel = panels.filter(function (p) {
                        return p.dataset.panel === name;
                    })[0];
                    if (!panel) return;

                    tab.setAttribute('role', 'tab');
                    if (!tab.id) tab.id = 'tab-' + name;
                    panel.setAttribute('role', 'tabpanel');
                    panel.setAttribute('aria-labelledby', tab.id);
                    pairs.push({ tab: tab, panel: panel });
                });
                if (!pairs.length) return;

                const root = document.documentElement;
                const allTab = tabs.filter(function (t) { return t.dataset.tab === 'all'; })[0];
                if (allTab) {
                    allTab.setAttribute('role', 'tab');
                    if (!allTab.id) allTab.id = 'tab-all';
                }
                /* Keyboard order across the whole bar: All first, then categories. */
                const order = (allTab ? [allTab] : []).concat(pairs.map(function (p) { return p.tab; }));
                let build = null;

                function play(panelList) {
                    if (build) build.kill();
                    if (!isMotion) return;
                    build = gsap.timeline();
                    build.fromTo(panelList,
                        { opacity: 0 },
                        { opacity: 1, duration: 0.28, ease: 'power1.out', stagger: panelList.length > 1 ? 0.05 : 0 }
                    );
                    /* Per-dish stagger only when a single section is shown; across
                       the whole menu it would be too much motion. */
                    if (panelList.length === 1) {
                        const rows = Array.prototype.slice.call(
                            panelList[0].querySelectorAll('[data-item]:not(.is-filtered)')
                        );
                        if (rows.length) {
                            build.fromTo(rows,
                                { opacity: 0, y: 10 },
                                { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', stagger: 0.05, clearProps: 'transform' },
                                0.06
                            );
                        }
                    }
                }

                function markActive(activeTab) {
                    order.forEach(function (tab) {
                        const on = tab === activeTab;
                        tab.classList.toggle('is-active', on);
                        tab.setAttribute('aria-selected', on ? 'true' : 'false');
                        tab.tabIndex = on ? 0 : -1;
                    });
                }

                /* "All": every category stacked with its title as a divider. */
                function showAll(animate) {
                    root.classList.add('menu-all');
                    markActive(allTab);
                    pairs.forEach(function (pair) {
                        pair.panel.classList.remove('is-active');
                        gsap.set(pair.panel, { clearProps: 'opacity,transform' });
                    });
                    if (animate) play(pairs.map(function (p) { return p.panel; }));
                    ScrollTrigger.refresh();
                }

                function activate(index, animate) {
                    if (index < 0 || index >= pairs.length) return;
                    root.classList.remove('menu-all');
                    markActive(pairs[index].tab);
                    pairs.forEach(function (pair, i) {
                        const on = i === index;
                        pair.panel.classList.toggle('is-active', on);
                        if (!on) gsap.set(pair.panel, { clearProps: 'opacity,transform' });
                    });
                    if (animate) play([pairs[index].panel]);
                    ScrollTrigger.refresh();
                }

                function select(tab, animate) {
                    if (allTab && tab === allTab) { showAll(animate); return; }
                    const idx = pairs.map(function (p) { return p.tab; }).indexOf(tab);
                    if (idx >= 0) activate(idx, animate);
                }

                order.forEach(function (tab, oi) {
                    listen(tab, 'click', function (event) {
                        /* The tab is an anchor; take over its jump so the page
                           does not leap to the section top on tap. */
                        event.preventDefault();
                        select(tab, true);
                    });
                    listen(tab, 'keydown', function (event) {
                        const key = event.key;
                        let ni = -1;
                        if (key === 'ArrowRight' || key === 'ArrowDown') ni = (oi + 1) % order.length;
                        else if (key === 'ArrowLeft' || key === 'ArrowUp') ni = (oi - 1 + order.length) % order.length;
                        else if (key === 'Home') ni = 0;
                        else if (key === 'End') ni = order.length - 1;
                        else return;
                        event.preventDefault();
                        select(order[ni], true);
                        order[ni].focus();
                    });
                });

                /* Default view: the whole menu ("All") if present, else the
                   first category, shown without the stagger so it opens composed. */
                if (allTab) showAll(false); else activate(0, false);

                resets.push(function () {
                    if (build) build.kill();
                    root.classList.remove('menu-all');
                    pairs.forEach(function (pair) {
                        gsap.set(pair.panel, { clearProps: 'opacity,transform' });
                    });
                });
            })();

            /* ============================================
               DISH DETAILS
               Each dish carries a details block (calories + allergens)
               that shows plainly with no JS. Here the dish name becomes a
               disclosure button and the block collapses, opening on tap.

               A single handler on the whole row toggles it, so a tap
               anywhere on the dish opens it (the QR-menu gesture people
               reach for). The button inside the name carries the keyboard
               focus, the accessible name and aria-expanded; its Enter or
               Space fires a click that bubbles to the same handler, so
               there is one source of truth and no double toggle. The block
               is pre-collapsed in CSS under .has-js, so nothing flashes.
               ============================================ */
            (function initItemDetails() {
                const items = gsap.utils.toArray('[data-item]');
                if (!items.length) return;

                items.forEach(function (item, i) {
                    const toggle = item.querySelector('[data-toggle]');
                    const more = item.querySelector('[data-more]');
                    if (!toggle || !more) return;

                    if (!more.id) more.id = 'item-more-' + i;
                    toggle.setAttribute('aria-controls', more.id);
                    toggle.setAttribute('aria-expanded', 'false');
                    /* Marks the dish as having a working disclosure, so the
                       chevron and pointer cursor only appear where a tap does
                       something. A dish whose details block was deleted skips
                       this block entirely and stays a plain, static name. */
                    item.classList.add('has-details');

                    /* Wrap the details in an inner element so .item__more can be
                       a padding-free clip container: height:0 then closes it
                       with no leftover gap. Guarded so a matchMedia re-run
                       (motion-preference change) does not nest a second wrap. */
                    if (!more.querySelector('.item__more-inner')) {
                        const inner = document.createElement('div');
                        inner.className = 'item__more-inner';
                        while (more.firstChild) inner.appendChild(more.firstChild);
                        more.appendChild(inner);
                    }

                    function setOpen(open) {
                        item.classList.toggle('is-open', open);
                        toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
                        if (isMotion) {
                            gsap.to(more, {
                                height: open ? 'auto' : 0,
                                opacity: open ? 1 : 0,
                                duration: 0.32,
                                ease: 'power2.out',
                                /* Heights below change, so anything sticky or
                                   pinned needs its positions recomputed. */
                                onComplete: function () { ScrollTrigger.refresh(); }
                            });
                        } else {
                            gsap.set(more, { height: open ? 'auto' : 0, opacity: open ? 1 : 0 });
                            ScrollTrigger.refresh();
                        }
                    }

                    listen(item, 'click', function () {
                        setOpen(!item.classList.contains('is-open'));
                    });
                });

                resets.push(function () {
                    items.forEach(function (item) {
                        const more = item.querySelector('[data-more]');
                        const toggle = item.querySelector('[data-toggle]');
                        item.classList.remove('is-open');
                        if (toggle) toggle.setAttribute('aria-expanded', 'false');
                        if (more) gsap.set(more, { clearProps: 'height,opacity' });
                    });
                });
            })();

            /* ============================================
               FOOTER REVEAL
               The one scroll-reveal on the page, kept for the closing
               notes. Guarded and once-only.
               ============================================ */
            (function initFooterReveal() {
                if (!isMotion) return;
                const items = gsap.utils.toArray('[data-note]');
                if (!items.length) return;

                const st = ScrollTrigger.batch(items, {
                    start: 'top 94%',
                    once: true,
                    onEnter: function (batch) {
                        gsap.fromTo(batch,
                            { opacity: 0, y: 16 },
                            {
                                opacity: 1,
                                y: 0,
                                duration: 0.6,
                                ease: 'power2.out',
                                stagger: 0.08,
                                overwrite: true
                            }
                        );
                    }
                });

                resets.push(function () {
                    st.forEach(function (t) { t.kill(); });
                    gsap.set(items, { clearProps: 'opacity,transform' });
                });
            })();

            /* ============================================
               CLEANUP
               ============================================ */
            return function cleanup() {
                handlers.forEach(function removeAll(entry, el) {
                    Object.keys(entry).forEach(function (type) {
                        el.removeEventListener(type, entry[type]);
                    });
                });
                handlers.clear();
                resets.forEach(function (fn) { fn(); });
                resets.length = 0;
                ScrollTrigger.getAll().forEach(function (t) { t.kill(); });
            };
        });
    });

    window.gsapContext = ctx;

    window.addEventListener('beforeunload', function () {
        if (ctx) ctx.kill();
        if (lenis) lenis.destroy();
    });
});
```

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
