Savor
Warm restaurant site with turning plates, a tabbed seasonal menu and a working table-reservation form.
app/page.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { ArrowRight, Check, Clock, Leaf, MapPin, Phone, Star } from "lucide-react";
import { useState, type FormEvent } from "react";
const ease = [0.22, 1, 0.36, 1] as const;
const up = {
initial: { opacity: 0, y: 24 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.3 },
transition: { duration: 0.7, ease },
};
type Dish = { name: string; text: string; price: string; veg?: boolean; colors: [string, string] };
const MENU: Record<string, Dish[]> = {
Starters: [
{ name: "Charred corn chaat", text: "Lime, chilli butter, crisp sev", price: "₹320", veg: true, colors: ["#fbbf24", "#f97316"] },
{ name: "Smoked beet tartare", text: "Curd, mustard seed, rye crisp", price: "₹360", veg: true, colors: ["#be123c", "#7c2d12"] },
{ name: "Pepper prawn skewers", text: "Curry leaf, coconut, lemon", price: "₹480", colors: ["#fb7185", "#f59e0b"] },
],
Mains: [
{ name: "Wood-fired aubergine", text: "Tamarind glaze, peanut, herbs", price: "₹540", veg: true, colors: ["#6d28d9", "#3f1d5c"] },
{ name: "Slow lamb with saffron rice", text: "Twelve-hour braise, mint raita", price: "₹820", colors: ["#b45309", "#78350f"] },
{ name: "Coastal fish curry", text: "Kokum, coconut milk, red rice", price: "₹760", colors: ["#ea580c", "#facc15"] },
],
Desserts: [
{ name: "Jaggery caramel custard", text: "Sea salt, roasted sesame", price: "₹290", veg: true, colors: ["#d97706", "#92400e"] },
{ name: "Mango and cardamom cream", text: "Pistachio crumb", price: "₹310", veg: true, colors: ["#fde047", "#fb923c"] },
{ name: "Dark chocolate and filter coffee", text: "Warm, with malai ice cream", price: "₹340", veg: true, colors: ["#451a03", "#a16207"] },
],
};
/** A "plate": concentric gradients that turn slowly, standing in for a food photo. */
function Plate({ colors, size = 96 }: { colors: [string, string]; size?: number }) {
return (
<motion.span aria-hidden className="relative block shrink-0 rounded-full bg-white shadow-lg ring-1 ring-black/5" style={{ width: size, height: size }} whileHover={{ rotate: 20, scale: 1.05 }} transition={{ type: "spring", stiffness: 200, damping: 14 }}>
<span className="absolute inset-[14%] rounded-full" style={{ background: `conic-gradient(from 40deg, ${colors[0]}, ${colors[1]}, ${colors[0]})` }} />
<span className="absolute inset-[34%] rounded-full bg-white/40 backdrop-blur-sm" />
</motion.span>
);
}
function Reservation() {
const [state, setState] = useState<"form" | "done">("form");
const [error, setError] = useState("");
const [guests, setGuests] = useState(2);
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
if (String(f.get("name")).trim().length < 2) return setError("Please enter your name.");
if (!/^[\d\s+()-]{8,}$/.test(String(f.get("phone")))) return setError("Please enter a valid phone number.");
if (!f.get("date")) return setError("Please choose a date.");
setError("");
setState("done");
}
const input = "h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-sm outline-none transition focus:border-orange-600 focus:ring-4 focus:ring-orange-600/10";
return (
<div className="rounded-3xl bg-white p-8 shadow-xl shadow-orange-900/10 ring-1 ring-stone-200">
<AnimatePresence mode="wait">
{state === "done" ? (
<motion.div key="done" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="py-10 text-center">
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", delay: 0.1 }} className="mx-auto grid size-14 place-items-center rounded-full bg-emerald-600 text-white">
<Check className="size-7" />
</motion.span>
<p className="mt-5 font-serif text-2xl text-stone-900">Your table is held</p>
<p className="mt-2 text-sm text-stone-500">We'll confirm by phone within the hour.</p>
<button onClick={() => setState("form")} className="mt-5 text-sm font-medium text-orange-700 underline underline-offset-4">
Make another booking
</button>
</motion.div>
) : (
<motion.form key="form" onSubmit={submit} noValidate exit={{ opacity: 0 }} className="space-y-4">
<p className="font-serif text-2xl text-stone-900">Reserve a table</p>
<input name="name" placeholder="Your name" aria-label="Your name" autoComplete="name" className={input} />
<input name="phone" placeholder="Phone number" aria-label="Phone number" inputMode="tel" autoComplete="tel" className={input} />
<div className="grid grid-cols-2 gap-3">
<input name="date" type="date" aria-label="Date" className={input} />
<select name="time" aria-label="Time" className={input} defaultValue="20:00">
{["12:30", "13:30", "19:00", "20:00", "21:00"].map((t) => (
<option key={t}>{t}</option>
))}
</select>
</div>
<div className="flex items-center justify-between rounded-xl border border-stone-300 px-4 py-2.5">
<span className="text-sm text-stone-600">Guests</span>
<span className="flex items-center gap-3">
<button type="button" aria-label="Fewer guests" onClick={() => setGuests((g) => Math.max(1, g - 1))} className="grid size-8 place-items-center rounded-full bg-stone-100 text-lg hover:bg-stone-200">
−
</button>
<span className="w-5 text-center font-medium tabular-nums text-stone-900">{guests}</span>
<button type="button" aria-label="More guests" onClick={() => setGuests((g) => Math.min(12, g + 1))} className="grid size-8 place-items-center rounded-full bg-stone-100 text-lg hover:bg-stone-200">
+
</button>
</span>
</div>
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
<button className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-orange-700 text-sm font-medium text-white transition hover:bg-orange-800">
Book for {guests} <ArrowRight className="size-4" />
</button>
</motion.form>
)}
</AnimatePresence>
</div>
);
}
export default function SavorTemplate() {
const [tab, setTab] = useState("Mains");
return (
<div className="min-h-screen bg-[#fbf6ee] font-sans text-stone-700 antialiased">
<header className="sticky top-0 z-40 border-b border-stone-200/70 bg-[#fbf6ee]/85 backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<span className="font-serif text-2xl italic text-stone-900">Savor</span>
<nav className="hidden gap-8 text-sm md:flex">
{["Menu", "Our story", "Private dining", "Visit"].map((l) => (
<a key={l} href="#" className="transition-colors hover:text-stone-950">
{l}
</a>
))}
</nav>
<a href="#reserve" className="rounded-full bg-stone-900 px-5 py-2 text-sm font-medium text-white transition hover:bg-stone-700">
Reserve
</a>
</div>
</header>
<section className="mx-auto grid max-w-6xl items-center gap-12 overflow-hidden px-6 pb-20 pt-16 lg:grid-cols-2 lg:pt-24">
<div>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="inline-flex items-center gap-2 text-sm font-medium text-orange-700">
<Star className="size-4 fill-orange-600 text-orange-600" /> Rated 4.9 by 2,300 diners
</motion.p>
<motion.h1 initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease }} className="mt-5 font-serif text-6xl leading-[1.02] text-stone-950 sm:text-7xl">
Fire, spice and <em className="text-orange-700">patience</em>.
</motion.h1>
<motion.p initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.7 }} className="mt-6 max-w-md text-lg leading-relaxed text-stone-600">
A seasonal kitchen cooking over wood, with produce from farms an hour away. Open for lunch and dinner, Tuesday to Sunday.
</motion.p>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.35, duration: 0.7 }} className="mt-9 flex flex-wrap items-center gap-5">
<a href="#reserve" className="inline-flex items-center gap-2 rounded-full bg-orange-700 px-7 py-3.5 text-sm font-medium text-white shadow-lg shadow-orange-700/25 transition hover:-translate-y-0.5">
Reserve a table <ArrowRight className="size-4" />
</a>
<a href="#menu" className="text-sm font-medium text-stone-900 underline underline-offset-4">
See the menu
</a>
</motion.div>
</div>
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 1, ease }} className="relative mx-auto grid size-72 place-items-center sm:size-[26rem]">
<motion.div aria-hidden className="absolute inset-0 rounded-full border border-dashed border-orange-300" animate={{ rotate: 360 }} transition={{ duration: 60, repeat: Infinity, ease: "linear" }} />
<motion.div animate={{ rotate: 360 }} transition={{ duration: 40, repeat: Infinity, ease: "linear" }}>
<Plate colors={["#ea580c", "#facc15"]} size={260} />
</motion.div>
<motion.div className="absolute -left-2 top-8" animate={{ y: [0, -10, 0] }} transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}>
<Plate colors={["#6d28d9", "#3f1d5c"]} size={92} />
</motion.div>
<motion.div className="absolute -right-1 bottom-10" animate={{ y: [0, 10, 0] }} transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}>
<Plate colors={["#fde047", "#fb923c"]} size={110} />
</motion.div>
</motion.div>
</section>
<section id="menu" className="bg-white py-20">
<div className="mx-auto max-w-4xl px-6">
<motion.div {...up} className="text-center">
<p className="text-sm font-medium uppercase tracking-[0.2em] text-orange-700">This season</p>
<h2 className="mt-3 font-serif text-5xl text-stone-950">The menu</h2>
</motion.div>
<div role="tablist" aria-label="Menu sections" className="mx-auto mt-10 flex w-fit rounded-full bg-stone-100 p-1">
{Object.keys(MENU).map((t) => (
<button key={t} role="tab" aria-selected={tab === t} onClick={() => setTab(t)} className="relative rounded-full px-6 py-2 text-sm font-medium">
{tab === t && <motion.span layoutId="savor-tab" className="absolute inset-0 rounded-full bg-stone-900" transition={{ type: "spring", stiffness: 400, damping: 32 }} />}
<span className={`relative ${tab === t ? "text-white" : "text-stone-600"}`}>{t}</span>
</button>
))}
</div>
<AnimatePresence mode="wait">
<motion.ul key={tab} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.3 }} className="mt-10 divide-y divide-stone-200">
{MENU[tab].map((d, i) => (
<motion.li key={d.name} initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.07 }} className="flex items-center gap-5 py-5">
<Plate colors={d.colors} size={64} />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-serif text-xl text-stone-950">
{d.name}
{d.veg && <Leaf className="size-4 text-emerald-600" aria-label="Vegetarian" />}
</p>
<p className="text-sm text-stone-500">{d.text}</p>
</div>
<p className="font-medium tabular-nums text-stone-900">{d.price}</p>
</motion.li>
))}
</motion.ul>
</AnimatePresence>
</div>
</section>
<section id="reserve" className="mx-auto grid max-w-6xl items-center gap-12 px-6 py-24 lg:grid-cols-2">
<motion.div {...up}>
<h2 className="font-serif text-5xl leading-tight text-stone-950">Come hungry. Stay late.</h2>
<ul className="mt-8 space-y-5 text-stone-700">
<li className="flex gap-4">
<Clock className="mt-0.5 size-5 shrink-0 text-orange-700" />
<span>
<strong className="block text-stone-950">Tuesday to Sunday</strong>Lunch 12:30 to 3:00 · Dinner 7:00 to 11:00
</span>
</li>
<li className="flex gap-4">
<MapPin className="mt-0.5 size-5 shrink-0 text-orange-700" />
<span>
<strong className="block text-stone-950">14 Garden Lane</strong>Opposite the old lighthouse
</span>
</li>
<li className="flex gap-4">
<Phone className="mt-0.5 size-5 shrink-0 text-orange-700" />
<span>
<strong className="block text-stone-950">+91 98765 43210</strong>For groups of nine or more
</span>
</li>
</ul>
</motion.div>
<motion.div {...up} transition={{ duration: 0.7, delay: 0.1, ease }}>
<Reservation />
</motion.div>
</section>
<footer className="border-t border-stone-200 py-10">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-6 text-sm text-stone-500">
<span className="font-serif text-xl italic text-stone-900">Savor</span>
<span>© 2026 Savor Kitchen · Cooked over wood, served with care</span>
</div>
</footer>
</div>
);
}
What's included
- Navbar
- Hero + plates
- Tabbed menu
- Hours and location
- Reservation form
- Footer
Use it in 3 steps
1. Install dependencies in your Next.js + Tailwind project:
$ npm i motion lucide-react- 2. Copy the code into
app/page.tsx. - 3. Replace the copy, colors and links with your own — then deploy.