Restaurant menu component with animated tab underline (Framer Motion layoutId), classic fine-dining dotted-line item style (name ... price), dietary badges, hover-to-preview dish photos, AnimatePresence transitions. Run when site is for a restaurant, cafe, or bar.
The restaurant menu done right. Tab between categories, see fine-dining formatted items with dotted separators, hover for photos.
src/app/restaurant/page.tsx - or /menu depending on navsrc/app/restaurant/layout.tsx - metadatasrc/data/menu.ts - typed menu itemssrc/types/index.ts - add MenuItem type if not presentHorizontal flex, with animated gold underline that slides between active tabs:
{categories.map(cat => (
<button onClick={() => setActive(cat.key)} className="relative px-4 py-3 text-xs uppercase tracking-widest">
<span className={active === cat.key ? "text-accent" : "text-muted"}>
{cat.label}
</span>
{active === cat.key && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</button>
))}
The layoutId is the magic - Framer Motion animates the underline between tabs automatically.
Classic fine-dining format with dotted separator:
<div>
<div className="flex items-baseline gap-2">
<h3 className="font-serif text-lg text-charcoal whitespace-nowrap">
{item.name}
</h3>
<div className="flex-1 border-b border-dotted border-taupe min-w-[2rem] translate-y-[-4px]" />
<span className="font-serif text-lg text-charcoal whitespace-nowrap">
{formatCurrency(item.price)}
</span>
</div>
<p className="mt-1.5 text-sm text-charcoal-light leading-relaxed">
{item.description}
</p>
{item.dietary && (
<div className="mt-2 flex gap-2">
{item.dietary.map(tag => (
<span className="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wider text-accent border border-accent/30 rounded-sm">
{tag}
</span>
))}
</div>
)}
</div>
Wrap items in AnimatePresence mode="wait" for smooth crossfade when switching tabs:
<AnimatePresence mode="wait">
<motion.div
key={active}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{filteredItems.map(...)}
</motion.div>
</AnimatePresence>
If dishes have photos, show a floating image on hover:
<div
onMouseEnter={() => setHovered(item.id)}
onMouseLeave={() => setHovered(null)}
>
{/* item content */}
<AnimatePresence>
{hovered === item.id && item.image && (
<motion.div
className="absolute right-0 top-0 w-64 aspect-square pointer-events-none"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<Image src={item.image} alt={item.name} fill className="object-cover rounded" />
</motion.div>
)}
</AnimatePresence>
</div>
// src/types/index.ts
export interface MenuItem {
id: string;
name: string;
description: string;
price: number;
category: "starters" | "mains" | "seafood" | "platters" | "breakfast" | "desserts" | "drinks";
dietary?: string[];
image?: string;
}
// src/data/menu.ts
export const menuItems: MenuItem[] = [
// ...
];
Adjust category enum based on what the actual restaurant offers.
Tab labels can be in local language with English subtitle:
Pull from site-plan.md language preference.
layoutId must be unique per animated element. Don't reuse "activeTab" elsewhere in the app or animations will cross-fire.
The dotted line is border-bottom dotted, not a background image. Background-image with dots doesn't align across different font weights. The border approach handles it.
whitespace-nowrap on name + price prevents the flex layout from breaking when a long name wraps.
Currency formatting - use formatCurrency from utils.ts. Don't hardcode $ or ₱. Pulls from locale.
Don't load all categories at once if there are 100+ items. Render only the active category to save DOM nodes.
Hover-preview photo needs absolute positioning relative to the menu container. Make sure parent has position: relative.
Mobile - hover-preview doesn't work. On tablet, touchstart might trigger. Gate to desktop only with media query check or @media (hover: hover).
templates/menu-page.tsx.template - full componenttemplates/menu-data.ts.template - sample data structure