Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/accordion.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
accordion.tsx. It's yours: change anything.
Usage
import { Accordion } from "@/components/accordion"
export function Faq() {
return (
<Accordion
items={[
{ title: "Can I use it in client work?", content: "Yes, in as many projects as you like." },
{ title: "Does it match my theme?", content: "It reads your shadcn colour tokens." },
]}
defaultValue={["0"]}
/>
)
}Source
One file, 198 lines, nothing hidden. Read it before you install it.
"use client";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface AccordionItem {
/** Stable id for `value`/`defaultValue`. Defaults to the item's position, e.g. "0". */
value?: string;
title: React.ReactNode;
content: React.ReactNode;
/** Optional icon before the title. */
icon?: React.ReactNode;
disabled?: boolean;
}
export interface AccordionProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
items: AccordionItem[];
/** `single` keeps one item open at a time; `multiple` lets any number stay open. */
type?: "single" | "multiple";
/** Open items when uncontrolled. */
defaultValue?: string[];
/** Controlled open items. */
value?: string[];
onValueChange?: (value: string[]) => void;
/** `lines` separates items with rules; `cards` gives each item its own surface. */
variant?: "lines" | "cards";
/** Heading level of each item's trigger, so it fits your page outline. */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** `plus` folds into a minus; `chevron` turns over. */
indicator?: "plus" | "chevron";
}
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
/**
* Questions that open smoothly to their answers.
*
* - WAI-ARIA accordion: each title is a button inside a real heading, with
* `aria-expanded` and `aria-controls`; answers are labelled regions.
* - ↑/↓ move between titles, Home/End jump to the ends.
* - A closed answer is `inert`, so it can't be tabbed into or read out.
* - Height springs open and can be reversed mid-way; a hover highlight
* glides between titles. Instant under `prefers-reduced-motion`.
*/
export function Accordion({
items,
type = "single",
defaultValue = [],
value: valueProp,
onValueChange,
variant = "lines",
headingLevel = 3,
indicator = "plus",
className,
...props
}: AccordionProps) {
const id = React.useId();
const reduceMotion = useReducedMotion();
const [hovered, setHovered] = React.useState<number | null>(null);
const spring = reduceMotion ? { duration: 0 } : SPRING_SMOOTH;
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const open = valueProp ?? uncontrolled;
const triggers = React.useRef<(HTMLButtonElement | null)[]>([]);
const Heading = `h${headingLevel}` as const;
const toggle = (key: string) => {
const isOpen = open.includes(key);
const next = isOpen ? open.filter((v) => v !== key) : type === "single" ? [key] : [...open, key];
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const enabled = items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0);
const at = enabled.indexOf(index);
let target: number | undefined;
if (event.key === "ArrowDown") target = enabled[(at + 1) % enabled.length];
else if (event.key === "ArrowUp") target = enabled[(at - 1 + enabled.length) % enabled.length];
else if (event.key === "Home") target = enabled[0];
else if (event.key === "End") target = enabled.at(-1);
if (target === undefined) return;
event.preventDefault();
triggers.current[target]?.focus();
};
return (
<div
className={cn("w-full", variant === "lines" ? "divide-y divide-border border-y border-border" : "grid gap-2", className)}
onPointerLeave={() => setHovered(null)}
{...props}
>
<LayoutGroup id={id}>
{items.map((item, index) => {
const key = item.value ?? String(index);
const isOpen = open.includes(key);
const triggerId = `${id}-trigger-${index}`;
const panelId = `${id}-panel-${index}`;
return (
<div
key={key}
data-state={isOpen ? "open" : "closed"}
className={cn(
variant === "cards" &&
"rounded-xl border border-border bg-card text-card-foreground transition-shadow duration-200 data-[state=open]:shadow-[0_8px_24px_-12px_rgb(0_0_0/0.25)] motion-reduce:transition-none",
)}
>
<Heading className="m-0 text-base font-medium">
<button
ref={(node) => {
triggers.current[index] = node;
}}
id={triggerId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
disabled={item.disabled}
onClick={() => toggle(key)}
onKeyDown={(event) => onKeyDown(event, index)}
onPointerEnter={(event) => event.pointerType === "mouse" && !item.disabled && setHovered(index)}
className={cn(
"group/trigger relative flex w-full items-center justify-between gap-4 text-left outline-none",
"rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
variant === "lines" ? "py-4" : "px-5 py-4",
)}
>
{variant === "lines" && hovered === index && !reduceMotion && (
<motion.span
layoutId="hover"
aria-hidden="true"
transition={SPRING_SNAPPY}
className="absolute -inset-x-3 inset-y-1.5 rounded-lg bg-foreground/[0.04]"
/>
)}
<span className="relative flex items-center gap-3 text-pretty [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground">
{item.icon}
{item.title}
</span>
<motion.span
aria-hidden="true"
initial={false}
animate={{ rotate: isOpen ? 180 : 0 }}
transition={spring}
className={cn(
"relative grid size-6 shrink-0 place-items-center rounded-full border border-border text-muted-foreground",
"transition-[background-color,color] duration-200 motion-reduce:transition-none group-hover/trigger:text-foreground",
isOpen && "bg-foreground text-background group-hover/trigger:text-background",
)}
>
{indicator === "chevron" ? (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
<path d="m4 6 4 4 4-4" />
</svg>
) : (
// A plus whose upright bar folds flat as the whole sign turns: a minus when open.
<>
<span className="absolute h-px w-2.5 rounded-full bg-current" />
<motion.span
initial={false}
animate={{ scaleX: isOpen ? 0 : 1 }}
transition={spring}
className="absolute h-px w-2.5 rotate-90 rounded-full bg-current"
/>
</>
)}
</motion.span>
</button>
</Heading>
<motion.div
id={panelId}
role="region"
aria-labelledby={triggerId}
inert={!isOpen}
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={spring}
className="overflow-hidden"
>
<motion.div
initial={false}
animate={isOpen ? { y: 0, filter: "blur(0px)" } : { y: -6, filter: "blur(2px)" }}
transition={spring}
className={cn("text-sm leading-relaxed text-muted-foreground", variant === "lines" ? "pb-5 pr-10" : "px-5 pb-5")}
>
{item.content}
</motion.div>
</motion.div>
</div>
);
})}
</LayoutGroup>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { title: ReactNode; content: ReactNode; icon?: ReactNode; value?: string; disabled?: boolean }[] | — | The questions and answers. `value` defaults to the item's position ("0", "1", …). |
| type | "single" | "multiple" | "single" | One open at a time, or any number. |
| value / defaultValue | string[] | — / [] | Controlled or initial open items. |
| onValueChange | (value: string[]) => void | — | Called with the open items. |
| variant | "lines" | "cards" | "lines" | Rules between items, or a card per item. |
| headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level around each title, to fit your page outline. |
| indicator | "plus" | "chevron" | "plus" | A plus that folds into a minus, or a chevron that turns over. |
Accessibility
- WAI-ARIA accordion: each title is a button inside a real heading, with aria-expanded and aria-controls.
- Answers are regions labelled by their title; a closed answer is inert, so it can't be tabbed into.
- ↑/↓ move between titles and Home/End jump to the ends, skipping disabled items.
- Opens and closes instantly, with no hover glide, under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.