# Menu Board

> A restaurant menu by service: dish, original name, a tabular price on a dotted leader, marks in icon and words. Opens on the service served now; tabs, or stacked with pinned titles (data-layout="stack").

Data display · menu, restaurant, price list, food, tabs, opening hours, allergens · HTML, CSS, vanilla JS · no dependencies · updated 2026-11-04
Demo: https://kodza.dev/components/menu-board/
Source on GitHub: https://github.com/kodza-dev/kodza/tree/main/components/menu-board

## Files

The code below, served as files: byte for byte what the Kodza site copies (the CSS includes its theme guard).

- [`kodza-menu-board.css`](https://kodza.dev/registry/menu-board/kodza-menu-board.css) · 22.5 KB
- [`kodza-menu-board.js`](https://kodza.dev/registry/menu-board/kodza-menu-board.js) · 13.5 KB
- [`kodza-menu-board.html`](https://kodza.dev/registry/menu-board/kodza-menu-board.html) · 8.8 KB · the markup, to adapt and paste into your page

**Can you run shell commands? Download the CSS and the JS, don't retype them.** Retyping costs thousands of tokens and can drop or change lines of the CSS. Put the files where your stack expects them (Use it says where: change the `-o` paths):

```bash
curl -fsSL --create-dirs -o kodza/kodza-menu-board.css https://kodza.dev/registry/menu-board/kodza-menu-board.css
curl -fsSL --create-dirs -o kodza/kodza-menu-board.js https://kodza.dev/registry/menu-board/kodza-menu-board.js
```

Then load the files from your page as Use it shows, instead of pasting the content. The HTML is the part you adapt (text, links, modifiers): paste it from the Code section. Copy the CSS and the JS from the Code section only if you can't run commands, exactly as they are. (Windows PowerShell: `curl.exe`.)

## API

- Root: `[data-kodza-menu-board]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-menu-board-fg: #0a0a0a`
  - `--kz-menu-board-muted: rgba(10, 10, 10, 0.62)`
  - `--kz-menu-board-line: rgba(10, 10, 10, 0.1)`
  - `--kz-menu-board-dots: rgba(10, 10, 10, 0.34)` — the leader between a dish and its price
  - `--kz-menu-board-tabs-bg: #ffffff`
  - `--kz-menu-board-ink: #0a0a0a`
  - `--kz-menu-board-on-ink: #ffffff`
  - `--kz-menu-board-veil: rgba(255, 255, 255, 0.78)` — behind a pinned title (stack layout)
  - `--kz-menu-board-top: 0px` — height of a fixed header above it (stack layout)
  - `--kz-menu-board-width: 720px`
  - `--kz-menu-board-duration: 0.5s`
  - `--kz-menu-board-ease: cubic-bezier(0.22, 1, 0.36, 1)`
- Attributes:
  - `data-hours` (e.g. `12:00-14:30`, `19:00-22:30`)
  - `data-kodza-menu-board`
  - `data-label` (e.g. `Menu`)
  - `data-layout`
  - `data-mark` (e.g. `veg`, `vegan`, `spicy`, `allergens`, `out`)
  - `data-now`
  - `data-timezone` (e.g. `Europe/Paris`)
- Lifecycle: `kodza:init` / `kodza:destroy` on `document`, with `{ detail: { root } }` to target a container.

## Keyboard

What each key does, by where the focus is. Keep it working when you adapt the HTML: same elements, same classes.

- On a tab (focus in `.kodza-menu-board__tabs`):
  - `ArrowRight` / `ArrowLeft`: Shows the next or previous service, round and round
  - `Home` / `End`: Shows the first or last service
  - `Tab`: Skips the other tabs, on to the dishes

## Dark mode

The values Kodza uses on a dark background (`#0a0a0a`). For a page that follows the system theme, add them after the component's CSS:

```css
@media (prefers-color-scheme: dark) {
  .kodza-menu-board {
    --kz-menu-board-fg: #f2f2f0;
    --kz-menu-board-muted: rgba(242, 242, 240, 0.62);
    --kz-menu-board-line: rgba(255, 255, 255, 0.12);
    --kz-menu-board-dots: rgba(242, 242, 240, 0.36);
    --kz-menu-board-tabs-bg: #161616;
    --kz-menu-board-ink: #f2f2f0;
    --kz-menu-board-on-ink: #0a0a0a;
    --kz-menu-board-veil: rgba(10, 10, 10, 0.82);
  }
}
```

A site with its own theme switch puts the same rules under its dark selector instead (`[data-theme="dark"] .kodza-menu-board { … }`); a single dark section, under that section's class.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-menu-board.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-menu-board.css">`. Without a shell, paste it in a `<style>` in `<head>` instead. It only touches `kodza-` classes.
- HTML: where the component goes. Paste it as many times as you need: instances never interfere.
- JS: `kodza/kodza-menu-board.js`, loaded with `<script src="kodza/kodza-menu-board.js" defer></script>` in `<head>`. Without a shell, paste it in a `<script>` before `</body>` (or in `<head>`: it waits for the page). One copy per page, whatever the number of instances.
- Restyle with the `--kz-…` variables, set on the component's root element itself (the root declares its own defaults, so a value on an ancestor is ignored), and with the modifiers / attributes listed under API.
- Font: it takes the page's own. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-display`, the display face (headings, big numbers, wordmarks); `--kz-font-display-weight`, its weight; `--kz-font-display-scale`, a multiplier on its sizes (1 by default); `--kz-font-display-tracking`, its letter-spacing; `--kz-font-display-case`, its case (`uppercase`…); `--kz-font-label`, the face of the small labels (meta, captions, counters, hints, footer links, button labels); `--kz-font-label-size`, their size, one for all of them (`11px`…); `--kz-font-label-weight`, their weight; `--kz-font-label-tracking`, their letter-spacing; `--kz-font-label-case`, their case (`uppercase`…; code and units keep theirs); `--kz-font-label-leading`, their line-height (`1.45`…; buttons, pills and keys keep their box); `--kz-font-mono`, the monospace face (code, labels, counters). The Kodza look is Geist: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza); `--kz-radius-pill` sets the pills (`0` squares them).
- Motion: a site sets its pace once for every Kodza component, on `:root`, never by editing the CSS or the JS: `--kz-motion-scale` multiplies every duration (`0.5` twice as fast, `2` twice as slow, `0` no motion at all, loops stopped, like reduced motion). Unset, nothing changes.
- Content added later (AJAX navigation, a SPA route, a builder preview): start it with `document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: container } }))`; before removing it, the same call with `'kodza:destroy'` stops it and puts the original markup back.

### Claude artifact (claude.ai)

- Paste the CSS in a `<style>` and the JS in a `<script>` at the end, in the artifact itself: it loads stylesheets only from Google Fonts and scripts only from a few CDNs, so no `<link>` or `<script src>` to Kodza.
- Writing the artifact file with a shell? Insert the downloaded files (Files) with a command instead of retyping them: a line `KODZA-CSS` alone inside the `<style>`, `KODZA-JS` inside the `<script>`, then `sed -e '/^KODZA-CSS$/{r kodza/kodza-menu-board.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-menu-board.js' -e 'd' -e '}' page.html > artifact.html`.
- Font: load Geist from Google Fonts (allowed): `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-display`, the display face (headings, big numbers, wordmarks); `--kz-font-display-weight`, its weight; `--kz-font-display-scale`, a multiplier on its sizes (1 by default); `--kz-font-display-tracking`, its letter-spacing; `--kz-font-display-case`, its case (`uppercase`…); `--kz-font-label`, the face of the small labels (meta, captions, counters, hints, footer links, button labels); `--kz-font-label-size`, their size, one for all of them (`11px`…); `--kz-font-label-weight`, their weight; `--kz-font-label-tracking`, their letter-spacing; `--kz-font-label-case`, their case (`uppercase`…; code and units keep theirs); `--kz-font-label-leading`, their line-height (`1.45`…; buttons, pills and keys keep their box); `--kz-font-mono`, the monospace face (code, labels, counters).
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza); `--kz-radius-pill` sets the pills (`0` squares them).
- Motion: a site sets its pace once for every Kodza component, on `:root`, never by editing the CSS or the JS: `--kz-motion-scale` multiplies every duration (`0.5` twice as fast, `2` twice as slow, `0` no motion at all, loops stopped, like reduced motion). Unset, nothing changes.
- Dark mode: the viewer sets `data-theme="dark"` or `"light"` on `<html>`, or nothing when it follows the system. Write the Dark mode rules twice: inside `@media (prefers-color-scheme: dark)` with `:root:not([data-theme="light"])` in front of each selector, and with `:root[data-theme="dark"]` in front of each selector.

### WordPress

- HTML: a **Custom HTML** block (or a pattern / template part). WordPress keeps this markup as it is only for users allowed to post unfiltered HTML (Administrators; Super Admins on multisite): for anyone else it strips tags such as `<svg>`, `<input>` or `<dialog>`, so add it from the theme (a pattern or template part) instead.
- CSS and JS: the downloaded files go in the theme, under `wp-content/themes/<your-theme>/kodza/` (the child theme if there is one), enqueued from its functions.php (or a code-snippets plugin):
  ```php
  add_action('wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory() . '/kodza/';
    $uri = get_stylesheet_directory_uri() . '/kodza/';
    wp_enqueue_style('kodza-menu-board', $uri . 'kodza-menu-board.css', [], filemtime($dir . 'kodza-menu-board.css'));
    wp_enqueue_script('kodza-menu-board', $uri . 'kodza-menu-board.js', [], filemtime($dir . 'kodza-menu-board.js'), true);
  });
  ```
  Scoped to `kodza-` classes, the CSS never touches the theme. One copy of the JS, whatever the number of blocks.
- No access to the theme files? The CSS can also go, as it is, in **Appearance → Customize → Additional CSS** (classic theme) or **Styles → Additional CSS** in the Site Editor (block theme).
- Theme with AJAX page transitions: fire `kodza:init` on the new content after each swap (and `kodza:destroy` on the old one).
- Performance plugin (WP Rocket, LiteSpeed Cache, Perfmatters…) with **Delay JavaScript execution** turned on: add `kodza` to the scripts it must not delay. Otherwise the component waits for the visitor's first scroll, tap or mouse move, and that first click can be lost.
- Colours: it takes the colour of the text around it (or keeps its own monochrome): nothing to point at the theme.
- WordPress turns straight quotes and `--` into typographic ones in the text of a Custom HTML block (not inside `<code>` or `<pre>`).

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- CSS and JS: as above. Elementor's Site Settings → Theme Style styles every button, field, link and image of the page, components included; the theme guard at the end of the CSS keeps the component's buttons, fields and link colours as they are.
- The Elementor editor redraws each widget after the page has loaded: without the script below, the component stays still in the editor preview (the published page works). Add it once per site, in the footer like the component's script (`kodza/elementor-editor.js`, or a code-snippets plugin):

```js
// Kodza in the Elementor editor: the editor redraws each HTML widget after the page has loaded.
// Start Kodza in what it draws, stop it in what it replaces or removes. Once per site.
window.addEventListener('elementor/frontend/init', () => {
  if (!elementorFrontend.isEditMode()) return;
  class KodzaWidget extends elementorModules.frontend.handlers.Base {
    onInit() {
      super.onInit();
      this.kodza = [...this.$element[0].children];
      document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: this.$element[0] } }));
    }
    onDestroy() {
      super.onDestroy();
      this.kodza.forEach((root) => document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } })));
    }
  }
  elementorFrontend.elementsHandler.attachHandler('html', KodzaWidget);
});
```

- No child theme: everything can go in the HTML widget instead, `<style>` with the CSS, the HTML, then `<script>` with the JS. It works on the page and in the editor without the script above.

### Shopify

- Files: `assets/kodza-menu-board.css` and `assets/kodza-menu-board.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-menu-board.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-menu-board.js' | asset_url }}" defer></script>`, then the HTML.
- The theme editor re-renders sections: restart them with
  `document.addEventListener('shopify:section:load', (e) => document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: e.target } })));`
  and the same with `shopify:section:unload` → `kodza:destroy`.

### React / Next.js

- Files, side by side (e.g. in `components/kodza/`): `MenuBoard.tsx` (below), the CSS block under Code saved as `menu-board.css` and the JS block as `menu-board.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { MenuBoard } from '@/components/kodza/MenuBoard';` then `<MenuBoard />`. Props: `timezone` (`data-timezone`: "Europe/Paris"), `label` (`data-label`: "Menu"), `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-menu-board-fg': … }}`), `className`, `style` and the other props of its `<div>` (`onClick`, `aria-…`).
- Next.js App Router: it is a Client Component already (`'use client'`). Pages Router: Next.js takes global CSS only in `pages/_app`, so move the `import './menu-board.css'` line there.
- Safe under StrictMode, client-side navigation and prop changes: when a prop changes, the `key` gives React a fresh element, `kodza:destroy` stops the script on the old one and `kodza:init` starts it on the new one.
- Built from your data (tabs, rows, options)? Add what the markup is made of to the `key`, as plain values (ids, text, numbers). What the visitor changes (the open tab, the sort, what they type) is the script's: follow it with `onClick` or `onInput` on the root, and change it from your code as a visitor would (click the tab; set the field's value, then dispatch a bubbling `input` event). Fields stay uncontrolled (`defaultValue`, `name`).

```tsx
'use client';
// Kodza — Menu Board, for React. From https://kodza.dev/components/menu-board/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: menu-board.css and menu-board.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './menu-board.css';

export type MenuBoardProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "timezone" | "label"> & {
  /** data-timezone on the root (default: Europe/Paris) */
  timezone?: "Europe/Paris";
  /** data-label on the root (default: Menu) */
  label?: "Menu";
  /** Settings, as CSS variables on the root */
  vars?: {
    /** default: #0a0a0a */
    "--kz-menu-board-fg"?: string | number;
    /** default: rgba(10, 10, 10, 0.62) */
    "--kz-menu-board-muted"?: string | number;
    /** default: rgba(10, 10, 10, 0.1) */
    "--kz-menu-board-line"?: string | number;
    /** the leader between a dish and its price — default: rgba(10, 10, 10, 0.34) */
    "--kz-menu-board-dots"?: string | number;
    /** default: #ffffff */
    "--kz-menu-board-tabs-bg"?: string | number;
    /** default: #0a0a0a */
    "--kz-menu-board-ink"?: string | number;
    /** default: #ffffff */
    "--kz-menu-board-on-ink"?: string | number;
    /** behind a pinned title (stack layout) — default: rgba(255, 255, 255, 0.78) */
    "--kz-menu-board-veil"?: string | number;
    /** height of a fixed header above it (stack layout) — default: 0px */
    "--kz-menu-board-top"?: string | number;
    /** default: 720px */
    "--kz-menu-board-width"?: string | number;
    /** default: 0.5s */
    "--kz-menu-board-duration"?: string | number;
    /** default: cubic-bezier(0.22, 1, 0.36, 1) */
    "--kz-menu-board-ease"?: string | number;
  };
  className?: string;
};

export function MenuBoard({ timezone = "Europe/Paris", label = "Menu", vars, className, style, ...rest }: MenuBoardProps) {
  const ref = useRef<ComponentRef<'div'>>(null);
  const classes = ["kodza-menu-board", className].filter(Boolean).join(' ');
  // Everything the markup is made of: when it changes, React gets a fresh element (see the top)
  const key = JSON.stringify([classes, timezone, label, vars, style, rest]);

  // Starts the script on this element once React has rendered it, stops it when it goes
  useEffect(() => {
    const root = ref.current;
    let live = true;
    // @ts-ignore: a plain script, no types to find
    import('./menu-board.js').then(() => {
      if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
    });
    return () => {
      live = false;
      document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
    };
  }, [key]);

  return (
    <div data-kodza-menu-board="" key={key} {...rest} className={classes} data-kodza-manual data-timezone={timezone} data-label={label} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <section className="kodza-menu-board__service" data-hours="12:00-14:30">
        <div className="kodza-menu-board__head">
          <h3 className="kodza-menu-board__title">Lunch</h3>
          <p className="kodza-menu-board__note">12:00 – 14:30 · Prices in euros, service included</p>
        </div>
        <ul className="kodza-menu-board__list">
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Mussels and fries <span className="kodza-menu-board__origin" lang="fr">Moules-frites</span></span>
              <span className="kodza-menu-board__price">16.50</span>
            </div>
            <p className="kodza-menu-board__desc">Bay mussels, white wine, shallots. The fries are cut here and fried twice.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains shellfish, celery</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Fish of the morning <span className="kodza-menu-board__origin" lang="fr">Poisson du jour</span></span>
              <span className="kodza-menu-board__price">21.00</span>
            </div>
            <p className="kodza-menu-board__desc">Whatever the boats brought in at six. Butter, lemon, nothing to hide it.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains fish, dairy</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Leek and Neufchâtel tart <span className="kodza-menu-board__origin" lang="fr">Tarte aux poireaux</span></span>
              <span className="kodza-menu-board__price">13.00</span>
            </div>
            <p className="kodza-menu-board__desc">The heart-shaped cheese from up the road, leeks from Gonfreville.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="veg">Vegetarian</span>
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains gluten, dairy, egg</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish kodza-menu-board__dish--out">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Grey shrimp <span className="kodza-menu-board__origin" lang="fr">Crevettes grises</span></span>
              <span className="kodza-menu-board__price">9.50</span>
            </div>
            <p className="kodza-menu-board__desc">A bowl of shrimp and bread. You peel them, we don’t judge.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="out">Sold out</span>
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains crustaceans</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Teurgoule</span>
              <span className="kodza-menu-board__price">7.00</span>
            </div>
            <p className="kodza-menu-board__desc">Rice pudding baked five hours with cinnamon. Normandy patience.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="veg">Vegetarian</span>
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains dairy</span>
            </div>
          </li>
        </ul>
      </section>
      <section className="kodza-menu-board__service" data-hours="19:00-22:30">
        <div className="kodza-menu-board__head">
          <h3 className="kodza-menu-board__title">Dinner</h3>
          <p className="kodza-menu-board__note">19:00 – 22:30 · Last orders at 22:00</p>
        </div>
        <ul className="kodza-menu-board__list">
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Fish soup, rouille <span className="kodza-menu-board__origin" lang="fr">Soupe de poissons</span></span>
              <span className="kodza-menu-board__price">11.00</span>
            </div>
            <p className="kodza-menu-board__desc">Rockfish and saffron. The garlic and chilli rouille comes on the side.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="spicy">Spicy</span>
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains fish, egg</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Seared scallops <span className="kodza-menu-board__origin" lang="fr">Saint-Jacques snackées</span></span>
              <span className="kodza-menu-board__price">26.00</span>
            </div>
            <p className="kodza-menu-board__desc">From October to May. The rest of the year, we don’t pretend.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains molluscs, dairy</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Duck with cider <span className="kodza-menu-board__origin" lang="fr">Canard au cidre</span></span>
              <span className="kodza-menu-board__price">24.00</span>
            </div>
            <p className="kodza-menu-board__desc">Pays d’Auge cider, apples cooked in the pan juices.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains dairy, sulphites</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Leek and Neufchâtel tart <span className="kodza-menu-board__origin" lang="fr">Tarte aux poireaux</span></span>
              <span className="kodza-menu-board__price">13.00</span>
            </div>
            <p className="kodza-menu-board__desc">Same tart as lunch. It was good at noon, it still is.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="veg">Vegetarian</span>
              <span className="kodza-menu-board__mark" data-mark="allergens">Contains gluten, dairy, egg</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Pear and Calvados <span className="kodza-menu-board__origin" lang="fr">Poire au calvados</span></span>
              <span className="kodza-menu-board__price">9.00</span>
            </div>
            <p className="kodza-menu-board__desc">One poached pear. The Calvados sits next to it, not hidden in it.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="vegan">Vegan</span>
            </div>
          </li>
        </ul>
      </section>
      <section className="kodza-menu-board__service">
        <div className="kodza-menu-board__head">
          <h3 className="kodza-menu-board__title">Drinks</h3>
          <p className="kodza-menu-board__note">Until we close · Ask for the cider of the week</p>
        </div>
        <ul className="kodza-menu-board__list">
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Farm cider <span className="kodza-menu-board__origin" lang="fr">Cidre fermier</span></span>
              <span className="kodza-menu-board__price">5.50</span>
            </div>
            <p className="kodza-menu-board__desc">Dry, from the Pays d’Auge. 33 cl, cold.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="vegan">Vegan</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Pommeau</span>
              <span className="kodza-menu-board__price">6.00</span>
            </div>
            <p className="kodza-menu-board__desc">Apple juice and Calvados, aged together. Before dinner, not instead of it.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="vegan">Vegan</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Calvados</span>
              <span className="kodza-menu-board__price">8.00</span>
            </div>
            <p className="kodza-menu-board__desc">Three years in oak. Small glass, strong opinion.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="vegan">Vegan</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Lemonade <span className="kodza-menu-board__origin" lang="fr">Limonade artisanale</span></span>
              <span className="kodza-menu-board__price">4.00</span>
            </div>
            <p className="kodza-menu-board__desc">Made in Rouen. Sweet, but not stupid about it.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="vegan">Vegan</span>
            </div>
          </li>
          <li className="kodza-menu-board__dish">
            <div className="kodza-menu-board__line">
              <span className="kodza-menu-board__name">Espresso</span>
              <span className="kodza-menu-board__price">2.20</span>
            </div>
            <p className="kodza-menu-board__desc">No refills. Ask for another one, we’ll make it.</p>
            <div className="kodza-menu-board__marks">
              <span className="kodza-menu-board__mark" data-mark="vegan">Vegan</span>
            </div>
          </li>
        </ul>
      </section>
    </div>
  );
}
```

## Code

```html
<div class="kodza-menu-board" data-kodza-menu-board data-timezone="Europe/Paris" data-label="Menu">
  <section class="kodza-menu-board__service" data-hours="12:00-14:30">
    <div class="kodza-menu-board__head">
      <h3 class="kodza-menu-board__title">Lunch</h3>
      <p class="kodza-menu-board__note">12:00 – 14:30 · Prices in euros, service included</p>
    </div>
    <ul class="kodza-menu-board__list">
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Mussels and fries <span class="kodza-menu-board__origin" lang="fr">Moules-frites</span></span><span class="kodza-menu-board__price">16.50</span></div>
        <p class="kodza-menu-board__desc">Bay mussels, white wine, shallots. The fries are cut here and fried twice.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="allergens">Contains shellfish, celery</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Fish of the morning <span class="kodza-menu-board__origin" lang="fr">Poisson du jour</span></span><span class="kodza-menu-board__price">21.00</span></div>
        <p class="kodza-menu-board__desc">Whatever the boats brought in at six. Butter, lemon, nothing to hide it.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="allergens">Contains fish, dairy</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Leek and Neufchâtel tart <span class="kodza-menu-board__origin" lang="fr">Tarte aux poireaux</span></span><span class="kodza-menu-board__price">13.00</span></div>
        <p class="kodza-menu-board__desc">The heart-shaped cheese from up the road, leeks from Gonfreville.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="veg">Vegetarian</span><span class="kodza-menu-board__mark" data-mark="allergens">Contains gluten, dairy, egg</span></div>
      </li>
      <li class="kodza-menu-board__dish kodza-menu-board__dish--out">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Grey shrimp <span class="kodza-menu-board__origin" lang="fr">Crevettes grises</span></span><span class="kodza-menu-board__price">9.50</span></div>
        <p class="kodza-menu-board__desc">A bowl of shrimp and bread. You peel them, we don’t judge.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="out">Sold out</span><span class="kodza-menu-board__mark" data-mark="allergens">Contains crustaceans</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Teurgoule</span><span class="kodza-menu-board__price">7.00</span></div>
        <p class="kodza-menu-board__desc">Rice pudding baked five hours with cinnamon. Normandy patience.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="veg">Vegetarian</span><span class="kodza-menu-board__mark" data-mark="allergens">Contains dairy</span></div>
      </li>
    </ul>
  </section>
  <section class="kodza-menu-board__service" data-hours="19:00-22:30">
    <div class="kodza-menu-board__head">
      <h3 class="kodza-menu-board__title">Dinner</h3>
      <p class="kodza-menu-board__note">19:00 – 22:30 · Last orders at 22:00</p>
    </div>
    <ul class="kodza-menu-board__list">
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Fish soup, rouille <span class="kodza-menu-board__origin" lang="fr">Soupe de poissons</span></span><span class="kodza-menu-board__price">11.00</span></div>
        <p class="kodza-menu-board__desc">Rockfish and saffron. The garlic and chilli rouille comes on the side.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="spicy">Spicy</span><span class="kodza-menu-board__mark" data-mark="allergens">Contains fish, egg</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Seared scallops <span class="kodza-menu-board__origin" lang="fr">Saint-Jacques snackées</span></span><span class="kodza-menu-board__price">26.00</span></div>
        <p class="kodza-menu-board__desc">From October to May. The rest of the year, we don’t pretend.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="allergens">Contains molluscs, dairy</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Duck with cider <span class="kodza-menu-board__origin" lang="fr">Canard au cidre</span></span><span class="kodza-menu-board__price">24.00</span></div>
        <p class="kodza-menu-board__desc">Pays d’Auge cider, apples cooked in the pan juices.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="allergens">Contains dairy, sulphites</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Leek and Neufchâtel tart <span class="kodza-menu-board__origin" lang="fr">Tarte aux poireaux</span></span><span class="kodza-menu-board__price">13.00</span></div>
        <p class="kodza-menu-board__desc">Same tart as lunch. It was good at noon, it still is.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="veg">Vegetarian</span><span class="kodza-menu-board__mark" data-mark="allergens">Contains gluten, dairy, egg</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Pear and Calvados <span class="kodza-menu-board__origin" lang="fr">Poire au calvados</span></span><span class="kodza-menu-board__price">9.00</span></div>
        <p class="kodza-menu-board__desc">One poached pear. The Calvados sits next to it, not hidden in it.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="vegan">Vegan</span></div>
      </li>
    </ul>
  </section>
  <section class="kodza-menu-board__service">
    <div class="kodza-menu-board__head">
      <h3 class="kodza-menu-board__title">Drinks</h3>
      <p class="kodza-menu-board__note">Until we close · Ask for the cider of the week</p>
    </div>
    <ul class="kodza-menu-board__list">
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Farm cider <span class="kodza-menu-board__origin" lang="fr">Cidre fermier</span></span><span class="kodza-menu-board__price">5.50</span></div>
        <p class="kodza-menu-board__desc">Dry, from the Pays d’Auge. 33 cl, cold.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="vegan">Vegan</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Pommeau</span><span class="kodza-menu-board__price">6.00</span></div>
        <p class="kodza-menu-board__desc">Apple juice and Calvados, aged together. Before dinner, not instead of it.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="vegan">Vegan</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Calvados</span><span class="kodza-menu-board__price">8.00</span></div>
        <p class="kodza-menu-board__desc">Three years in oak. Small glass, strong opinion.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="vegan">Vegan</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Lemonade <span class="kodza-menu-board__origin" lang="fr">Limonade artisanale</span></span><span class="kodza-menu-board__price">4.00</span></div>
        <p class="kodza-menu-board__desc">Made in Rouen. Sweet, but not stupid about it.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="vegan">Vegan</span></div>
      </li>
      <li class="kodza-menu-board__dish">
        <div class="kodza-menu-board__line"><span class="kodza-menu-board__name">Espresso</span><span class="kodza-menu-board__price">2.20</span></div>
        <p class="kodza-menu-board__desc">No refills. Ask for another one, we’ll make it.</p>
        <div class="kodza-menu-board__marks"><span class="kodza-menu-board__mark" data-mark="vegan">Vegan</span></div>
      </li>
    </ul>
  </section>
</div>
```

```css
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Menu Board
   A restaurant menu by service. Each dish has its name (and the one it has at home), a line of
   description, a price set in tabular figures at the end of a dotted leader, and its marks
   (vegetarian, spicy, allergens), each an icon and words. The script turns the services into tabs
   with the ink pill of Tabs, and opens on the one served now at the place; data-layout="stack"
   keeps them one under the other, each title pinned while you read its dishes.
   Everything is tuned with the --kz-menu-board-* variables. */
.kodza-menu-board {
  --kz-menu-board-fg: #0a0a0a;
  --kz-menu-board-muted: rgba(10, 10, 10, 0.62);
  --kz-menu-board-line: rgba(10, 10, 10, 0.1);
  --kz-menu-board-dots: rgba(10, 10, 10, 0.34); /* the leader between a dish and its price */
  --kz-menu-board-tabs-bg: #ffffff;
  --kz-menu-board-ink: #0a0a0a;
  --kz-menu-board-on-ink: #ffffff;
  --kz-menu-board-veil: rgba(255, 255, 255, 0.78); /* behind a pinned title (stack layout) */
  --kz-menu-board-top: 0px; /* height of a fixed header above it (stack layout) */
  --kz-menu-board-width: 720px;
  --kz-menu-board-duration: 0.5s;
  --kz-menu-board-ease: cubic-bezier(0.22, 1, 0.36, 1);

  box-sizing: border-box;
  width: 100%;
  max-width: var(--kz-menu-board-width);
  color: var(--kz-menu-board-fg);
}

/* A service: its title, a note (hours, what the prices include), its dishes */
.kodza-menu-board__service + .kodza-menu-board__service {
  margin-top: 48px;
}

.kodza-menu-board__head {
  position: relative;
  padding-bottom: 10px;
}

.kodza-menu-board__title {
  margin: 0;
  color: var(--kz-menu-board-fg);
  font-family: var(--kz-font-display, inherit);
  font-size: calc(30px * var(--kz-font-display-scale, 1));
  font-weight: var(--kz-font-display-weight, 600);
  line-height: 1.1;
  letter-spacing: var(--kz-font-display-tracking, -0.035em);
  text-transform: var(--kz-font-display-case, inherit);
}

.kodza-menu-board__note {
  margin: 8px 0 0;
  text-wrap: balance;
  color: var(--kz-menu-board-muted);
  font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
  font-size: var(--kz-font-label-size, 12.5px);
  font-weight: var(--kz-font-label-weight, 400);
  line-height: var(--kz-font-label-leading, 1.4);
  letter-spacing: var(--kz-font-label-tracking, normal);
  text-transform: var(--kz-font-label-case, none);
}

.kodza-menu-board__list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.kodza-menu-board__dish {
  display: grid;
  gap: 6px;
  padding: 18px 0;
  border-top: 1px solid var(--kz-menu-board-line);
}

.kodza-menu-board__dish:last-child {
  padding-bottom: 0;
}

/* Name, leader, price: the price keeps to the right on the last line of the name, the leader
   fills what is left */
.kodza-menu-board__line {
  display: flex;
  align-items: baseline;
  align-items: last baseline;
  gap: 10px;
}

.kodza-menu-board__line::after {
  content: "";
  order: 1;
  flex: 1 1 24px;
  min-width: 24px;
  height: 2px;
  background: radial-gradient(circle, var(--kz-menu-board-dots) 0.9px, transparent 1.3px) 0 50% / 6px 2px repeat-x;
}

.kodza-menu-board__name {
  min-width: 0;
  color: var(--kz-menu-board-fg);
  font-size: 17px;
  font-weight: 500;
  line-height: 1.4;
  letter-spacing: -0.01em;
  text-wrap: pretty;
}

.kodza-menu-board__origin {
  /* The name it has at home stays whole, on the next line if need be; it breaks only when longer
     than the line itself */
  display: inline-block;
  color: var(--kz-menu-board-muted);
  font-weight: 400;
}

.kodza-menu-board__price {
  flex: none;
  order: 2;
  color: var(--kz-menu-board-fg);
  font-size: 17px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  line-height: 1.4;
  letter-spacing: -0.01em;
  white-space: nowrap;
}

.kodza-menu-board__desc {
  max-width: 34em;
  margin: 0;
  color: var(--kz-menu-board-muted);
  font-size: 15px;
  line-height: 1.55;
  text-wrap: pretty;
}

/* The marks: an icon and words, never the icon alone */
.kodza-menu-board__marks {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 16px;
  margin-top: 4px;
}

.kodza-menu-board__marks:empty {
  display: none;
}

.kodza-menu-board__mark {
  --kz-menu-board-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E%3C/svg%3E");

  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--kz-menu-board-muted);
  font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
  font-size: var(--kz-font-label-size, 12px);
  font-weight: var(--kz-font-label-weight, 400);
  line-height: 1;
  letter-spacing: var(--kz-font-label-tracking, normal);
  text-transform: var(--kz-font-label-case, none);
}

.kodza-menu-board__mark::before {
  content: "";
  flex: none;
  width: 14px;
  height: 14px;
  background: currentColor;
  -webkit-mask: var(--kz-menu-board-icon) center / contain no-repeat;
  mask: var(--kz-menu-board-icon) center / contain no-repeat;
}

.kodza-menu-board__mark[data-mark="veg"] {
  --kz-menu-board-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 19C5 10 10 5 19 5c0 9-5 14-14 14Z'/%3E%3Cpath d='M5 19l8-8'/%3E%3C/svg%3E");
}

.kodza-menu-board__mark[data-mark="vegan"] {
  --kz-menu-board-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 21v-8'/%3E%3Cpath d='M12 13C12 8 9 5 4 5c0 5 3 8 8 8Z'/%3E%3Cpath d='M12 11c0-4 2.5-6 7-6 0 4-2.5 6-7 6Z'/%3E%3C/svg%3E");
}

.kodza-menu-board__mark[data-mark="spicy"] {
  --kz-menu-board-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 21c-3.9 0-7-2.8-7-6.5 0-2.6 1.5-4.6 3-6 .3 1.6 1.2 2.7 2.4 3.2C10 8.2 11.5 5 15 3c-.5 3 .8 5 2.2 6.8 1.1 1.4 1.8 3 1.8 4.7 0 3.7-3.1 6.5-7 6.5Z'/%3E%3C/svg%3E");
}

.kodza-menu-board__mark[data-mark="allergens"] {
  --kz-menu-board-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E%3Cpath d='M12 7.5v5.5'/%3E%3Cpath d='M12 16.5v.01'/%3E%3C/svg%3E");
}

.kodza-menu-board__mark[data-mark="out"] {
  --kz-menu-board-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='8.5'/%3E%3Cpath d='M6 18 18 6'/%3E%3C/svg%3E");

  height: 22px;
  padding: 0 9px 0 7px;
  border-radius: var(--kz-radius-pill, 999px);
  background: var(--kz-menu-board-ink);
  color: var(--kz-menu-board-on-ink);
  font-weight: var(--kz-font-label-weight, 500);
}

.kodza-menu-board__mark[data-mark="out"]::before {
  width: 12px;
  height: 12px;
}

/* Sold out: still on the menu, at a lower voice, its price struck through */
.kodza-menu-board__dish--out .kodza-menu-board__name {
  color: var(--kz-menu-board-muted);
}

.kodza-menu-board__dish--out .kodza-menu-board__price {
  color: var(--kz-menu-board-muted);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}

/* Tabs (built by the script) */
.kodza-menu-board.is-tabs {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
}

.kodza-menu-board__tabs {
  position: relative;
  display: inline-flex;
  justify-self: start;
  box-sizing: border-box;
  max-width: 100%;
  margin-bottom: 28px;
  padding: 4px;
  border-radius: var(--kz-radius-pill, 999px);
  background: var(--kz-menu-board-tabs-bg);
  box-shadow: inset 0 0 0 1px var(--kz-menu-board-line);
}

/* More services than room (a phone): the row scrolls sideways */
.kodza-menu-board__tabs.is-scroll {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}

.kodza-menu-board__tabs.is-scroll::-webkit-scrollbar {
  display: none;
}

.kodza-menu-board__tab,
.kodza-menu-board__ghost {
  display: inline-flex;
  flex: none;
  align-items: center;
  box-sizing: border-box;
  height: 36px;
  padding: 0 18px;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 15px);
  font-weight: var(--kz-font-label-weight, 500);
  line-height: 1;
  letter-spacing: var(--kz-font-label-tracking, normal);
  text-transform: var(--kz-font-label-case, none);
  white-space: nowrap;
}

.kodza-menu-board__tab {
  position: relative;
  margin: 0;
  border: 0;
  border-radius: var(--kz-radius-pill, 999px);
  background: none;
  color: var(--kz-menu-board-muted);
  cursor: pointer;
  transition: color calc(0.3s * var(--kz-motion-scale, 1)) ease;
}

.kodza-menu-board__tab:hover,
.kodza-menu-board__tab[aria-selected="true"] {
  color: var(--kz-menu-board-fg);
}

.kodza-menu-board__tab:focus-visible {
  outline: 2px solid var(--kz-menu-board-fg);
  outline-offset: 2px;
}

/* The service served now: a dot before its name */
.kodza-menu-board__now {
  display: none;
  position: relative;
  width: 6px;
  height: 6px;
  order: -1;
  margin-right: 8px;
  border-radius: 50%;
  background: currentColor;
}

.is-now > .kodza-menu-board__now {
  display: inline-block;
}

.kodza-menu-board__hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The pill covers the whole row (scrolled part included) and is cut down to the chosen service:
   only clip-path moves. A white copy of the names, cut the same way, turns them white exactly
   at its edges */
.kodza-menu-board__indicator,
.kodza-menu-board__mirror {
  position: absolute;
  inset: 0 auto 0 0;
  box-sizing: border-box;
  width: var(--kz-menu-board-track, 100%);
  clip-path: inset(4px var(--kz-menu-board-r, 100%) 4px var(--kz-menu-board-l, 0) round var(--kz-radius-pill, 999px));
  pointer-events: none;
}

.kodza-menu-board__indicator {
  background: var(--kz-menu-board-ink);
}

.kodza-menu-board__mirror {
  display: flex;
  padding: 4px;
  color: var(--kz-menu-board-on-ink);
}

.kodza-menu-board.is-live .kodza-menu-board__indicator,
.kodza-menu-board.is-live .kodza-menu-board__mirror {
  transition: clip-path calc(var(--kz-menu-board-duration) * var(--kz-motion-scale, 1)) var(--kz-menu-board-ease);
}

/* In tabs, the services are stacked in one cell: the board keeps the height of the longest, so
   nothing under it moves. The old one leaves on one side while the new one comes in from the
   side of the service you picked */
.kodza-menu-board.is-tabs .kodza-menu-board__service {
  grid-area: 2 / 1;
  margin: 0;
  visibility: hidden;
  opacity: 0;
  /* A waiting service sits a little to the side, never further than the page leaves there (a
     phone gutter): it never widens the page */
  transform: translateX(calc(var(--kz-menu-board-leave, -1) * min(14px, (100vw - 100%) / 2)));
  transition:
    opacity calc(0.2s * var(--kz-motion-scale, 1)) ease,
    transform calc(0.3s * var(--kz-motion-scale, 1)) ease-in,
    visibility 0s linear calc(0.3s * var(--kz-motion-scale, 1));
}

.kodza-menu-board.is-tabs .kodza-menu-board__service.is-active {
  visibility: visible;
  opacity: 1;
  transform: none;
  transition:
    opacity calc(0.4s * var(--kz-motion-scale, 1)) ease calc(0.06s * var(--kz-motion-scale, 1)),
    transform calc(var(--kz-menu-board-duration) * var(--kz-motion-scale, 1)) var(--kz-menu-board-ease) calc(0.06s * var(--kz-motion-scale, 1)),
    visibility 0s;
}

.kodza-menu-board.is-tabs .kodza-menu-board__service.is-from {
  transform: translateX(calc(var(--kz-menu-board-leave, -1) * -1 * min(22px, (100vw - 100%) / 2)));
  transition: none;
}

.kodza-menu-board.is-tabs .kodza-menu-board__service:focus-visible {
  border-radius: calc(6px * var(--kz-radius-scale, 1));
  outline: 2px solid var(--kz-menu-board-fg);
  outline-offset: 6px;
}

/* The tab already says the service: its title stays for screen readers only */
.kodza-menu-board.is-tabs .kodza-menu-board__title {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.kodza-menu-board.is-tabs .kodza-menu-board__note {
  margin: 0;
}

/* Stack: each title stays in view while its dishes scroll by, on a frosted veil once something
   passes under it */
.kodza-menu-board.is-stack .kodza-menu-board__head {
  position: sticky;
  top: var(--kz-menu-board-top);
  z-index: 1;
  padding: 14px 0 12px;
}

.kodza-menu-board.is-stack .kodza-menu-board__head::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--kz-menu-board-veil);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  box-shadow: 0 1px 0 var(--kz-menu-board-line);
  opacity: 0;
  transition: opacity calc(0.25s * var(--kz-motion-scale, 1)) ease;
  pointer-events: none;
}

.kodza-menu-board.is-stack .kodza-menu-board__head.is-stuck::before {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-menu-board.is-live .kodza-menu-board__indicator,
  .kodza-menu-board.is-live .kodza-menu-board__mirror,
  .kodza-menu-board__tab,
  .kodza-menu-board.is-tabs .kodza-menu-board__service,
  .kodza-menu-board.is-tabs .kodza-menu-board__service.is-active,
  .kodza-menu-board.is-stack .kodza-menu-board__head::before {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}

/* Theming example — on a dark background:
.dark-section .kodza-menu-board {
  --kz-menu-board-fg: #f2f2f0;
  --kz-menu-board-muted: rgba(242, 242, 240, 0.62);
  --kz-menu-board-line: rgba(255, 255, 255, 0.12);
  --kz-menu-board-dots: rgba(242, 242, 240, 0.36);
  --kz-menu-board-tabs-bg: #161616;
  --kz-menu-board-ink: #f2f2f0;
  --kz-menu-board-on-ink: #0a0a0a;
  --kz-menu-board-veil: rgba(10, 10, 10, 0.82);
}
*/

/* kodza-guard 5612f4d631a9 — generated by Kodza, do not edit.
   Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
   component. :not(#_) is always true and weighs as much as an id, so these lines win over
   theme rules made of classes and tags. Customise the component through its --kz-* variables. */

/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */

:where(.kodza-menu-board__tab):not(#_),
:where(.kodza-menu-board__service):not(#_) {
  outline: revert;
  outline-offset: revert;
}

:where(.kodza-menu-board__title):not(#_) {
  position: revert;
}

/* What the browser gives, on everything the component leaves to it */

.kodza-menu-board__tab:not(#_) {
  text-decoration: revert;
  list-style: revert;
  float: revert;
  box-shadow: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  min-height: revert;
}

.kodza-menu-board__now:not(#_),
.kodza-menu-board__hidden:not(#_),
.kodza-menu-board__head:not(#_),
.kodza-menu-board__line:not(#_),
.kodza-menu-board__name:not(#_),
.kodza-menu-board__origin:not(#_),
.kodza-menu-board__price:not(#_),
.kodza-menu-board__marks:not(#_),
.kodza-menu-board__mark:not(#_),
.kodza-menu-board__tab span:where(:not([class*="kodza-"])):not(#_),
.kodza-menu-board__ghost span:where(:not([class*="kodza-"])):not(#_) {
  box-sizing: revert;
}

.kodza-menu-board__service:not(#_) {
  box-sizing: revert;
  box-shadow: revert;
}

.kodza-menu-board__title:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-menu-board__note:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-menu-board__list:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-menu-board__dish:not(#_) {
  margin: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  border-right-width: revert;
  border-bottom-width: revert;
  border-left-width: revert;
  border-right-style: revert;
  border-bottom-style: revert;
  border-left-style: revert;
  border-right-color: revert;
  border-bottom-color: revert;
  border-left-color: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-menu-board__desc:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: revert;
  font-family: revert;
  font-weight: revert;
  font-style: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-menu-board__title:not(#_):where(:not([hidden])),
.kodza-menu-board__note:not(#_):where(:not([hidden])),
.kodza-menu-board__list:not(#_):where(:not([hidden])),
.kodza-menu-board__desc:not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-menu-board__title:not(#_):where([hidden]),
.kodza-menu-board__note:not(#_):where([hidden]),
.kodza-menu-board__list:not(#_):where([hidden]),
.kodza-menu-board__desc:not(#_):where([hidden]) {
  display: none;
}

.kodza-menu-board__line:not(#_)::after,
.kodza-menu-board__mark:not(#_)::before,
.kodza-menu-board__head:not(#_)::before {
  box-sizing: revert;
}

/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */

.kodza-menu-board:not(#_) {
  width: min(100%, var(--kz-menu-board-width));
}

.kodza-menu-board__title:not(#_) {
  margin: 0;
  color: var(--kz-menu-board-fg);
  font-family: var(--kz-font-display, inherit);
  font-size: calc(30px * var(--kz-font-display-scale, 1));
  font-weight: var(--kz-font-display-weight, 600);
  line-height: 1.1;
  letter-spacing: var(--kz-font-display-tracking, -0.035em);
  text-transform: var(--kz-font-display-case, inherit);
}

.kodza-menu-board__note:not(#_) {
  margin: 8px 0 0;
  color: var(--kz-menu-board-muted);
  font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
  font-size: var(--kz-font-label-size, 12.5px);
  font-weight: var(--kz-font-label-weight, 400);
  line-height: var(--kz-font-label-leading, 1.4);
  letter-spacing: var(--kz-font-label-tracking, normal);
  text-transform: var(--kz-font-label-case, none);
}

.kodza-menu-board__list:not(#_) {
  margin: 0;
  padding: 0;
  list-style: none;
}

.kodza-menu-board__dish:not(#_) {
  display: grid;
  padding: 18px 0;
  border-top: 1px solid var(--kz-menu-board-line);
}

.kodza-menu-board__dish:last-child:not(#_) {
  padding-bottom: 0;
}

.kodza-menu-board__desc:not(#_) {
  max-width: 34em;
  margin: 0;
  color: var(--kz-menu-board-muted);
  font-size: 15px;
  line-height: 1.55;
}

.kodza-menu-board__tab:not(#_) {
  display: inline-flex;
  flex: none;
  align-items: center;
  box-sizing: border-box;
  height: 36px;
  padding: 0 18px;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 15px);
  font-weight: var(--kz-font-label-weight, 500);
  line-height: 1;
  letter-spacing: var(--kz-font-label-tracking, normal);
  text-transform: var(--kz-font-label-case, none);
  white-space: nowrap;
}

.kodza-menu-board__tab:not(#_) {
  position: relative;
  margin: 0;
  border: 0;
  border-radius: var(--kz-radius-pill, 999px);
  background: none;
  color: var(--kz-menu-board-muted);
  cursor: pointer;
  transition: color calc(0.3s * var(--kz-motion-scale, 1)) ease;
}

.kodza-menu-board__tab:hover:not(#_),
.kodza-menu-board__tab[aria-selected="true"]:not(#_) {
  color: var(--kz-menu-board-fg);
}

.kodza-menu-board__tab:focus-visible:not(#_) {
  outline: 2px solid var(--kz-menu-board-fg);
  outline-offset: 2px;
}

.kodza-menu-board.is-tabs .kodza-menu-board__service:focus-visible:not(#_) {
  outline: 2px solid var(--kz-menu-board-fg);
  outline-offset: 6px;
}

.kodza-menu-board.is-tabs .kodza-menu-board__title:not(#_) {
  position: absolute;
}

.kodza-menu-board.is-tabs .kodza-menu-board__note:not(#_) {
  margin: 0;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-menu-board__tab:not(#_) {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}
```

```js
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Menu Board
// Turns the services into tabs (the ink pill of Tabs) and opens on the one served now at the place,
// a dot before its name. data-layout="stack" keeps them one under the other instead, each title
// pinned on a frosted veil while its dishes scroll by. Without the script, the menu reads as is.
// Settings: data-hours on a service ("12:00-14:30", several with commas, past midnight allowed),
// data-timezone on the root (the place's, "Europe/Paris"), data-now="19:30" to fix the time (a
// screenshot, a test), data-label (the name of the row of tabs), and TICK below.
(() => {
  const SELECTOR = '[data-kodza-menu-board]';
  const TICK = 60000; // how often the dot checks the hour (a clock, not motion: it is not paced)
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  // The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
  // script times it, and 0 moves nothing, like reduced motion
  const paceOf = (el) => {
    const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
    return pace >= 0 ? pace : 1;
  };
  let uid = 0;

  // "12:30" -> 750
  const minutes = (time) => {
    const [h, m] = time.trim().split(':').map(Number);
    return h * 60 + (m || 0);
  };
  // The hour at the place, whatever the visitor's clock says
  const clockOf = (zone) => {
    const options = { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' };
    try {
      return new Intl.DateTimeFormat('en-GB', { ...options, timeZone: zone || undefined });
    } catch {
      return new Intl.DateTimeFormat('en-GB', options);
    }
  };
  // "12:00-14:30, 19:00-22:30" -> [[720, 870], [1140, 1350]]
  const rangesOf = (hours) => (hours || '').split(',').map((part) => part.split('-'))
    .filter((pair) => pair.length === 2 && pair.every((t) => /^\s*\d{1,2}(:\d{2})?\s*$/.test(t)))
    .map(([a, b]) => [minutes(a), minutes(b)]);
  const within = (t, [a, b]) => (a < b ? a <= t && t < b : t >= a || t < b);

  // One instance, with its own state: two copies on a page never interfere. Everything it
  // starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
  // 'abort' event), so kodza:destroy stops it all.
  function mount(root, signal) {
    const pace = paceOf(root);
    const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
    const services = [...root.querySelectorAll(':scope > .kodza-menu-board__service')];
    if (!services.length) return;

    // Stack: the titles pin themselves (CSS); the script only lays the veil once a dish passes under
    if (root.dataset.layout === 'stack') {
      root.classList.add('is-stack');
      const heads = services.map((service) => [service, service.querySelector('.kodza-menu-board__head')]).filter(([, head]) => head);
      let frame = 0;
      const spy = () => {
        frame = 0;
        heads.forEach(([service, head]) => {
          head.classList.toggle('is-stuck', service.getBoundingClientRect().top < head.getBoundingClientRect().top - 0.5);
        });
      };
      const queue = () => { frame ||= requestAnimationFrame(spy); };
      window.addEventListener('scroll', queue, { passive: true, signal });
      window.addEventListener('resize', queue, { passive: true, signal });
      signal.addEventListener('abort', () => cancelAnimationFrame(frame));
      spy();
      return;
    }

    root.classList.add('is-tabs');
    const prefix = `kodza-menu-board-${++uid}`; // ids are made here, so the copied HTML stays free of them
    const list = document.createElement('div');
    list.className = 'kodza-menu-board__tabs';
    list.setAttribute('role', 'tablist');
    list.setAttribute('aria-label', root.dataset.label || 'Menu');
    const indicator = document.createElement('span');
    indicator.className = 'kodza-menu-board__indicator';
    indicator.setAttribute('aria-hidden', 'true');
    list.append(indicator);

    const tabs = services.map((service, i) => {
      const tab = document.createElement('button');
      tab.type = 'button';
      tab.className = 'kodza-menu-board__tab';
      tab.setAttribute('role', 'tab');
      tab.id = `${prefix}-tab-${i}`;
      const name = document.createElement('span');
      name.textContent = (service.querySelector('.kodza-menu-board__title')?.textContent || `${i + 1}`).trim();
      // Drawn first (CSS order), read last: "Dinner, served now"
      const now = document.createElement('span');
      now.className = 'kodza-menu-board__now';
      const said = document.createElement('span');
      said.className = 'kodza-menu-board__hidden';
      said.textContent = ', served now';
      now.append(said);
      tab.append(name, now);
      service.id = `${prefix}-panel-${i}`;
      service.setAttribute('role', 'tabpanel');
      service.setAttribute('aria-labelledby', tab.id);
      service.tabIndex = 0;
      tab.setAttribute('aria-controls', service.id);
      list.append(tab);
      return tab;
    });

    // A white copy of the names, cut to the same shape as the pill
    const mirror = document.createElement('span');
    mirror.className = 'kodza-menu-board__mirror';
    mirror.setAttribute('aria-hidden', 'true');
    const ghosts = tabs.map((tab) => {
      const ghost = document.createElement('span');
      ghost.className = 'kodza-menu-board__ghost';
      ghost.append(...[...tab.childNodes].map((node) => node.cloneNode(true)));
      return ghost;
    });
    mirror.append(...ghosts);
    list.append(mirror);
    root.prepend(list);

    // The pill and the white copy span the whole row, scrolled part included
    const place = (tab, scroll = false) => {
      list.style.removeProperty('--kz-menu-board-track');
      const track = list.scrollWidth;
      list.classList.toggle('is-scroll', track > list.clientWidth + 1);
      list.style.setProperty('--kz-menu-board-track', `${track}px`);
      list.style.setProperty('--kz-menu-board-l', `${tab.offsetLeft}px`);
      list.style.setProperty('--kz-menu-board-r', `${track - tab.offsetLeft - tab.offsetWidth}px`);
      if (scroll && list.classList.contains('is-scroll')) {
        const left = tab.offsetLeft - (list.clientWidth - tab.offsetWidth) / 2;
        list.scrollTo({ left, behavior: still ? 'auto' : 'smooth' });
      }
    };

    // The time at the place, or the one written in data-now
    const clock = clockOf(root.dataset.timezone);
    const now = () => {
      if (/^\d{1,2}:\d{2}$/.test(root.dataset.now || '')) return minutes(root.dataset.now);
      const parts = Object.fromEntries(clock.formatToParts(new Date()).map((p) => [p.type, p.value]));
      return minutes(`${parts.hour}:${parts.minute}`);
    };
    const hours = services.map((service) => rangesOf(service.dataset.hours));
    const servedAt = (t) => hours.findIndex((ranges) => ranges.some((range) => within(t, range)));

    let current = 0;
    const select = (index, focus = false, first = false) => {
      const next = (index + tabs.length) % tabs.length;
      tabs.forEach((tab, i) => {
        const on = i === next;
        tab.setAttribute('aria-selected', String(on));
        tab.tabIndex = on ? 0 : -1;
      });
      // The old service leaves on the side you come from, the new one enters from the other
      root.style.setProperty('--kz-menu-board-leave', next > current ? -1 : 1);
      services.forEach((service, i) => {
        if (i !== next) service.classList.remove('is-active');
      });
      const service = services[next];
      if (next !== current || first) {
        if (!first) {
          service.classList.add('is-from');
          void service.offsetWidth;
          service.classList.remove('is-from');
        }
        service.classList.add('is-active');
      }
      current = next;
      place(tabs[next], !first);
      if (focus) tabs[next].focus();
    };

    // The dot follows the hour; the open tab never changes under the reader
    let timer = 0;
    const mark = () => {
      const served = servedAt(now());
      tabs.forEach((tab, i) => {
        tab.classList.toggle('is-now', i === served);
        ghosts[i].classList.toggle('is-now', i === served);
      });
      place(tabs[current]);
    };
    const tick = () => {
      mark();
      timer = setTimeout(tick, TICK - (Date.now() % TICK) + 50);
    };
    signal.addEventListener('abort', () => clearTimeout(timer));

    // Opens on the service served now, else the next one today, else the first
    const t = now();
    let start = servedAt(t);
    if (start < 0) {
      let soonest = Infinity;
      hours.forEach((ranges, i) => ranges.forEach(([a]) => {
        if (a > t && a < soonest) {
          soonest = a;
          start = i;
        }
      }));
    }
    current = Math.max(0, start);

    tabs.forEach((tab, i) => tab.addEventListener('click', () => select(i), { signal }));
    list.addEventListener('keydown', (event) => {
      const keys = { ArrowRight: current + 1, ArrowLeft: current - 1, Home: 0, End: tabs.length - 1 };
      if (!(event.key in keys)) return;
      event.preventDefault();
      select(keys[event.key], true);
    }, { signal });
    select(current, false, true);
    tick();
    const observer = new ResizeObserver(() => place(tabs[current]));
    observer.observe(list);
    signal.addEventListener('abort', () => observer.disconnect());
    document.fonts?.ready.then(() => !signal.aborted && place(tabs[current]));
    // The first placement is instant; only later moves slide
    requestAnimationFrame(() => !signal.aborted && root.classList.add('is-live'));
  }

  // Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
  // event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
  // markup back. For themes and apps that swap content without a page load. Each running root
  // keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
  // component inside another one is saved as written, not half-started, and stopped with it. Two
  // components on one root (a reveal on a stat card) both start, and stop together.
  const KODZA = Symbol.for('kodza');
  const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
  const instances = new WeakMap();
  const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
  const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
  const put = (el, saved) => {
    [...el.attributes].forEach((a) => el.removeAttribute(a.name));
    saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
    el.innerHTML = saved.html;
  };
  // The markup inside a root as written: Kodza components already running in it give back their own
  const written = (root) => {
    const all = [...root.querySelectorAll('*')];
    if (!all.some((el) => el[KODZA])) return root.innerHTML;
    const copy = root.cloneNode(true);
    const twins = copy.querySelectorAll('*');
    let last = null;
    all.forEach((el, i) => {
      if (!el[KODZA] || last?.contains(el)) return;
      last = el;
      put(twins[i], el[KODZA]);
    });
    return copy.innerHTML;
  };
  const init = (scope) => find(scope).forEach((root) => {
    // Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
    // running on it, a root is a copy of a started one (a cloned slide): it is left as it is
    if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
    // Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
    // it has hydrated it. Any earlier start would change markup React has not taken over yet
    const owner = root.closest('[data-kodza-manual]');
    if (owner && !(scope && owner.contains(scope))) return;
    const controller = new AbortController();
    // Another Kodza component already running on this root: its markup as written is kept, and the
    // two stop together
    const before = root[KODZA];
    const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
    if (before) before.above = saved;
    instances.set(root, { controller, saved, before });
    root[KODZA] = saved;
    root[RUNNING] = true;
    mount(root, controller.signal);
    root.classList.add('is-ready');
  });
  const destroy = (scope) => find(scope).forEach((root) => {
    const instance = instances.get(root);
    if (!instance) return;
    instances.delete(root);
    instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
    // The Kodza components running inside it stop first: once its markup is put back, their own
    // script could not find them any more
    root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
    instance.controller.abort();
    delete root[RUNNING];
    // Started on top of another one: that one stops next, and puts the markup back
    if (instance.before) {
      delete instance.before.above;
      root[KODZA] = instance.before;
      return instance.before.stop();
    }
    delete root[KODZA];
    put(root, instance.saved);
  });
  document.addEventListener('kodza:init', (event) => init(event.detail?.root));
  document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
  init();
  // Loaded in <head>, the script runs before the markup exists: once more when it is parsed
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
```

Free to use, from Kodza (https://kodza.dev).
