UINavigationmotion/react

Accordion

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

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.

Customize

8 props

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add accordion

Writes 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.

example.tsx
1import { Accordion } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Accordion />6  );7}

Props

PropTypeDefault
items

Sections to render.

{ value: string; title: string; content?: React.ReactNode; disabled?: boolean }[]4-question FAQ
type

Allow one or several sections open at once.

'single' | 'multiple''single'
collapsible

In single mode, allow closing the open section.

booleantrue
value

Controlled list of open item values.

string[]undefined
defaultValue

Initially open item values when uncontrolled.

string[]['install']
onValueChange

Called with the open item values after each toggle.

(value: string[]) => voidundefined
variant

Separate cards, one bordered container, or bare dividers.

'separated' | 'bordered' | 'ghost''separated'
icon

Indicator icon; the chevron flips, the plus turns into an x.

'chevron' | 'plus''chevron'
size

Padding and text size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the open indicator and open-card border.

string'#6366f1'
headingLevel

Heading level wrapping each header button.

2 | 3 | 4 | 5 | 63
disabled

Disable every item.

booleanfalse
className

Class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/accordion.tsx
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}