Expanding Panels
A row of panels where the one you point at opens and the others narrow.
From the Nitip template — see it in contextAurora2026
import { ExpandingPanels } from "@/components/ui/expanding-panels";
// Demo photos from Unsplash (free under the Unsplash License), loaded from its image CDN.
const photo = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&w=1000&q=80`;
const projects = [
{ value: "aurora", label: "Aurora", meta: "2026", image: photo("photo-1612089889100-bbcf80456e1f"), alt: "White walls against a deep blue sky" },
{ value: "kestrel", label: "Kestrel", meta: "2025", image: photo("photo-1600210492493-0946911123ea"), alt: "Living room with a leather sofa and tall windows" },
{ value: "halden", label: "Halden", meta: "2025", image: photo("photo-1628744876497-eb30460be9f6"), alt: "Bright room with two grey sofas facing each other" },
{ value: "oakmere", label: "Oakmere", meta: "2024", image: photo("photo-1586023492125-27b2c045efd7"), alt: "Yellow armchair beside a brass floor lamp" },
];
export default function ExpandingPanelsDemo() {
return (
<ExpandingPanels
label="Recent projects"
height={280}
collapsedSize={88}
className="max-w-xl"
items={projects.map((project) => ({
value: project.value,
label: project.label,
meta: project.meta,
media: <img src={project.image} alt={project.alt} className="object-cover" />,
}))}
/>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/expanding-panels.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/expanding-panels.tsx."use client"; import * as React from "react"; import Link from "next/link"; import { motion, useReducedMotion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Expanding Panels — a row of panels where the one you point at opens and the others narrow * (from the Nitip "Project Recent List" / "Project Simple Card"). * Framer's soft spring drives it: spring, bounce 0, duration 0.4s, on the panel's flex-grow and on the label's opacity. * Below 640px the panels stack at full width and every label stays visible, as Framer does on phones. * * Reduced motion: flex-grow is neither a transform nor a size key, so <MotionConfig reducedMotion="user"> does not * skip it — the component checks useReducedMotion() itself and switches panels instantly. * Each panel's button or link is named by its label and meta (aria-labelledby), which works while the label is faded * out and for labels that aren't plain text; a panel without a label is named by its value. */ const soft: Transition = { type: "spring", bounce: 0, duration: 0.4 }; export type ExpandingPanel = { value: string; /** The media: an image, a video, anything. It is stretched to the panel and clipped. */ media: React.ReactNode; /** Shown under the media; it fades in as the panel opens. */ label?: React.ReactNode; /** Shown at the right of the label. */ meta?: React.ReactNode; /** Makes the panel's media a Next.js link. */ href?: string; }; export type ExpandingPanelsProps = { items: ExpandingPanel[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Accessible name of the row. */ label: string; /** What opens a panel. "hover" also opens on tap and on keyboard focus. */ trigger?: "hover" | "click"; /** Width of a closed panel from 640px up, in px. */ collapsedSize?: number; /** Height of the row from 640px up, in px. */ height?: number; className?: string; }; export function ExpandingPanels({ items, value, defaultValue, onValueChange, label, trigger = "hover", collapsedSize = 130, height = 320, className, }: ExpandingPanelsProps) { const [internal, setInternal] = React.useState(defaultValue ?? items[0]?.value); const open = value ?? internal; const reduce = useReducedMotion(); function activate(next: string) { if (next === open) return; if (value === undefined) setInternal(next); onValueChange?.(next); } return ( <div role="group" aria-label={label} style={{ ["--panels-height" as string]: `${height}px`, ["--panels-collapsed" as string]: `${collapsedSize}px` }} className={cn("flex w-full flex-col gap-2 sm:h-(--panels-height) sm:flex-row", className)} > {items.map((item) => ( <Panel key={item.value} item={item} open={item.value === open} reduce={Boolean(reduce)} trigger={trigger} onActivate={() => activate(item.value)} /> ))} </div> ); } /* One panel: flex-grow carries the opening, the label fades in with it. */ function Panel({ item, open, reduce, trigger, onActivate, }: { item: ExpandingPanel; open: boolean; reduce: boolean; trigger: "hover" | "click"; onActivate: () => void; }) { const transition = reduce ? { duration: 0 } : soft; const id = React.useId(); const media = <div className="relative size-full overflow-hidden rounded-lg bg-muted *:size-full">{item.media}</div>; const name = item.label || item.meta ? { "aria-labelledby": [item.label && `${id}-label`, item.meta && `${id}-meta`].filter(Boolean).join(" ") } : { "aria-label": item.value }; return ( <motion.div initial={false} animate={{ flexGrow: open ? 1 : 0 }} transition={transition} onPointerEnter={(event) => { if (trigger === "hover" && event.pointerType === "mouse") onActivate(); }} onFocusCapture={onActivate} onClick={onActivate} className="flex h-48 min-w-0 flex-col gap-2 sm:h-full sm:shrink-0 sm:basis-(--panels-collapsed)" > {item.href ? ( <Link href={item.href} {...name} className="min-h-0 flex-1 rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {media} </Link> ) : ( <button type="button" aria-expanded={open} {...name} className="min-h-0 flex-1 cursor-pointer rounded-lg text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {media} </button> )} {(item.label || item.meta) && ( <motion.div initial={false} animate={{ opacity: open ? 1 : 0 }} transition={transition} aria-hidden={!open} className="flex h-5 items-center gap-4 text-sm font-medium max-sm:opacity-100!" > <span id={`${id}-label`} className="min-w-0 flex-1 truncate"> {item.label} </span> {item.meta && ( <span id={`${id}-meta`} className="shrink-0 text-muted-foreground"> {item.meta} </span> )} </motion.div> )} </motion.div> ); }
Usage
import { ExpandingPanels } from "@/components/ui/expanding-panels";
<ExpandingPanels
label="Recent projects"
items={projects.map((p) => ({
value: p.slug,
label: p.title,
meta: p.year,
href: `/work/${p.slug}`,
media: <Image src={p.cover} alt="" fill className="object-cover" />,
}))}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { value, media, label?, meta?, href? }[] | — | The panels. `media` is stretched to the panel and clipped; `href` makes it a Next.js link. |
| label | string | — | Accessible name of the row. |
| value | string | — | Open panel (controlled). |
| defaultValue | string | first item | Initially open panel (uncontrolled). |
| onValueChange | (value: string) => void | — | Called when another panel opens. |
| trigger | "hover" | "click" | "hover" | How a panel opens. Both settings also open on tap and on keyboard focus. |
| collapsedSize | number | 130 | Width of a closed panel from 640 px up, in px. |
| height | number | 320 | Height of the row from 640 px up, in px. |
- Below 640 px the panels stack at full width and every label stays visible, as Framer does on phones.
- Tabbing to a panel opens it, so the keyboard reaches the same content as the pointer.
- Reduced motion is handled inside the component: flex-grow is neither a transform nor a size key, so MotionConfig does not skip it.
- Each panel's button or link takes its accessible name from the label and meta (aria-labelledby), even while the label is faded out.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called ExpandingPanels. - A horizontal row of panels: each panel holds media (rounded-lg, overflow-hidden, bg-muted, children stretched) and, under it, a row with a label (text-sm font-medium, truncated) and optional meta (text-muted-foreground). - Closed panels keep a fixed basis (default 130px) and flex-grow 0; the open one animates flex-grow to 1 with a spring (bounce 0, duration 0.4s). Its label animates opacity 0 → 1 with the same spring. - A panel opens on pointer enter (mouse only), on click or tap, and on keyboard focus of its link or button. Controlled (value + onValueChange) or uncontrolled (defaultValue, first item by default). - Under 640px the panels stack at full width, each with a fixed height, and every label stays visible (force opacity 1 with a class so the inline animation does not hide it). - Reduced motion: flex-grow is not a transform or size key, so MotionConfig does not skip it — call useReducedMotion() and use a zero-duration transition instead. - Accessibility: role group with an aria-label; each panel's media is a link or a button with aria-expanded, named by its label and meta through aria-labelledby (ids from useId; a panel without a label uses aria-label = value); visible focus ring (ring-[3px] ring-ring/50); a closed panel's label row is aria-hidden (the name still works).











