Accordion
Rows whose panels spring open to their measured height.
import { useId, useLayoutEffect, useRef, useState } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import { SPRING, Spring } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { clamp, useMotion } from '../nav-kit/motion';
import '../shared/ember.css';
import './accordion.css';
export interface AccordionItem {
title: string;
content: ReactNode;
}
export interface AccordionProps {
items: AccordionItem[];
type?: 'single' | 'multiple';
/** Indices of the items open at first. */
defaultOpen?: number[];
onOpenChange?: (open: number[]) => void;
accent?: Accent;
className?: string;
}
const HEADER = 48;
interface ItemSim {
h: Spring; // panel height, px
rot: Spring; // the plus's vertical bar, degrees
full: number; // measured content height
}
export function Accordion({ items, type = 'single', defaultOpen, onOpenChange, accent: accentProp, className }: AccordionProps) {
const accent = resolveAccent(accentProp);
const uid = useId();
const [open, setOpen] = useState<number[]>(() => {
const list = (defaultOpen ?? []).filter((i) => i >= 0 && i < items.length);
return type === 'single' ? list.slice(0, 1) : list;
});
const headerEls = useRef<(HTMLButtonElement | null)[]>([]);
const panelEls = useRef<(HTMLDivElement | null)[]>([]);
const contentEls = useRef<(HTMLDivElement | null)[]>([]);
const lineEls = useRef<(HTMLSpanElement | null)[]>([]);
const barEls = useRef<(SVGPathElement | null)[]>([]);
const sim = useRef({ items: [] as ItemSim[], open: new Set<number>(open), ready: false });
{
const s = sim.current;
while (s.items.length < items.length) {
const i = s.items.length;
const isOpen = open.includes(i);
s.items.push({ h: new Spring(0, SPRING.soft), rot: new Spring(isOpen ? 90 : 0, SPRING.snappy), full: 0 });
}
}
const apply = () => {
const s = sim.current;
s.items.forEach((it, i) => {
const panel = panelEls.current[i];
const content = contentEls.current[i];
if (!panel || !content) return;
const h = Math.max(0, it.h.value);
const p = it.full > 0 ? clamp(h / it.full, 0, 1) : s.open.has(i) ? 1 : 0;
const closedAtRest = !s.open.has(i) && h < 0.5 && Math.abs(it.h.velocity) < 0.01;
panel.hidden = closedAtRest;
panel.style.height = `${h.toFixed(2)}px`;
content.style.opacity = clamp(p * 1.25 - 0.1, 0, 1).toFixed(3);
content.style.transform = p >= 1 ? '' : `translate3d(0,${((1 - p) * 4).toFixed(2)}px,0)`;
const line = lineEls.current[i];
if (line) line.style.height = `${(p * (HEADER + it.full)).toFixed(2)}px`;
const bar = barEls.current[i];
if (bar) bar.style.transform = `rotate(${it.rot.value.toFixed(2)}deg)`;
});
};
const motion = useMotion(
(dt) => {
let moving = false;
for (const it of sim.current.items) {
if (it.h.step(dt)) moving = true;
if (it.rot.step(dt)) moving = true;
}
apply();
return moving;
},
(r) => {
if (!r) return;
for (const it of sim.current.items) {
it.h.set(it.h.target);
it.rot.set(it.rot.target);
}
},
);
// Measure content and keep open panels sized to it (text rewraps as the width changes).
useLayoutEffect(() => {
const s = sim.current;
const measure = () => {
s.items.forEach((it, i) => {
const c = contentEls.current[i];
if (!c) return;
it.full = c.offsetHeight;
if (s.open.has(i)) {
const settled = Math.abs(it.h.value - it.h.target) < 0.5 && Math.abs(it.h.velocity) < 0.01;
it.h.target = it.full;
if (settled || !s.ready || motion.reduced) it.h.set(it.full);
}
});
s.ready = true;
apply();
motion.wake();
};
measure();
const ro = new ResizeObserver(measure);
contentEls.current.forEach((el) => el && ro.observe(el));
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length]);
useLayoutEffect(() => {
const s = sim.current;
s.open = new Set(open);
s.items.forEach((it, i) => {
const isOpen = s.open.has(i);
if (isOpen) {
const panel = panelEls.current[i];
if (panel) panel.hidden = false;
const c = contentEls.current[i];
if (c) it.full = c.offsetHeight;
}
it.h.target = isOpen ? it.full : 0;
it.rot.target = isOpen ? 90 : 0;
if (motion.reduced) {
it.h.set(it.h.target);
it.rot.set(it.rot.target);
}
});
apply();
motion.wake();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggle = (i: number) => {
const isOpen = open.includes(i);
let next: number[];
if (type === 'single') next = isOpen ? [] : [i];
else next = isOpen ? open.filter((j) => j !== i) : [...open, i].sort((a, b) => a - b);
setOpen(next);
onOpenChange?.(next);
};
const onKeyDown = (i: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
const n = items.length;
let next: number | null = null;
if (e.key === 'ArrowDown') next = (i + 1) % n;
else if (e.key === 'ArrowUp') next = (i - 1 + n) % n;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
if (next === null) return;
e.preventDefault();
headerEls.current[next]?.focus();
};
return (
<div className={`ember em-acc${className ? ` ${className}` : ''}`} data-accent={accent}>
{items.map((it, i) => {
const isOpen = open.includes(i);
const hid = `${uid}-h-${i}`;
const pid = `${uid}-p-${i}`;
return (
<div key={i} className={`em-acc__item${isOpen ? ' is-open' : ''}`}>
<h3 className="em-acc__heading">
<button
ref={(el) => {
headerEls.current[i] = el;
}}
id={hid}
type="button"
className="em-acc__header"
aria-expanded={isOpen}
aria-controls={pid}
onClick={() => toggle(i)}
onKeyDown={onKeyDown(i)}
>
<span className="em-acc__title">{it.title}</span>
<svg className="em-icon em-acc__plus" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.5 8h9" />
<path
ref={(el) => {
barEls.current[i] = el;
}}
className="em-acc__bar"
d="M8 3.5v9"
/>
</svg>
</button>
</h3>
<div
ref={(el) => {
panelEls.current[i] = el;
}}
id={pid}
role="region"
aria-labelledby={hid}
className="em-acc__panel"
>
<div
ref={(el) => {
contentEls.current[i] = el;
}}
className="em-acc__content"
>
{it.content}
</div>
</div>
<span
ref={(el) => {
lineEls.current[i] = el;
}}
className="em-acc__line"
aria-hidden="true"
/>
</div>
);
})}
</div>
);
}
export default Accordion;
Customize
012345678903
single
multiple
The plus turns into a minus, and the open item sits on a soft fill that grows with its panel.
Install
npx shadcn@latest add https://obsydeui.com/r/accordion.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Accordion } from '@/components/obsyde/ember/accordion/Accordion';
export function Example() {
return (
<Accordion type="single" />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/accordion/Accordion.tsxcomponentember/accordion/accordion.cssstylesember/accordion/AccordionDemo.tsxdemoember/nav-kit/motion.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
countItems | number | 3 |
typeType | 'single' | 'multiple' | 'single' |
openOpen at first | string | 'First' |