A vertically stacked set of disclosure sections that open with a smooth height animation and a spring-rotating chevron or plus icon. Supports single or multiple open items, separated, bordered or ghost styles, and controlled or uncontrolled state.
Preview
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add accordionWrites components/ui/accordion.tsx and components/styles/accordion.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Accordion } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Accordion />6 );7}Props
Accessibility
Follows the WAI-ARIA Accordion pattern: each header is a native button inside a heading (level configurable) with aria-expanded and aria-controls; each open panel is a role=region labelled by its header. Enter/Space toggle, ArrowUp/ArrowDown move between headers (wrapping) and Home/End jump to the first/last, skipping disabled items. In single, non-collapsible mode the open header reports aria-disabled. Closed panels are unmounted so their content leaves the tab order. Reduced motion opens panels instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/accordion.module.css";6 7export interface AccordionItem {8 /** Unique value of the item */9 value: string;10 /** Header text */11 title: string;12 /** Panel content */13 content?: React.ReactNode;14 /** Disable this item */15 disabled?: boolean;16}17 18export interface AccordionProps {19 /** Items to render; defaults to an FAQ */20 items?: AccordionItem[];21 /** Allow one or several items open at once */22 type?: "single" | "multiple";23 /** In single mode, allow closing the open item */24 collapsible?: boolean;25 /** Controlled open item values */26 value?: string[];27 /** Initially open item values when uncontrolled */28 defaultValue?: string[];29 /** Called with the open item values */30 onValueChange?: (value: string[]) => void;31 /** Visual style */32 variant?: "separated" | "bordered" | "ghost";33 /** Indicator icon */34 icon?: "chevron" | "plus";35 /** Size preset */36 size?: "sm" | "md" | "lg";37 /** Accent color of the open indicator and focus highlights */38 accentColor?: string;39 /** Heading level used for item headers */40 headingLevel?: 2 | 3 | 4 | 5 | 6;41 /** Disable every item */42 disabled?: boolean;43 /** Optional className merged onto the root */44 className?: string;45 /** Optional inline styles merged onto the root */46 style?: React.CSSProperties;47}48 49const DEFAULT_ITEMS: AccordionItem[] = [50 {51 value: "install",52 title: "How do I install a component?",53 content: "Run the CLI with the component slug. It copies a single self-contained file and its CSS module into your project, so you own the code.",54 },55 {56 value: "styling",57 title: "Can I restyle everything?",58 content: "Yes. Every surface reads the --mf-* theme variables, and props such as accentColor, size and variant cover the most common tweaks.",59 },60 {61 value: "a11y",62 title: "Is it accessible?",63 content: "Headers are real buttons with aria-expanded, panels are labelled regions, and arrow keys move between headers following the WAI-ARIA pattern.",64 },65 {66 value: "motion",67 title: "What about reduced motion?",68 content: "When the user prefers reduced motion, panels open instantly and the indicator stops rotating.",69 },70];71 72export function Accordion({73 items = DEFAULT_ITEMS,74 type = "single",75 collapsible = true,76 value: valueProp,77 defaultValue = ["install"],78 onValueChange,79 variant = "separated",80 icon = "chevron",81 size = "md",82 accentColor = "#6366f1",83 headingLevel = 3,84 disabled = false,85 className = "",86 style,87}: AccordionProps) {88 const reduce = useReducedMotion();89 const [inner, setInner] = React.useState<string[]>(defaultValue);90 const controlled = valueProp !== undefined;91 const open = controlled ? valueProp : inner;92 const baseId = React.useId();93 const headerRefs = React.useRef<Array<HTMLButtonElement | null>>([]);94 const Heading = `h${headingLevel}` as "h3";95 96 const toggle = (v: string) => {97 const isOpen = open.includes(v);98 let next: string[];99 if (type === "multiple") next = isOpen ? open.filter((x) => x !== v) : [...open, v];100 else if (isOpen) {101 if (!collapsible) return;102 next = [];103 } else next = [v];104 if (!controlled) setInner(next);105 onValueChange?.(next);106 };107 108 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {109 const nodes = headerRefs.current;110 const enabled = nodes.map((n, idx) => (n && !n.disabled ? idx : -1)).filter((idx) => idx >= 0);111 const pos = enabled.indexOf(i);112 let next = -1;113 if (e.key === "ArrowDown") next = enabled[(pos + 1) % enabled.length];114 else if (e.key === "ArrowUp") next = enabled[(pos - 1 + enabled.length) % enabled.length];115 else if (e.key === "Home") next = enabled[0];116 else if (e.key === "End") next = enabled[enabled.length - 1];117 if (next === undefined || next < 0) return;118 e.preventDefault();119 nodes[next]?.focus();120 };121 122 const ease = [0.16, 1, 0.3, 1] as const;123 124 return (125 <div126 className={`${styles.root} ${className}`.trim()}127 data-variant={variant}128 data-size={size}129 style={{ "--mf-acc-accent": accentColor, ...style } as React.CSSProperties}130 >131 {items.map((item, i) => {132 const isOpen = open.includes(item.value);133 const itemDisabled = disabled || !!item.disabled;134 const headerId = `${baseId}-h-${i}`;135 const panelId = `${baseId}-p-${i}`;136 return (137 <div key={item.value} className={styles.item} data-open={isOpen ? "" : undefined}>138 <Heading className={styles.heading}>139 <button140 ref={(el) => {141 headerRefs.current[i] = el;142 }}143 id={headerId}144 type="button"145 className={styles.trigger}146 aria-expanded={isOpen}147 aria-controls={panelId}148 aria-disabled={type === "single" && isOpen && !collapsible ? true : undefined}149 disabled={itemDisabled}150 onClick={() => toggle(item.value)}151 onKeyDown={(e) => onKeyDown(e, i)}152 >153 <span>{item.title}</span>154 <motion.span155 aria-hidden="true"156 className={styles.icon}157 initial={false}158 animate={{ rotate: isOpen ? (icon === "plus" ? 45 : 180) : 0 }}159 transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 28 }}160 >161 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">162 <path d={icon === "plus" ? "M12 5v14M5 12h14" : "m6 9 6 6 6-6"} />163 </svg>164 </motion.span>165 </button>166 </Heading>167 <AnimatePresence initial={false}>168 {isOpen && (169 <motion.div170 key="panel"171 id={panelId}172 role="region"173 aria-labelledby={headerId}174 className={styles.panel}175 initial={{ height: 0, opacity: 0 }}176 animate={{ height: "auto", opacity: 1 }}177 exit={{ height: 0, opacity: 0 }}178 transition={reduce ? { duration: 0 } : { height: { duration: 0.28, ease }, opacity: { duration: 0.2 } }}179 >180 <motion.div181 className={styles.content}182 initial={{ y: reduce ? 0 : -6 }}183 animate={{ y: 0 }}184 exit={{ y: reduce ? 0 : -6 }}185 transition={{ duration: reduce ? 0 : 0.25, ease }}186 >187 {item.content}188 </motion.div>189 </motion.div>190 )}191 </AnimatePresence>192 </div>193 );194 })}195 </div>196 );197}