Accordion
pnpm dlx shadcn@latest add …/r/accordion.jsonAn accessible React accordion for Next.js: sections that open and close with a smooth height change, as one card, separate cards or plain rows. Built on Radix.
- CardYes. It follows the WAI-ARIA accordion pattern.Yes, server components included.Yes. It's your code, styled with Tailwind CSS.
- SeparatedYes. It follows the WAI-ARIA accordion pattern.Yes, server components included.Yes. It's your code, styled with Tailwind CSS.
- PlainYes. It follows the WAI-ARIA accordion pattern.Yes, server components included.Yes. It's your code, styled with Tailwind CSS.
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
export function Faq() {
return (
<Accordion type="single" collapsible>
<AccordionItem value="refunds">
<AccordionTrigger>Can I get a refund?</AccordionTrigger>
<AccordionContent>Yes, within 30 days of your purchase.</AccordionContent>
</AccordionItem>
<AccordionItem value="shipping">
<AccordionTrigger>How long does shipping take?</AccordionTrigger>
<AccordionContent>Three to five working days.</AccordionContent>
</AccordionItem>
</Accordion>
)
}import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
type AccordionVariant,
} from "@/components/ui/accordion";
const variants: { variant: AccordionVariant; label: string }[] = [
{ variant: "card", label: "Card" },
{ variant: "separated", label: "Separated" },
{ variant: "plain", label: "Plain" },
];
const questions = [
{
value: "accessible",
question: "Is it accessible?",
answer: "Yes. It follows the WAI-ARIA accordion pattern.",
},
{
value: "nextjs",
question: "Does it work with Next.js?",
answer: "Yes, server components included.",
},
{
value: "style",
question: "Can I restyle it?",
answer: "Yes. It's your code, styled with Tailwind CSS.",
},
];
/*
* The three looks side by side, named underneath, the first section open in each. Each one hangs from the top
* of a box as tall as its tallest state (any section open, two-line answers), so opening and closing never
* changes the preview's height or moves the labels.
*/
export default function AccordionVariants() {
return (
<ul className="flex w-full flex-wrap items-start justify-center gap-8">
{variants.map(({ variant, label }) => (
<li key={variant} className="flex w-64 flex-col gap-4">
<div className="min-h-64">
<Accordion type="single" collapsible defaultValue="accessible" variant={variant}>
{questions.map(({ value, question, answer }) => (
<AccordionItem key={value} value={value}>
<AccordionTrigger>{question}</AccordionTrigger>
<AccordionContent>{answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<span className="text-center text-xs text-muted-foreground">{label}</span>
</li>
))}
</ul>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority lucide-react motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { blur, duration, ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { motion, type Transition, useReducedMotion } from "motion/react"; import { Accordion as AccordionPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type ReactNode, useContext, useEffect, useLayoutEffect, useRef, useState, } from "react"; /* * Accordion, built on Radix Accordion (roadmap, 2026-10-16: "Sections that open and close with a smooth * height change"). * - Three looks (owner, 2026-10-19): * - `card` (the default): one surface with Card's shadow and hairline, a quiet rule between sections * - `separated`: each section is its own card, with no surface behind them (owner, 2026-10-19: no tray). * An open section moves 4px further from its neighbours as it opens, so it reads as the one pulled out. * - `plain`: rows and rules only, for inside a surface that's already a card (a Sheet, a Card) * - Hover: no underline (owner, 2026-10-19). In card and separated the question's row tints, a fill that * only fades (motion.md); in every look the chevron brightens. * - Opening and closing run on motion (owner, 2026-10-19: "more smoother"; it was CSS keyframes before): * - The height glides on spring.smooth, the preset for page sections. It's damped enough that it never * visibly overshoots (about 0.1%), so the page below doesn't bounce, and it lands softly both ways. A * section pressed again mid-glide turns round from where it is, keeping its speed, instead of jumping. * - The answer is staged against it. Opening, the box leads and the answer follows a beat later * (duration.instant), sharpening through the blur as it settles 6px down into place. Closing, the answer * goes first and quickly (duration.fast), then the empty box folds. * - The content is clipped only while it moves, so focus rings inside an open section aren't cut off. * - Closed answers stay in the page (Radix's forceMount) as hidden="until-found": find in page sees their * text and opens the section, and search engines read them. React only writes a plain `hidden`, so the * value is set after it. * - The chevron turns on spring.snappy. A span around it turns, so the icon's own transform is never * replaced (motion.md's SVG pitfall). * - Nothing animates on first paint (initial={false}). With reduced motion everything changes at once. * - One section open at a time (type="single", plus `collapsible` to close it again) or several * (type="multiple"). Each question is a button in a heading; arrow keys move between them. */ const accordionVariants = cva("", { variants: { variant: { card: "rounded-lg bg-card text-card-foreground shadow-sm ring-1 ring-border", separated: "flex flex-col gap-2", plain: "", }, }, defaultVariants: { variant: "card" }, }); export type AccordionVariant = NonNullable<VariantProps<typeof accordionVariants>["variant"]>; const itemVariants = cva("group/accordion-item", { variants: { variant: { card: "border-b border-border last:border-b-0", separated: [ "rounded-lg bg-card text-card-foreground shadow-sm ring-1 ring-border", // Open, it moves apart from its neighbours: the gap glides from 8 to 12px as the height does, and back "[transition-property:margin] duration-(--duration-slow) ease-out", "data-[state=open]:not-first:mt-1 data-[state=open]:not-last:mb-1", ], plain: "border-b border-border last:border-b-0", }, }, defaultVariants: { variant: "card" }, }); const triggerVariants = cva( [ "group/accordion-trigger flex flex-1 cursor-pointer items-start justify-between gap-4 py-4 text-start text-sm font-medium", "outline-ring focus-visible:outline-(length:--focus-ring-width)", "disabled:pointer-events-none disabled:opacity-(--opacity-disabled)", ], { variants: { variant: { // The row runs edge to edge: it tints on hover, takes the card's corners at the ends, and its focus // ring sits inside it card: [ "px-4 transition-colors duration-(--duration-fast) ease-out hover:bg-muted/50", "focus-visible:-outline-offset-(--focus-ring-width)", "group-first/accordion-item:rounded-t-lg", "group-last/accordion-item:group-data-[state=closed]/accordion-item:rounded-b-lg", ], separated: [ "rounded-lg px-4 transition-colors duration-(--duration-fast) ease-out hover:bg-muted/50", "focus-visible:-outline-offset-(--focus-ring-width)", "group-data-[state=open]/accordion-item:rounded-b-none", ], plain: "rounded-md focus-visible:outline-offset-(--focus-ring-offset)", }, }, defaultVariants: { variant: "card" }, }, ); const contentVariants = cva("pb-4 text-muted-foreground", { variants: { variant: { card: "px-4", separated: "px-4", plain: "", }, }, defaultVariants: { variant: "card" }, }); type AccordionContextValue = { /** The open sections */ open: string[]; reduce: boolean; variant: AccordionVariant; /** Opens a section from outside its question: a match found in the page */ reveal: (value: string) => void; }; const AccordionContext = createContext<AccordionContextValue | null>(null); const ItemContext = createContext({ value: "", open: false, disabled: false }); /** A single or multiple accordion's value, as a list */ function asList(value: string | string[] | undefined) { if (Array.isArray(value)) return value; return value ? [value] : []; } export type AccordionProps = ComponentProps<typeof AccordionPrimitive.Root> & { /** card (one surface), separated (each section its own card) or plain (rows and rules) */ variant?: AccordionVariant; }; /** * Sections that open and close, built on Radix Accordion. Holds the open sections (controlled or not) so each * section knows whether it's open, and so a match found in the page can open one. */ function Accordion(props: AccordionProps) { const reduce = useReducedMotion() ?? false; const variant = props.variant ?? "card"; const [internal, setInternal] = useState(() => asList(props.defaultValue)); const open = props.value !== undefined ? asList(props.value) : internal; const own = { "data-slot": "accordion", "data-variant": variant, className: cn(accordionVariants({ variant }), props.className), }; // Every change comes through here: a question pressed (through Radix) or a match found in the page const change = (next: string[]) => { setInternal(next); if (props.type === "multiple") props.onValueChange?.(next); else props.onValueChange?.(next[0] ?? ""); }; const reveal = (item: string) => { if (props.disabled || open.includes(item)) return; change(props.type === "multiple" ? [...open, item] : [item]); }; let root: ReactNode; if (props.type === "multiple") { const { value: _value, defaultValue: _defaultValue, onValueChange: _change, variant: _variant, ...rest } = props; root = <AccordionPrimitive.Root {...rest} {...own} value={open} onValueChange={change} />; } else { const { value: _value, defaultValue: _defaultValue, onValueChange: _change, variant: _variant, ...rest } = props; root = ( <AccordionPrimitive.Root {...rest} {...own} value={open[0] ?? ""} onValueChange={(next: string) => change(asList(next))} /> ); } return ( <AccordionContext.Provider value={{ open, reduce, variant, reveal }}>{root}</AccordionContext.Provider> ); } function AccordionItem({ className, value, disabled = false, ...props }: ComponentProps<typeof AccordionPrimitive.Item>) { const accordion = useContext(AccordionContext); const open = accordion?.open.includes(value) ?? false; return ( <ItemContext.Provider value={{ value, open, disabled }}> <AccordionPrimitive.Item data-slot="accordion-item" value={value} disabled={disabled} className={cn(itemVariants({ variant: accordion?.variant }), className)} {...props} /> </ItemContext.Provider> ); } /** The section's heading and the button that opens it */ function AccordionTrigger({ className, children, ...props }: ComponentProps<typeof AccordionPrimitive.Trigger>) { const accordion = useContext(AccordionContext); const { open } = useContext(ItemContext); return ( <AccordionPrimitive.Header data-slot="accordion-header" className="flex"> <AccordionPrimitive.Trigger data-slot="accordion-trigger" className={cn(triggerVariants({ variant: accordion?.variant }), className)} {...props} > {children} {/* Lined up with the first line of the question; it brightens while the question is hovered */} <motion.span aria-hidden data-slot="accordion-chevron" className={cn( "pointer-events-none mt-0.5 flex shrink-0 text-muted-foreground", "transition-colors duration-(--duration-fast) ease-out group-hover/accordion-trigger:text-foreground", )} initial={false} animate={{ rotate: open ? 180 : 0 }} transition={accordion?.reduce ? { duration: 0 } : spring.snappy} > <ChevronDownIcon className="size-(--size-icon-md)" /> </motion.span> </AccordionPrimitive.Trigger> </AccordionPrimitive.Header> ); } const shown = { opacity: 1, filter: blur.shown, y: 0, transitionEnd: { filter: "none" } }; const gone = { opacity: 0, filter: blur.hidden, y: -6 }; /** * The section's content. `className` goes on the inner block, so padding and text styles never fight the * height animation. */ function AccordionContent({ className, children, ...props }: ComponentProps<typeof AccordionPrimitive.Content>) { const accordion = useContext(AccordionContext); const item = useContext(ItemContext); const { open } = item; const reveal = accordion?.reveal; const node = useRef<HTMLDivElement>(null); // Still: not gliding. Cleared in the same render as the section opens or closes, so it's clipped from the // first frame of the glide, and set again when the glide reaches the new state. const [still, setStill] = useState(true); const [wasOpen, setWasOpen] = useState(open); // Opened by find in page: the browser scrolls to the match straight away, so it opens at once const [instant, setInstant] = useState(false); if (open !== wasOpen) { setWasOpen(open); setStill(false); } const hidden = !open && still; const jump = (accordion?.reduce ?? false) || instant; const glide: Transition = jump ? { duration: 0 } : spring.smooth; const fade: Transition = jump ? { duration: 0 } : open ? { duration: duration.slow, ease: ease.out, delay: duration.instant } : { duration: duration.fast, ease: ease.in }; // A plain `hidden` from React becomes "until-found", so find in page can reach the text. A disabled section // can't open, so it stays plainly hidden. useLayoutEffect(() => { if (hidden && !item.disabled) node.current?.setAttribute("hidden", "until-found"); }, [hidden, item.disabled]); useEffect(() => { const element = node.current; if (!element || !reveal) return; const onMatch = () => { // The browser has already removed `hidden` and scrolls to the match straight after this event, so the // height is set by hand for that moment; motion takes over on the next frame element.style.height = "auto"; setInstant(true); reveal(item.value); }; element.addEventListener("beforematch", onMatch); return () => element.removeEventListener("beforematch", onMatch); }, [reveal, item.value]); return ( <AccordionPrimitive.Content data-slot="accordion-content" {...props} hidden={hidden} forceMount asChild> <motion.div ref={node} className={cn("text-sm", !(open && still) && "overflow-hidden")} initial={false} animate={{ height: open ? "auto" : 0 }} transition={glide} onAnimationComplete={(definition) => { // An interrupted glide reports in too: only the one that reached the current state counts if ((definition as { height?: unknown }).height !== (open ? "auto" : 0)) return; setStill(true); setInstant(false); }} > <motion.div className={cn(contentVariants({ variant: accordion?.variant }), className)} initial={false} animate={open ? shown : gone} transition={fade} > {children} </motion.div> </motion.div> </AccordionPrimitive.Content> ); } export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, accordionVariants };Update the import paths to match your project.
Examples
Several open at once
With type="multiple", each section opens and closes on its own.
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
/**
* Each section opens and closes on its own. The box is as tall as all three open, so the preview never
* changes height.
*/
export default function AccordionMultiple() {
return (
<div className="min-h-72 w-full max-w-md">
<Accordion type="multiple" defaultValue={["shipping"]}>
<AccordionItem value="shipping">
<AccordionTrigger>Shipping</AccordionTrigger>
<AccordionContent>Orders arrive in three to five working days.</AccordionContent>
</AccordionItem>
<AccordionItem value="returns">
<AccordionTrigger>Returns</AccordionTrigger>
<AccordionContent>Send it back within 30 days for a full refund.</AccordionContent>
</AccordionItem>
<AccordionItem value="warranty">
<AccordionTrigger>Warranty</AccordionTrigger>
<AccordionContent>Two years on every part, with free repairs.</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}API reference
Accordion
The root, built on Radix Accordion. Also accepts every Radix Accordion Root prop. Arrow keys move between the questions; Home and End jump to the ends.
| Prop | Type | Default | Description |
|---|---|---|---|
variant? | "card" | "separated" | "plain" | "card" | Card is one surface with a rule between sections. Separated makes each section its own card, and an open one moves apart from the rest. Plain is rows and rules only, for inside a surface that's already a card. |
type | "single" | "multiple" | – | Single opens one section at a time; multiple lets each open on its own. |
value? | string | string[] | – | The open section (single) or sections (multiple), when you control them. |
defaultValue? | string | string[] | – | The section or sections open at first. |
onValueChange? | (value: string) => void | (value: string[]) => void | – | Called when a section opens or closes. Single sends an empty string once it's closed. |
collapsible? | boolean | false | Single only: the open section closes when its question is pressed again. |
disabled? | boolean | false | Prevents opening or closing any section. |
AccordionItem
One section: a row between quiet rules (card and plain), or its own small card (separated).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Names the section in the accordion's value. |
disabled? | boolean | false | Prevents opening or closing it. |
AccordionTrigger
The question: a button inside a heading (h3), with aria-expanded. On hover the row tints (card and separated) and the chevron brightens; the chevron turns on a spring as the section opens.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | The question. |
AccordionContent
The answer, a region named by its question. Its height glides on a spring while the answer follows through a blur, clipped only while it moves. Closed, it stays in the page, hidden until found, so find in page opens it.
| Prop | Type | Default | Description |
|---|---|---|---|
className? | string | – | Applies to the inner block, so padding and text styles never fight the height animation. |
Related components
- Your name and email, as others see them.
Accessible React tabs for Next.js, with an underline or pill that slides to the active tab and panels that glide in, built on Radix Tabs.
An accessible React breadcrumb for Next.js and Tailwind CSS: a trail of links whose levels animate as you move, with custom separators and a collapsed middle.
An accessible React command menu for Next.js and Tailwind CSS: a ⌘K palette that filters and ranks as you type, with groups, shortcuts and a gliding highlight.