Media Accordion
Rows that open by height with a photo beside the title that grows with them.
From the Titen template — see it in context
Positioning, audience research and a voice your team can actually keep to.

Layouts, type and motion designed together, tested at every screen size.

Fast, accessible sites your team can edit without a developer.
import { MediaAccordion } from "@/components/ui/media-accordion";
// Demo photos from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
const services = [
{
value: "strategy",
title: "Brand strategy",
content: "Positioning, audience research and a voice your team can actually keep to.",
image: "/demo/media-accordion-strategy.webp",
alt: "Two people reviewing notes pinned to a wall",
},
{
value: "design",
title: "Web design",
content: "Layouts, type and motion designed together, tested at every screen size.",
image: "/demo/media-accordion-design.webp",
alt: "Designer sketching layouts in a notebook",
},
{
value: "build",
title: "Development",
content: "Fast, accessible sites your team can edit without a developer.",
image: "/demo/media-accordion-build.webp",
alt: "Code on a laptop screen in a dim room",
},
];
export default function MediaAccordionDemo() {
return (
<MediaAccordion
className="max-w-2xl"
defaultValue="strategy"
closedHeight={120}
openHeight={220}
mediaWidth={200}
items={services.map((service) => ({
value: service.value,
title: service.title,
content: service.content,
media: <img src={service.image} alt={service.alt} />,
}))}
/>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/media-accordion.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/media-accordion.tsx."use client"; import * as React from "react"; import { motion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Media Accordion — rows that open by height with a photo beside the title that grows with the row * (from the Titen service list). The photo is always there, small while the row is closed and tall while it is open, * so it never pops in. Framer's spring: bounce 0.2, duration 0.4s. One row is open at a time. * * It differs from `accordion-list`, where any media sits inside the panel and only appears with it: here the media * itself is the thing that animates. * Closed panels stay in the HTML (search engines read them) but are inert, so keyboards and screen readers skip them. */ const spring: Transition = { type: "spring", bounce: 0.2, duration: 0.4 }; export type MediaAccordionItem = { value: string; title: React.ReactNode; content: React.ReactNode; /** The photo or video beside the title. It is stretched and clipped by its frame. */ media: React.ReactNode; }; export type MediaAccordionProps = { items: MediaAccordionItem[]; value?: string | null; defaultValue?: string; onValueChange?: (value: string | null) => void; /** Height of a closed row's media from 1024px up, in px. */ closedHeight?: number; /** Height of an open row's media from 1024px up, in px. */ openHeight?: number; /** Width of the media column from 1024px up, in px. */ mediaWidth?: number; className?: string; }; export function MediaAccordion({ items, value, defaultValue, onValueChange, closedHeight = 192, openHeight = 363, mediaWidth = 277, className, }: MediaAccordionProps) { const [internal, setInternal] = React.useState<string | null>(defaultValue ?? null); const open = value === undefined ? internal : value; const id = React.useId(); function toggle(next: string) { const value = open === next ? null : next; setInternal(value); onValueChange?.(value); } return ( <div style={{ ["--media-w" as string]: `${mediaWidth}px` }} className={cn("w-full border-b", className)} > {items.map((item, index) => { const isOpen = item.value === open; const panelId = `${id}-${index}-panel`; const triggerId = `${id}-${index}-trigger`; return ( <div key={item.value} className="border-t first:border-t-0"> <h3> <button id={triggerId} type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={() => toggle(item.value)} className="flex w-full cursor-pointer items-center gap-5 py-4 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <span className="flex-1 text-base font-medium">{item.title}</span> {/* Plus turns into minus: the upright stroke collapses while the row opens. */} <span aria-hidden className="relative size-4 shrink-0 text-muted-foreground"> <span className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 rounded-full bg-current" /> <motion.span initial={false} animate={{ scaleY: isOpen ? 0 : 1 }} transition={spring} className="absolute top-0 left-1/2 h-full w-0.5 -translate-x-1/2 rounded-full bg-current" /> </span> </button> </h3> <div className="flex flex-col gap-5 pb-4 lg:flex-row lg:items-start"> <motion.div initial={false} animate={{ height: isOpen ? openHeight : closedHeight }} transition={spring} /* The same height inline, so the server renders the row at its real size before Motion takes over. */ style={{ height: isOpen ? openHeight : closedHeight }} className="w-full shrink-0 overflow-hidden rounded-lg bg-muted lg:w-(--media-w) [&>*]:size-full [&>*]:object-cover" > {item.media} </motion.div> <motion.div id={panelId} role="region" aria-labelledby={triggerId} inert={!isOpen} initial={false} animate={isOpen ? "open" : "closed"} variants={{ open: { height: "auto", opacity: 1, transition: { height: spring, opacity: { duration: 0.25, delay: 0.1 } } }, closed: { height: 0, opacity: 0, transition: { height: spring, opacity: { duration: 0.15 } } }, }} className="overflow-hidden lg:flex-1" > <p className="text-sm text-muted-foreground">{item.content}</p> </motion.div> </div> </div> ); })} </div> ); }
Usage
import { MediaAccordion } from "@/components/ui/media-accordion";
<MediaAccordion
defaultValue="strategy"
items={services.map((s) => ({
value: s.slug,
title: s.title,
content: s.summary,
media: <Image src={s.photo} alt="" fill />,
}))}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { value, title, content, media }[] | — | Rows. The media sits beside the title and is always there, small while the row is closed. |
| value | string | null | — | Open row (controlled); null closes them all. |
| defaultValue | string | — | Row that starts open (uncontrolled); leave it out for all closed. |
| onValueChange | (value: string | null) => void | — | Called when a row opens or closes. |
| closedHeight | number | 192 | Height of a closed row's media from 1024 px up, in px. |
| openHeight | number | 363 | Height of an open row's media from 1024 px up, in px. |
| mediaWidth | number | 277 | Width of the media column from 1024 px up, in px. |
- The difference from `accordion-list`: there any media sits inside the panel and appears with it; here the media itself grows, so nothing pops in.
- One row is open at a time, and clicking the open row closes it.
- Closed panels stay in the HTML (search engines read them) but are inert, so keyboards and screen readers skip them.
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 MediaAccordion. - Rows separated by 1px borders. Each row: a full-width button with the title (text-base font-medium) and a plus icon whose upright stroke scales to 0 as the row opens, so it becomes a minus. Spring: bounce 0.2, duration 0.4s. - Under the button, a flex row (column below 1024px): the media on the left in a rounded-lg frame, animating its height between closedHeight and openHeight with the same spring, always present; the text panel on the right, animating height 0 → auto with the content fading in 0.25s after a 0.1s delay. - One row open at a time; clicking the open row closes it. Controlled (value, which may be null) or uncontrolled. - Give the media its height inline as well, so the server renders the row at its real size. - Accessibility: aria-expanded and aria-controls on the button, role region with aria-labelledby on the panel, ids from the row's index, and a closed panel is inert.











