Numbered service rows that open on hover, sliding in a button and raising an arched photo. React and Tailwind CSS, no dependencies.
ExpandingServiceRows.tsx
"use client";
// Numbered service rows on a dark band. On desktop the hovered row opens: its
// button slides in and an arched photo rises on the right. Phones show every row open.
// Pure Tailwind + React, no dependencies. Works with Tailwind v3 and v4.
import { useState } from "react";
type Service = { title: string; text: string; link?: { label: string; href: string }; image: string };
type Props = {
heading?: string;
intro?: string;
services?: Service[];
};
const img = (id: string) => `https://images.unsplash.com/photo-${id}?w=600&q=80&auto=format&fit=crop`;
const DEFAULT_SERVICES: Service[] = [
{ title: "Breeding", text: "Fresh and frozen semen from proven stallions, with mare care on site through foaling.", link: { label: "Breeding fees", href: "#" }, image: img("1692024363479-1648f19f240f") },
{ title: "Training", text: "Young horses started patiently, then produced for the ring by our riders.", link: { label: "Meet the riders", href: "#" }, image: img("1695133996129-017af80651fc") },
{ title: "Livery", text: "Full livery in airy stables with daily turnout and a vet on call.", link: { label: "Livery options", href: "#" }, image: img("1576692192914-9abed71b3ef9") },
{ title: "Sales", text: "Horses we know inside out, matched to riders with honest advice and trial rides.", link: { label: "Horses for sale", href: "#" }, image: img("1639370804113-c398aca25aee") },
];
const ease = "ease-[cubic-bezier(0.22,1,0.36,1)]";
export default function ServiceRowsHover({
heading = "What we offer",
intro = "Four ways we work with owners, breeders and riders.",
services = DEFAULT_SERVICES,
}: Props) {
const [active, setActive] = useState(0);
return (
<section className="bg-[#210313] py-28 font-['Hanken_Grotesk',ui-sans-serif,system-ui,sans-serif] text-white antialiased md:py-40">
<div className="mx-auto grid max-w-[100rem] gap-8 px-4 md:px-8 lg:grid-cols-12 lg:items-end lg:px-12">
<h2 className="font-['Fraunces',Georgia,'Times_New_Roman',serif] text-[clamp(2.25rem,4.5vw,4.75rem)] leading-none tracking-[-0.03em] lg:col-span-7">{heading}</h2>
<p className="text-lg leading-relaxed text-white/65 lg:col-span-4 lg:col-start-9">{intro}</p>
</div>
<ul className="mt-14 md:mt-20">
{services.map((s, i) => {
const open = active === i;
return (
<li key={s.title} onPointerEnter={() => setActive(i)} onFocus={() => setActive(i)} className="relative">
<div aria-hidden className={`absolute inset-0 bg-[#2c081a] transition-opacity duration-700 ${ease} ${open ? "lg:opacity-100" : "opacity-0"}`} />
<div className="relative mx-auto grid max-w-[100rem] gap-5 px-4 py-9 md:px-8 lg:grid-cols-12 lg:items-center lg:gap-8 lg:px-12 lg:py-11">
<span className="font-['Fraunces',Georgia,serif] text-2xl italic text-[#dcc59a] lg:col-span-1">{String(i + 1).padStart(2, "0")}</span>
<div className="lg:col-span-5">
<h3 className={`font-['Fraunces',Georgia,serif] text-4xl transition-all duration-700 ${ease} md:text-5xl lg:text-6xl ${open ? "lg:translate-x-3 lg:text-[#efe2c4]" : ""}`}>{s.title}</h3>
{s.link && (
<div className={`grid transition-all duration-700 ${ease} ${open ? "lg:grid-rows-[1fr]" : "lg:grid-rows-[0fr] lg:opacity-0"}`}>
<div className="overflow-hidden lg:pl-3">
<a href={s.link.href} className="mt-7 inline-flex h-12 items-center border border-white/40 px-6 text-xs font-medium uppercase tracking-[0.1em] transition-colors hover:bg-white hover:text-[#210313]">
{s.link.label}
</a>
</div>
</div>
)}
</div>
<p className={`text-base leading-relaxed text-white/70 transition-opacity duration-700 lg:col-span-4 ${open ? "" : "lg:opacity-55"}`}>{s.text}</p>
{/* Phones and tablets: photo inline */}
<div className="aspect-video overflow-hidden lg:hidden">
<img src={s.image} alt={s.title} loading="lazy" className="h-full w-full object-cover" />
</div>
{/* Desktop: an arch that rises in when the row opens */}
<div className="hidden justify-end lg:col-span-2 lg:flex">
<div className={`aspect-[3/4] w-32 overflow-hidden rounded-t-full transition-[clip-path] duration-[1100ms] ease-in-out ${open ? "[clip-path:inset(0)]" : "[clip-path:inset(100%_0_0_0)]"}`}>
<img src={s.image} alt="" className={`h-full w-full object-cover transition-transform duration-[1600ms] ${ease} ${open ? "scale-100" : "scale-125"}`} />
</div>
</div>
</div>
</li>
);
})}
</ul>
</section>
);
}
How to use it
- Make sure your Next.js or React project has Tailwind CSS set up (v3 or v4 both work).
- Create
components/ExpandingServiceRows.tsxand paste the code. - Import it into a page and replace the sample text and images with your own.
