Website Sections
ChalkMenu
A menu on a real blackboard in a wooden frame. A stub of chalk writes every line when the board comes into view, the day's dish gets its price ringed, and changing the tab sends a felt duster across before the next list is written.
Download
- React
- Tailwind CSS v4
- motion
components/ui/chalk-menu.tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type ChalkItem = { name: string; price: string; special?: boolean };
type ChalkSection = { label: string; items: ChalkItem[] };
type ChalkMenuProps = {
/** Big line at the top of the board. */
title?: string;
/** Small line under the title. */
tagline?: string;
/** One tab each. Mark one dish `special` to ring its price. */
sections?: ChalkSection[];
/** Line under the coffee doodle. Shows when the board is wide. */
note?: string;
/** Called when a tab is chosen. */
onTabChange?: (index: number) => void;
/** Set the width here. */
className?: string;
};
const SECTIONS: ChalkSection[] = [
{
label: "Morning",
items: [
{ name: "Idli, two", price: "₹40" },
{ name: "Ghee pongal", price: "₹70", special: true },
{ name: "Masala dosa", price: "₹85" },
{ name: "Filter coffee", price: "₹30" },
],
},
{
label: "Noon",
items: [
{ name: "Full meals", price: "₹140", special: true },
{ name: "Sambar rice", price: "₹80" },
{ name: "Curd rice", price: "₹70" },
{ name: "Appalam, extra", price: "₹10" },
],
},
{
label: "Evening",
items: [
{ name: "Onion bajji", price: "₹45" },
{ name: "Medu vada", price: "₹35" },
{ name: "Rava dosa", price: "₹80", special: true },
{ name: "Sukku coffee", price: "₹25" },
],
},
];
const CHALK = "#f1eee3";
const YELLOW = "#f5e08a";
const PINK = "#f3b4c0";
const BLUE = "#a9d8e6";
const HAND = "'Segoe Print', 'Bradley Hand', 'Chalkboard SE', 'Comic Sans MS', cursive";
const SLATE = "#1d2926";
// Specks of bare slate laid over the writing: the board shows through the chalk.
const GRAIN = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.05' numOctaves='2' seed='7'/%3E%3CfeColorMatrix values='0 0 0 0 0.114 0 0 0 0 0.161 0 0 0 0 0.149 0 0 0 5.6 -2.9'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23g)'/%3E%3C/svg%3E")`;
const HIDDEN = "inset(-40% 100% -40% -3%)";
/* ChalkMenu: a menu on a real blackboard in a wooden frame. A stub of chalk writes every line when the board is seen, the day's dish gets its price ringed, and changing the tab sends a felt duster across before the next list is written. */
export function ChalkMenu({ title = "Kaveri Mess", tagline = "Written fresh every morning", sections = SECTIONS, note = "Second coffee is free before 9", onTabChange, className = "w-full max-w-3xl" }: ChalkMenuProps) {
const reduce = useReducedMotion();
const id = useId();
const box = useRef<HTMLDivElement>(null);
const board = useRef<HTMLDivElement>(null);
const stub = useRef<HTMLSpanElement>(null);
const dust = useRef<HTMLSpanElement>(null);
const seen = useInView(box, { once: true, amount: 0.3 });
const [tab, setTab] = useState(0);
const [wipe, setWipe] = useState(-1); // the tab waiting behind the duster
const [written, setWritten] = useState(false);
const [titled, setTitled] = useState(false);
const shown = written || !!reduce;
const wiping = wipe >= 0;
const clear = !shown && !wiping;
useEffect(() => {
if (!seen || !clear) return;
const root = board.current;
const chalk = stub.current;
const specks = dust.current;
if (!root || !chalk || !specks) return;
const lines = Array.from(root.querySelectorAll<HTMLElement>("[data-line]"));
const times = lines.map((l) => 160 + (l.textContent?.length ?? 0) * 20);
const puffs = Array.from(specks.children) as HTMLElement[];
let raf = 0;
let line = 0;
let begun = performance.now();
let nextPuff = 0;
let puff = 0;
chalk.style.opacity = "1";
const tick = (now: number) => {
const el = lines[line];
if (!el) {
chalk.style.opacity = "0";
setWritten(true);
setTitled(true);
return;
}
const p = Math.min(1, (now - begun) / times[line]);
el.style.clipPath = p < 1 ? `inset(-40% ${100 - p * 100}% -40% -3%)` : "none";
const a = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
const x = a.left - b.left + a.width * p;
const y = a.top - b.top + a.height * (0.45 + Math.sin(now / 38) * 0.2);
chalk.style.transform = `translate(${x}px, ${y}px)`;
if (now > nextPuff) {
nextPuff = now + 90;
const s = puffs[puff++ % puffs.length];
s?.animate(
[
{ transform: `translate(${x}px, ${y}px)`, opacity: 0.8 },
{ transform: `translate(${x - 5 + Math.random() * 10}px, ${y + 22 + Math.random() * 16}px)`, opacity: 0 },
],
{ duration: 750, easing: "ease-in" },
);
}
if (p >= 1) {
line += 1;
begun = now;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
chalk.style.opacity = "0";
};
}, [seen, clear, tab]);
const pick = (i: number) => {
if (i === (wiping ? wipe : tab)) return;
onTabChange?.(i);
if (reduce) setTab(i);
else setWipe(i);
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const n = sections.length;
const from = wiping ? wipe : tab;
const to = e.key === "ArrowRight" ? (from + 1) % n : e.key === "ArrowLeft" ? (from + n - 1) % n : e.key === "Home" ? 0 : e.key === "End" ? n - 1 : -1;
if (to < 0) return;
e.preventDefault();
pick(to);
e.currentTarget.querySelectorAll("button")[to]?.focus();
};
const active = wiping ? wipe : tab;
const fresh = !titled && !reduce;
return (
<div ref={box} className={`@container ${className}`}>
<div
className="relative select-none rounded-[0.55em] p-[0.6em] pb-[1.05em]"
style={{
fontSize: "clamp(11.5px, 3.7cqw, 20px)",
background: "linear-gradient(100deg, #7a4d2b, #a06a3c 18%, #8a5a33 42%, #a9743f 66%, #6f4526)",
boxShadow: "0 0.5em 1.2em #0009, inset 0 0.08em 0 #ffffff33, inset 0 -0.1em 0 #0005",
}}
>
<div
ref={board}
className="relative overflow-hidden rounded-[0.15em] px-[1.1em] pt-[0.55em] pb-[0.8em]"
style={{
color: CHALK,
fontFamily: HAND,
background: `radial-gradient(60% 50% at 24% 30%, #ffffff12, transparent 70%), radial-gradient(50% 60% at 78% 70%, #ffffff0e, transparent 70%), linear-gradient(120deg, #ffffff08 20%, transparent 21% 44%, #ffffff06 45%, transparent 46%), ${SLATE}`,
boxShadow: "inset 0 0.25em 0.6em #000a",
}}
>
<div className="flex items-baseline justify-between gap-[1em]">
<h3 data-line={titled ? undefined : ""} className="m-0 truncate text-[1.65em] leading-[1.25] font-bold" style={{ color: YELLOW, clipPath: fresh ? HIDDEN : undefined }}>
{title}
</h3>
<p data-line={titled ? undefined : ""} className="m-0 hidden truncate text-[0.82em] @[420px]:block" style={{ color: BLUE, clipPath: fresh ? HIDDEN : undefined }}>
{tagline}
</p>
</div>
<div role="tablist" aria-label="Menu" onKeyDown={onKey} className="mt-[0.2em] flex gap-[1.1em]">
{sections.map((s, i) => {
const on = i === active;
return (
<button
key={s.label}
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={on}
aria-controls={`${id}-list`}
tabIndex={on ? 0 : -1}
onClick={() => pick(i)}
className="relative cursor-pointer pb-[0.2em] leading-[1.35]"
style={{ color: on ? PINK : CHALK, opacity: on ? 1 : 0.68 }}
>
{s.label}
{on && (
<svg viewBox="0 0 60 6" preserveAspectRatio="none" className="absolute -bottom-[0.1em] left-0 h-[0.4em] w-full" aria-hidden>
<path d="M1 3.5 C 12 1.5, 22 5, 33 3 S 52 1.8, 59 3.6" fill="none" stroke={PINK} strokeWidth="2" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
)}
</button>
);
})}
</div>
<div className="mt-[0.45em] grid gap-[1.6em] @[560px]:grid-cols-[1fr_11em]">
<motion.ul
key={tab}
id={`${id}-list`}
role="tabpanel"
aria-labelledby={`${id}-tab-${tab}`}
className="m-0 min-w-0 list-none p-0"
animate={{ opacity: wiping ? 0.08 : 1, filter: wiping ? "blur(3px)" : "blur(0px)" }}
transition={{ duration: 0.45 }}
>
{sections[tab]?.items.map((item) => (
<li key={item.name} data-line="" className="flex items-baseline gap-[0.45em] leading-[1.62]" style={{ clipPath: shown ? undefined : HIDDEN }}>
<span className="truncate">{item.name}</span>
<span className="min-w-[1em] flex-1 translate-y-[-0.2em] border-b-[0.13em] border-dotted" style={{ borderColor: `${CHALK}66` }} aria-hidden />
<span className="relative px-[0.2em] font-bold" style={{ color: YELLOW }}>
{item.price}
{item.special && shown && (
<svg viewBox="0 0 100 40" preserveAspectRatio="none" className="pointer-events-none absolute -inset-x-[0.4em] -inset-y-[0.08em] h-[calc(100%+0.16em)] w-[calc(100%+0.8em)]" aria-hidden>
<motion.path
d="M62 4 C 30 0, 6 7, 4 20 C 2 34, 40 38, 66 35 C 90 32, 99 22, 94 12 C 90 4, 66 1, 44 5"
fill="none"
stroke={PINK}
strokeWidth="1.9"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
/>
</svg>
)}
{item.special && <span className="sr-only"> (today's pick)</span>}
</span>
</li>
))}
</motion.ul>
<div data-line={titled ? undefined : ""} className="hidden text-center @[560px]:block" style={{ clipPath: fresh ? HIDDEN : undefined }}>
<svg viewBox="0 0 120 92" className="mx-auto h-[5.6em] w-auto" fill="none" stroke={CHALK} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M44 28 L49 66 C 50 70, 70 70, 71 66 L76 28" />
<path d="M43 28 C 50 32, 70 32, 77 28 C 70 24, 50 24, 43 28" />
<path d="M25 66 C 34 84, 86 84, 95 66 C 88 72, 74 74, 60 74 C 46 74, 32 72, 25 66" />
<path stroke={BLUE} d="M52 19 C 47 14, 57 10, 52 3 M64 19 C 59 14, 69 10, 64 3" />
</svg>
<p className="m-0 text-[0.84em] leading-[1.3]" style={{ color: BLUE }}>{note}</p>
</div>
</div>
{/* Bare slate through the chalk. */}
<div className="pointer-events-none absolute inset-0" style={{ backgroundImage: GRAIN, opacity: 0.7 }} aria-hidden />
<span ref={dust} className="pointer-events-none absolute top-0 left-0" aria-hidden>
{Array.from({ length: 8 }, (_, i) => (
<span key={i} className="absolute top-0 left-0 block size-[0.12em] rounded-full opacity-0" style={{ background: CHALK }} />
))}
</span>
<span ref={stub} className="pointer-events-none absolute top-0 left-0 opacity-0" aria-hidden>
<span className="block h-[0.4em] w-[1.5em] origin-left -translate-y-[0.3em] rotate-[-38deg] rounded-[0.06em]" style={{ background: "linear-gradient(180deg, #fffdf6, #d9d3c2)", boxShadow: "0.05em 0.12em 0.2em #0007" }} />
</span>
{wiping && (
<motion.span
className="pointer-events-none absolute z-10 block h-[2.5em] w-[5.2em] rounded-[0.3em]"
initial={{ left: "-30%", top: "12%", rotate: -7 }}
animate={{ left: ["-30%", "78%", "4%", "80%", "-34%"], top: ["12%", "24%", "40%", "60%", "72%"], rotate: [-7, 5, -5, 4, -7] }}
transition={{ duration: 0.72, ease: "easeInOut" }}
onAnimationComplete={() => {
setTab(wipe);
setWipe(-1);
setWritten(false);
}}
style={{ background: "linear-gradient(180deg, #a06a3c 0 42%, #55585c 42%, #d8d4c7)", boxShadow: "0 0.25em 0.5em #0009" }}
aria-hidden
/>
)}
</div>
{/* The ledge, with what is left of the chalk. */}
<div className="absolute inset-x-[0.3em] bottom-[0.14em] h-[0.72em] rounded-[0.14em]" style={{ background: "linear-gradient(180deg, #b9824a, #7a4d2b)", boxShadow: "0 0.14em 0.25em #0007" }} aria-hidden>
<span className="absolute top-[0.05em] left-[12%] block h-[0.3em] w-[1.5em] rounded-[0.05em]" style={{ background: "#f4f1e7" }} />
<span className="absolute top-[0.08em] left-[calc(12%+1.8em)] block h-[0.28em] w-[0.9em] rotate-[6deg] rounded-[0.05em]" style={{ background: PINK }} />
<span className="absolute top-[0.08em] right-[16%] block h-[0.28em] w-[1.1em] rotate-[-4deg] rounded-[0.05em]" style={{ background: YELLOW }} />
</div>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/chalk-menu.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/chalk-menu-demo.tsx
import { ChalkMenu } from "@/components/ui/chalk-menu"; export default function ChalkMenuExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#23201c] p-2"> <ChalkMenu className="w-[min(135cqh,96cqw,820px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | "Kaveri Mess" | Big line at the top of the board. |
| tagline | string | "Written fresh every morning" | Small line beside the title. Shows from 420px wide. |
| sections | { label: string; items: { name: string; price: string; special?: boolean }[] }[] | 3 sections | One tab each. Mark one dish special to ring its price. |
| note | string | coffee line | Under the coffee doodle. Shows from 560px wide. |
| onTabChange | (index: number) => void | - | Called when a tab is chosen. |
| className | string | "w-full max-w-3xl" | Set the width here. |