Menu Card Slider
A restaurant menu slider: an intro card then dish cards with photo, price and description, moved with square arrows. React and Tailwind CSS.
MenuCardSlider.tsx
"use client";
// Menu slider: an intro card followed by dish cards (photo, name, price,
// description) that slide one step at a time with square arrow buttons.
// Pure Tailwind + React, no carousel library. Works with Tailwind v3 and v4.
import { useEffect, useState } from "react";
type Dish = { name: string; price: string; description: string; image: string };
type Props = {
eyebrow?: string;
heading?: string[];
intro?: string;
dishes?: Dish[];
};
const img = (id: string) => `https://images.unsplash.com/photo-${id}?w=900&q=80&auto=format&fit=crop`;
const DEFAULT_DISHES: Dish[] = [
{ name: "Duck confit", price: "$32", description: "Slow-rendered duck leg, celeriac purée, pickled cherry, thyme jus", image: img("1414235077428-338989a2e8c0") },
{ name: "Bouillabaisse", price: "$54", description: "Marseille-style, saffron broth, rouille, grilled sourdough", image: img("1547592166-23ac45744acd") },
{ name: "Côte de boeuf", price: "$68", description: "28-day dry-aged, bordelaise sauce, pommes sarladaises", image: img("1529692236671-f1f6cf9683ba") },
{ name: "Garden salad", price: "$18", description: "Market leaves, shaved fennel, toasted seeds, sherry dressing", image: img("1540189549336-e6e99c3679fe") },
];
const Arrow = ({ flip = false }: { flip?: boolean }) => (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden className={flip ? "rotate-180" : ""}>
<path d="M7 4l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
// Cards visible at once per breakpoint (matches the w-full / md:w-1/2 / lg:w-1/3 widths below)
function useVisible() {
const [visible, setVisible] = useState(1);
useEffect(() => {
const update = () => setVisible(window.innerWidth >= 1024 ? 3 : window.innerWidth >= 768 ? 2 : 1);
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
return visible;
}
export default function ConveyorBeltSlider({
eyebrow = "From the kitchen",
heading = ["Signature", "plates"],
intro = "Great food begins long before it reaches the plate: in our relationships with local farmers, the hours of preparation and a deep respect for classical technique.",
dishes = DEFAULT_DISHES,
}: Props) {
const visible = useVisible();
const total = dishes.length + 1; // intro card + dishes
const maxIndex = Math.max(0, total - visible);
const [index, setIndex] = useState(0);
const current = Math.min(index, maxIndex);
const button = "grid h-14 w-14 place-items-center border border-[#0F4729]/30 bg-white text-[#0F4729] transition-colors duration-200 hover:border-[#0F4729] hover:bg-[#0F4729] hover:text-white disabled:pointer-events-none disabled:opacity-40";
return (
<section className="bg-[#FFF7EA] px-5 py-20 font-['Inter',ui-sans-serif,system-ui,sans-serif] text-[#1c1917] antialiased md:px-10 md:py-28">
<div className="mx-auto max-w-7xl">
<div className="overflow-hidden">
<div className="-ml-6 flex transition-transform duration-500 ease-out" style={{ transform: `translateX(-${(current * 100) / visible}%)` }}>
<div className="w-full shrink-0 pl-6 md:w-1/2 lg:w-1/3">
<div className="lg:pr-8">
<p className="mb-4 text-xs font-bold uppercase tracking-[0.2em] text-[#0F4729]">{eyebrow}</p>
<h2 className="mb-6 font-['Bricolage_Grotesque',ui-sans-serif,system-ui,sans-serif] text-[clamp(2.5rem,4vw,4rem)] font-bold uppercase leading-[1.1] tracking-tight text-[#0F4729]">
{heading.map((line) => (
<span key={line} className="block">
{line}
</span>
))}
</h2>
<p className="mb-10 text-lg font-medium text-stone-500">{intro}</p>
<div className="flex gap-3">
<button type="button" onClick={() => setIndex(current - 1)} disabled={current === 0} aria-label="Previous" className={button}>
<Arrow flip />
</button>
<button type="button" onClick={() => setIndex(current + 1)} disabled={current === maxIndex} aria-label="Next" className={button}>
<Arrow />
</button>
</div>
</div>
</div>
{dishes.map((d) => (
<article key={d.name} className="group w-full shrink-0 pl-6 md:w-1/2 lg:w-1/3">
<div className="relative h-[420px] w-full overflow-hidden">
<img src={d.image} alt={d.name} loading="lazy" className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-105" />
</div>
<div className="py-6">
<div className="mb-2 flex items-start justify-between gap-4">
<h3 className="text-lg font-black uppercase tracking-widest text-[#0F4729]">{d.name}</h3>
<span className="text-lg font-bold text-[#0F4729]">{d.price}</span>
</div>
<p className="text-base leading-relaxed text-stone-600">{d.description}</p>
</div>
</article>
))}
</div>
</div>
</div>
</section>
);
}
How to use it
- Make sure your Next.js or React project has Tailwind CSS set up.
- Create
components/MenuCardSlider.tsxand paste the code. - Import it into a page and replace the placeholder text and images with your own.
More free components
All carousel →
Ready for a faster site that's easier to edit?
Book a free 30-minute call. We'll review your current site and send a fixed-price plan. No pressure, no pitch.
Book Free Call No commitment required


