Interactive
ImageAccordion
A row of image slices. Hover, tap or tab to one and it opens wide while the others fold down to a side title. Stacks by itself in a narrow space.
Download
- React
- Tailwind CSS v4
components/ui/image-accordion.tsx
"use client";
import { useState, type ReactNode } from "react";
export type ImageAccordionItem = {
id: string;
title: string;
caption?: string;
/** Your image, filling the panel. It sits inside a button, so no links here. */
media: ReactNode;
};
type ImageAccordionProps = {
items: ImageAccordionItem[];
/** Index of the panel open at the start. */
start?: number;
/** How many times wider the open panel grows. */
grow?: number;
/** Set a height here. Panels sit side by side above 448px, stacked below. */
className?: string;
};
/* ImageAccordion: a row of image slices. Hover, tap or tab to one and it opens wide while the others fold down. */
export function ImageAccordion({ items, start = 0, grow = 4, className = "h-80" }: ImageAccordionProps) {
const [active, setActive] = useState(start);
return (
<div className={`@container ${className}`}>
<div className="flex h-full flex-col gap-2 @md:flex-row">
{items.map((item, i) => {
const open = i === active;
return (
<button
key={item.id}
type="button"
aria-expanded={open}
onPointerEnter={(e) => e.pointerType === "mouse" && setActive(i)}
onFocus={() => setActive(i)}
onClick={() => setActive(i)}
style={{ flexGrow: open ? grow : 1, flexBasis: 0 }}
className="relative min-h-0 min-w-0 overflow-hidden rounded-2xl text-left text-white outline-none transition-[flex-grow] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] focus-visible:ring-2 focus-visible:ring-white/70 motion-reduce:transition-none"
>
<span className="absolute inset-0 block *:h-full *:w-full *:object-cover">{item.media}</span>
<span className={`absolute inset-0 transition-colors duration-500 ${open ? "bg-black/25" : "bg-black/50"}`} />
<span className={`absolute inset-0 grid place-items-center transition-opacity duration-300 ${open ? "opacity-0" : "opacity-100"}`}>
<span className="truncate text-sm font-medium @md:[writing-mode:vertical-rl]">{item.title}</span>
</span>
<span className={`absolute bottom-0 left-0 block w-48 p-4 transition-opacity duration-300 ${open ? "opacity-100 delay-150" : "opacity-0"}`}>
<span className="block font-semibold">{item.title}</span>
{item.caption && <span className="mt-1 block text-xs text-white/85">{item.caption}</span>}
</span>
</button>
);
})}
</div>
</div>
);
}
1. Paste the Code tab into
components/ui/image-accordion.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/image-accordion-demo.tsx
import { ImageAccordion } from "@/components/ui/image-accordion"; // Gradients stand in for photos. Pass an <img> as media. const scene = (from: string, to: string) => <div style={{ background: `radial-gradient(circle at 70% 25%, ${from}, ${to} 75%)` }} />; const PLACES = [ { id: "coast", title: "Coast", caption: "Salt air and long light.", media: scene("#fde68a", "#0e7490") }, { id: "forest", title: "Forest", caption: "Green all the way up.", media: scene("#bef264", "#14532d") }, { id: "desert", title: "Desert", caption: "Quiet until the wind.", media: scene("#fed7aa", "#9a3412") }, { id: "peaks", title: "Peaks", caption: "Snow above the cloud.", media: scene("#e0e7ff", "#3730a3") }, ]; export default function ImageAccordionExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <ImageAccordion items={PLACES} className="h-full max-h-[320px] w-full max-w-[560px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | ImageAccordionItem[] | — | Panels: id, title, caption and media. Media sits inside a button, so no links there. |
| start | number | 0 | Index of the panel open at the start. |
| grow | number | 4 | How many times wider the open panel grows. |
| className | string | "h-80" | Set a height here. Panels sit side by side above 448px. |