Departure Board
The next departures from a place, worked out at its own time: the day's timetable by season and weekday, the next trip on top with a countdown that rolls every minute, gone trips faded, a line once the day is over.
View codeA 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").
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Menu Board to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<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>
/* 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;
}
}
// 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 });
})();
'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>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.