Cards
Accordion
Panels that open by animating a grid row from 0fr to 1fr, so the content measures itself.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/accordion.json'use client';
import { useId, useRef, useState } from 'react';
const css = `
.acc {
width: min(100%, 440px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.acc__item + .acc__item { border-top: 1px solid rgba(255, 255, 255, 0.08); }
.acc__heading { margin: 0; }
.acc__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
width: 100%;
padding: 14px 16px;
border: 0;
background: none;
color: #f3f0e9;
font: 500 14.5px/1.4 ui-sans-serif, system-ui, sans-serif;
text-align: left;
cursor: pointer;
transition: background-color 180ms ease;
}
.acc__trigger:hover { background: rgba(255, 255, 255, 0.04); }
.acc__trigger:focus-visible { outline: 2px solid #ffca7a; outline-offset: -2px; }
.acc__chevron {
flex: none;
width: 8px;
height: 8px;
border-right: 2px solid rgba(255, 202, 122, 0.9);
border-bottom: 2px solid rgba(255, 202, 122, 0.9);
transform: rotate(45deg) translate(-2px, -2px);
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.acc__item[data-open="1"] .acc__chevron { transform: rotate(-135deg) translate(-2px, -2px); }
/* The whole trick: a grid row animating 0fr -> 1fr. Height cannot be
transitioned from auto, so the usual workarounds have to measure the content
with JS first. A fr row lets the content measure itself. */
.acc__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.acc__item[data-open="1"] .acc__panel { grid-template-rows: 1fr; }
/* min-height: 0 is required — a grid item's default minimum is its content,
which would stop the row ever collapsing to zero. */
.acc__inner {
min-height: 0;
overflow: hidden;
padding: 0 16px;
color: rgba(243, 240, 233, 0.6);
font: 400 13.5px/1.55 ui-sans-serif, system-ui, sans-serif;
}
.acc__item[data-open="1"] .acc__inner { padding-bottom: 15px; }
@media (prefers-reduced-motion: reduce) {
.acc__panel, .acc__chevron { transition: none; }
}
`;
export interface AccordionItem {
title: string;
body: React.ReactNode;
}
const DEFAULT_ITEMS: AccordionItem[] = [
{
title: 'What is a plinth?',
body: 'The block a sculpture stands on. It does not do the work; it makes the work presentable.',
},
{
title: 'Why not just animate height?',
body: 'Because you would have to know the height first. Animating a grid row from 0fr to 1fr lets the content measure itself.',
},
{
title: 'Does it work without JavaScript?',
body: 'The open state is an attribute, so server-rendering it open needs no script. Only toggling does.',
},
];
export function Accordion({
items = DEFAULT_ITEMS,
defaultOpen = 0,
}: {
items?: AccordionItem[];
defaultOpen?: number | null;
}) {
const [open, setOpen] = useState<number | null>(defaultOpen);
const base = useId();
const triggers = useRef<(HTMLButtonElement | null)[]>([]);
return (
<>
<style>{css}</style>
<div className="acc">
{items.map((item, i) => {
const isOpen = open === i;
const panelId = `${base}-panel-${i}`;
const triggerId = `${base}-trigger-${i}`;
return (
<div className="acc__item" key={item.title} data-open={isOpen ? '1' : undefined}>
<h3 className="acc__heading">
<button
ref={el => {
triggers.current[i] = el;
}}
className="acc__trigger"
type="button"
id={triggerId}
// aria-expanded is what a screen reader reads; the data
// attribute only drives CSS. Both move together.
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpen(isOpen ? null : i)}
onKeyDown={e => {
// Arrows move between headers, Home/End jump to the ends —
// what the accordion pattern expects.
const next =
e.key === 'ArrowDown'
? i + 1
: e.key === 'ArrowUp'
? i - 1
: e.key === 'Home'
? 0
: e.key === 'End'
? items.length - 1
: -1;
if (next < 0 || next >= items.length) return;
e.preventDefault();
triggers.current[next]?.focus();
}}
>
{item.title}
<span className="acc__chevron" aria-hidden="true" />
</button>
</h3>
<div className="acc__panel" id={panelId} role="region" aria-labelledby={triggerId} aria-hidden={!isOpen} inert={!isOpen}>
<div className="acc__inner">{item.body}</div>
</div>
</div>
);
})}
</div>
</>
);
}