"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(null); const board = useRef(null); const stub = useRef(null); const dust = useRef(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("[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) => { 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 (
{sections.map((s, i) => { const on = i === active; return ( ); })}
{sections[tab]?.items.map((item) => ( ))}
{/* Bare slate through the chalk. */}
{Array.from({ length: 8 }, (_, i) => ( ))} {wiping && ( { 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 /> )}
{/* The ledge, with what is left of the chalk. */}
); }