Skip to content
Uxelith
Sign in
Freev1.0.0Navigation

Accordion

Questions that spring open to their answers — reversible mid-way — with a hover highlight that glides between titles and a plus that folds into a minus (or a chevron). Single or multiple open, two looks.

pnpm dlx shadcn add …/r/accordion.json
Licence
MIT
Packages
3
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/accordion.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as accordion.tsx. It's yours: change anything.

Usage

example.tsx
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.

accordion.tsx
"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

PropTypeDefaultDescription
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 / defaultValuestring[]— / []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.
headingLevel2 | 3 | 4 | 5 | 63Heading 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

  1. v1.0.0Latest

    First release.

More in Navigation

All 6 in Navigation
  • NavigationFree

    Animated Tabs

    Tabs with an indicator that springs to the selected tab, a hover highlight that follows the pointer, and panels that slide in from the side you're heading to while the height eases to fit. Pill or underline.

  • NavigationPro

    Command Palette

    A ⌘K command menu with ranked fuzzy search and match highlighting. Give it a trigger and the search button grows into the palette and shrinks back on close. Inline or in a dialog.

  • NavigationFree

    Dock

    A macOS-style dock: icons swell up to 2× under the pointer or keyboard focus, labels spring in, and apps hop when launched.