Back to Templates 036

QR Table Menu Template Template

FREE

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.

ScrollTrigger tabsfilterscroll-revealstagger Lenis intermediate

About This Template

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.

What's Included

  • 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
  • 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

Perfect For

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

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, because a dish is only ever hidden by a class that JavaScript adds.

Plugins ScrollTrigger
Difficulty Intermediate
Smooth Scroll Lenis Integration
Includes HTML + JS + CSS source, documentation, lifetime updates
<!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">Sorrel Green Shakshuka</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>
        </li>
        <li class="item" data-item data-diet="veg vegan">
          <div class="item__head">
            <h3 class="item__name">Smashed Avocado &amp; Dukkah</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>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name">Buttermilk Pancakes</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>
        </li>
        <li class="item" data-item data-diet="veg gf">
          <div class="item__head">
            <h3 class="item__name">Field Mushroom Toast</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>
        </li>
        <li class="item" data-item data-diet="">
          <div class="item__head">
            <h3 class="item__name">The Full Sorrel</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>
        </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">Padrón Peppers</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>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name">Whipped Feta &amp; Honey</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>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name">Crispy New Potatoes</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>
        </li>
        <li class="item" data-item data-diet="veg gf">
          <div class="item__head">
            <h3 class="item__name">Charred Hispi Cabbage</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>
        </li>
        <li class="item" data-item data-diet="gf">
          <div class="item__head">
            <h3 class="item__name">Buttermilk Chicken</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>
        </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">Basque Cheesecake</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>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name">Salted Chocolate Cookie</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>
        </li>
        <li class="item" data-item data-diet="veg">
          <div class="item__head">
            <h3 class="item__name">Sticky Date Pudding</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>
        </li>
        <li class="item" data-item data-diet="veg vegan">
          <div class="item__head">
            <h3 class="item__name">Seasonal Fruit Galette</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>
        </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">Flat White</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>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name">Filter, Batch Brew</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>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name">Matcha Latte</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>
        </li>
        <li class="item" data-item data-diet="veg vegan gf">
          <div class="item__head">
            <h3 class="item__name">Blood Orange Soda</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>
        </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>
/**
 * 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, 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]');
                const chips = gsap.utils.toArray('[data-diet]');
                const items = gsap.utils.toArray('[data-item]');
                if (!bar || !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' });
                    });
                });
            })();

            /* ============================================
               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();
    });
});
"undefined"!=typeof gsap&&"undefined"!=typeof ScrollTrigger?(gsap.registerPlugin(ScrollTrigger),document.documentElement.classList.add("has-js")):document.documentElement.classList.remove("has-js"),function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){let t=null;"undefined"!=typeof Lenis&&(t=new Lenis({autoRaf:!0,anchors:!0}));const e=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(t){const e=t.conditions.isMotion,n=new Map,o=[];function r(t,e,o){t.addEventListener(e,o);const r=n.get(t)||{};r[e]=o,n.set(t,r)}const a=[];return function(){const t=document.querySelector("[data-filters]"),n=gsap.utils.toArray("[data-diet]"),s=gsap.utils.toArray("[data-item]");if(!t||!n.length||!s.length)return;const i=gsap.utils.toArray("[data-empty]");function c(){i.forEach(function(t){const e=t.closest("[data-panel]");if(!e)return;const n=Array.prototype.slice.call(e.querySelectorAll("[data-item]")),o=n.some(function(t){return!t.classList.contains("is-filtered")});t.hidden=o||0===n.length})}function l(){s.forEach(function(t){const n=function(t){const e=t.dataset.diet||"";return a.every(function(t){return-1!==e.split(/\s+/).indexOf(t)})}(t),o=t.classList.contains("is-filtered");n&&o?(t.classList.remove("is-filtered"),e?gsap.fromTo(t,{opacity:0,y:6},{opacity:1,y:0,duration:.3,ease:"power2.out"}):gsap.set(t,{clearProps:"opacity,transform"})):n||o||(e?gsap.to(t,{opacity:0,duration:.22,ease:"power1.out",onComplete:function(){t.classList.add("is-filtered"),gsap.set(t,{clearProps:"opacity,transform"}),c()}}):t.classList.add("is-filtered"))}),e||c(),ScrollTrigger.refresh()}n.forEach(function(t){r(t,"click",function(){const e=t.dataset.diet,n=a.indexOf(e),o=-1===n;o?a.push(e):a.splice(n,1),t.classList.toggle("is-on",o),t.setAttribute("aria-pressed",o?"true":"false"),l()})}),o.push(function(){a.length=0,s.forEach(function(t){t.classList.remove("is-filtered"),gsap.set(t,{clearProps:"opacity,transform"})}),n.forEach(function(t){t.classList.remove("is-on"),t.setAttribute("aria-pressed","false")}),i.forEach(function(t){t.hidden=!0})})}(),function(){const t=document.querySelector("[data-tabs]"),n=gsap.utils.toArray("[data-tab]"),a=gsap.utils.toArray("[data-panel]");if(!t||!n.length||!a.length)return;t.setAttribute("role","tablist");const s=[];if(n.forEach(function(t){const e=t.dataset.tab,n=a.filter(function(t){return t.dataset.panel===e})[0];n&&(t.setAttribute("role","tab"),t.id||(t.id="tab-"+e),n.setAttribute("role","tabpanel"),n.setAttribute("aria-labelledby",t.id),s.push({tab:t,panel:n}))}),!s.length)return;const i=document.documentElement,c=n.filter(function(t){return"all"===t.dataset.tab})[0];c&&(c.setAttribute("role","tab"),c.id||(c.id="tab-all"));const l=(c?[c]:[]).concat(s.map(function(t){return t.tab}));let u=null;function f(t){if(u&&u.kill(),e&&(u=gsap.timeline(),u.fromTo(t,{opacity:0},{opacity:1,duration:.28,ease:"power1.out",stagger:t.length>1?.05:0}),1===t.length)){const e=Array.prototype.slice.call(t[0].querySelectorAll("[data-item]:not(.is-filtered)"));e.length&&u.fromTo(e,{opacity:0,y:10},{opacity:1,y:0,duration:.4,ease:"power2.out",stagger:.05,clearProps:"transform"},.06)}}function d(t){l.forEach(function(e){const n=e===t;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false"),e.tabIndex=n?0:-1})}function p(t){i.classList.add("menu-all"),d(c),s.forEach(function(t){t.panel.classList.remove("is-active"),gsap.set(t.panel,{clearProps:"opacity,transform"})}),t&&f(s.map(function(t){return t.panel})),ScrollTrigger.refresh()}function g(t,e){t<0||t>=s.length||(i.classList.remove("menu-all"),d(s[t].tab),s.forEach(function(e,n){const o=n===t;e.panel.classList.toggle("is-active",o),o||gsap.set(e.panel,{clearProps:"opacity,transform"})}),e&&f([s[t].panel]),ScrollTrigger.refresh())}function h(t,e){if(c&&t===c)return void p(e);const n=s.map(function(t){return t.tab}).indexOf(t);n>=0&&g(n,e)}l.forEach(function(t,e){r(t,"click",function(e){e.preventDefault(),h(t,!0)}),r(t,"keydown",function(t){const n=t.key;let o=-1;if("ArrowRight"===n||"ArrowDown"===n)o=(e+1)%l.length;else if("ArrowLeft"===n||"ArrowUp"===n)o=(e-1+l.length)%l.length;else if("Home"===n)o=0;else{if("End"!==n)return;o=l.length-1}t.preventDefault(),h(l[o],!0),l[o].focus()})}),c?p(!1):g(0,!1),o.push(function(){u&&u.kill(),i.classList.remove("menu-all"),s.forEach(function(t){gsap.set(t.panel,{clearProps:"opacity,transform"})})})}(),function(){if(!e)return;const t=gsap.utils.toArray("[data-note]");if(!t.length)return;const n=ScrollTrigger.batch(t,{start:"top 94%",once:!0,onEnter:function(t){gsap.fromTo(t,{opacity:0,y:16},{opacity:1,y:0,duration:.6,ease:"power2.out",stagger:.08,overwrite:!0})}});o.push(function(){n.forEach(function(t){t.kill()}),gsap.set(t,{clearProps:"opacity,transform"})})}(),function(){n.forEach(function(t,e){Object.keys(t).forEach(function(n){e.removeEventListener(n,t[n])})}),n.clear(),o.forEach(function(t){t()}),o.length=0,ScrollTrigger.getAll().forEach(function(t){t.kill()})}})});window.gsapContext=e,window.addEventListener("beforeunload",function(){e&&e.kill(),t&&t.destroy()})});
/* ============================================================
   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); }

.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;
    }
}

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, 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:

<li class="item" data-item data-diet="veg gf">
  <div class="item__head">
    <h3 class="item__name">Dish name</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>
</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__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:

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

--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. 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 3.14.2 with ScrollTrigger (CDN)
  • 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.

Your Cart

Your cart is empty

Browse Effects