Accordion

An accordion is a stack of sections where each heading opens its own content. It keeps a long page short and lets people read only what they need.

Yes. Every color comes from a theme token, so changing a token restyles every component.

import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";

export default function AccordionDemo() {
  return (
    <Accordion defaultValue={["colors"]} className="max-w-md">
      <AccordionItem value="what">
        <AccordionTrigger>What is nsdev/ui?</AccordionTrigger>
        <AccordionContent>
          A set of components built on Base UI and styled with Tailwind. The code is yours to read
          and change.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="colors">
        <AccordionTrigger>Can I change the colors?</AccordionTrigger>
        <AccordionContent>
          Yes. Every color comes from a theme token, so changing a token restyles every component.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="access">
        <AccordionTrigger>Is it accessible?</AccordionTrigger>
        <AccordionContent>
          Base UI handles the keyboard and screen reader behavior, and the styles keep a visible
          focus ring.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="soon" disabled>
        <AccordionTrigger>Coming soon</AccordionTrigger>
        <AccordionContent>This section is not available yet.</AccordionContent>
      </AccordionItem>
    </Accordion>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Plus } from "lucide-react";
import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
import { cx } from "@/lib/cx";
import { focusRing } from "@/lib/styles";
import type { WithClass } from "@/lib/types";

// A stack of sections that open and close. Only one is open at a time unless you pass `multiple`.
export function Accordion({ className, ...props }: WithClass<ComponentProps<typeof BaseAccordion.Root>>) {
  return <BaseAccordion.Root className={cx("flex w-full flex-col", className)} {...props} />;
}

export function AccordionItem({ className, ...props }: WithClass<ComponentProps<typeof BaseAccordion.Item>>) {
  return <BaseAccordion.Item className={cx("border-b border-border", className)} {...props} />;
}

// The heading wraps the button, as the pattern asks. The plus turns into a cross while open.
export function AccordionTrigger({
  className,
  children,
  ...props
}: WithClass<ComponentProps<typeof BaseAccordion.Trigger>>) {
  return (
    <BaseAccordion.Header className="flex">
      <BaseAccordion.Trigger
        className={cx(
          "group flex flex-1 cursor-pointer items-center justify-between gap-4 rounded-sm py-4 text-left text-base font-medium text-text transition-colors",
          focusRing,
          "not-data-[disabled]:hover:text-text/70 data-[disabled]:cursor-not-allowed data-[disabled]:text-text-secondary",
          className,
        )}
        {...props}
      >
        {children}
        <Plus
          aria-hidden
          className="size-4 shrink-0 transition-transform duration-150 group-data-[panel-open]:rotate-45 motion-reduce:transition-none"
        />
      </BaseAccordion.Trigger>
    </BaseAccordion.Header>
  );
}

// The panel animates its height, using the height Base UI measures for it.
export function AccordionContent({
  className,
  children,
  ...props
}: WithClass<ComponentProps<typeof BaseAccordion.Panel>>) {
  return (
    <BaseAccordion.Panel
      className="h-[var(--accordion-panel-height)] overflow-hidden text-base text-text/70 transition-[height] duration-150 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none"
      {...props}
    >
      <div className={cx("pb-4", className)}>{children}</div>
    </BaseAccordion.Panel>
  );
}

Props

PropTypeDefaultWhat it does
multiplebooleanfalseLets several sections be open at the same time. By default opening one closes the other.
defaultValuestring[]—The values of the items that start open, when the accordion manages its own state.
valuestring[]—The values of the open items, when you control them yourself.
onValueChangefunction—Called with the new list of open values.
valuestring—On each AccordionItem. The name of that section. Required if you control the accordion.
disabledbooleanfalseOn the Accordion or on one AccordionItem. Stops it from opening or closing.

Every other prop goes to the Base UI Accordion underneath.

The thinking

Some content is needed by a few people and ignored by the rest. An accordion hides it behind its heading without removing it.

  • Built on Base UI: Base UI tracks which items are open, wires the heading to its panel, and measures the panel so it can animate to its real height.
  • Four parts: Accordion holds the stack, AccordionItem is one section, AccordionTrigger is its heading and AccordionContent is what opens.
  • One or several: By default one section is open at a time. Add multiple when people may want to compare two answers side by side.
  • Not the same as Tabs: An accordion stacks sections vertically and reads top to bottom. Tabs show one panel at a time, in the same place.

Interface design

  • Quiet: Each section is a heading and a hairline under it. There are no boxes, so a stack of them stays light.
  • Plus to cross: The plus turns a quarter turn into a cross while the section is open, and sits after the heading text.
  • Open: The panel text is the dimmer secondary text color, so the heading stays the strongest thing in the row.
  • Disabled: The heading uses the muted text color, drops the hover change and shows the not-allowed cursor.

Interaction

  • Open and close: Click a heading, or press Enter or Space when it has focus.
  • Keyboard: Each heading is its own stop in the tab order, so Tab moves from one to the next.
  • Hover: A heading dims slightly under the pointer to show it can be pressed.
  • Motion: The panel grows and shrinks in 150ms. With reduced motion turned on it opens at once.

Empathy

  • Headings: Each trigger sits inside a heading, so a screen reader user can jump between sections the way they jump between headings on a page.
  • State is announced: Each trigger reports whether it is expanded, so the plus and cross are never the only signal.
  • Focus: Keyboard focus draws a ring around the heading.
  • Write real headings: The trigger text is the only thing visible while closed, so make it say what is inside.