Accordion

A vertically stacked set of interactive headings that each reveal a section of content.

We offer standard (5-7 days), express (2-3 days), and overnight shipping. Free shipping on international orders.

import * as stylex from "@stylexjs/stylex"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"

const styles = stylex.create({
  accordion: { maxWidth: "32rem" },
})

export default function AccordionDemo() {
  return (
    <Accordion defaultValue={["shipping"]} xstyle={styles.accordion}>
      <AccordionItem value="shipping">
        <AccordionTrigger>What are your shipping options?</AccordionTrigger>
        <AccordionContent>
          We offer standard (5-7 days), express (2-3 days), and overnight shipping. Free shipping on
          international orders.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="returns">
        <AccordionTrigger>What is your return policy?</AccordionTrigger>
        <AccordionContent>
          Returns accepted within 30 days. Items must be unused and in original packaging. Refunds
          processed within 5-7 business days.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="support">
        <AccordionTrigger>How can I contact customer support?</AccordionTrigger>
        <AccordionContent>
          Reach us via email, live chat, or phone. We respond within 24 hours during business days.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. The trigger takes its focus ring and its disabled dim from the first and reads Base UI's state through the second. The chevron reads the trigger's hover and open state through the accordionTrigger marker in markers.stylex.ts, also on that page. interop.css sizes any icon you put in a trigger.

Copy and paste the following code into your project.

components/ui/accordion.tsx
"use client"

import type { CSSProperties } from "react"
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { stateStyles } from "@/lib/state-styles"
import { accordionTrigger } from "@/theme/markers.stylex"
import { states } from "@/theme/states"
import { color, duration, ease, leading, radius, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: { display: "flex", flexDirection: "column", width: "100%" },
  item: {
    borderBlockEndWidth: { default: 1, ":last-child": 0 },
    borderBlockEndStyle: "solid",
  },
  header: { display: "flex" },
  trigger: {
    display: "flex",
    flexGrow: 1,
    alignItems: "flex-start",
    gap: space.s2,
    textAlign: "start",
    paddingBlock: space.s2_5,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    transitionProperty: "border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s4,
  },
  chevron: {
    marginInlineStart: "auto",
    width: space.s5,
    height: space.s5,
    color: {
      default: color.mutedForeground,
      [stylex.when.ancestor(":hover", accordionTrigger)]: color.foreground,
    },
    transitionProperty: "color",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
  },
  chevronPath: {
    transformBox: "fill-box",
    transformOrigin: "center",
    transform: {
      default: "none",
      [stylex.when.ancestor("[data-panel-open]", accordionTrigger)]: "scaleY(-1)",
    },
    transitionProperty: "transform",
    transitionDuration: { default: duration.move, "@media (prefers-reduced-motion: reduce)": "0s" },
    transitionTimingFunction: ease.standard,
  },
  content: {
    overflow: "hidden",
    height: "var(--accordion-panel-height)",
    fontSize: text.sm,
    lineHeight: leading.sm,
    transitionProperty: "height",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },
  contentHidden: { height: 0 },
  contentInner: { paddingBlockEnd: space.s2_5 },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }

type AccordionProps = Omit<AccordionPrimitive.Root.Props, "className"> & Overrides

function Accordion({ xstyle, className, ...props }: AccordionProps) {
  const sx = stylex.props(styles.root, xstyle)
  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type AccordionItemProps = Omit<AccordionPrimitive.Item.Props, "className"> & Overrides

function AccordionItem({ xstyle, className, ...props }: AccordionItemProps) {
  const sx = stylex.props(styles.item, xstyle)
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-item"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type AccordionTriggerProps = Omit<AccordionPrimitive.Trigger.Props, "className" | "style"> &
  Overrides & {
    style?: CSSProperties
    headerProps?: Omit<AccordionPrimitive.Header.Props, "className">
  }

function AccordionTrigger({
  xstyle,
  style,
  className,
  headerProps,
  children,
  ...props
}: AccordionTriggerProps) {
  return (
    <AccordionPrimitive.Header
      data-slot="accordion-header"
      {...stylex.props(styles.header)}
      {...headerProps}
    >
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        {...props}
        {...stateStyles(
          (state) => [
            accordionTrigger,
            styles.trigger,
            states.focusRing,
            state.disabled && states.disabled,
            xstyle,
          ],
          { className, style, hook: "carbon-icons" },
        )}
      >
        {children}
        <svg
          data-slot="accordion-trigger-icon"
          aria-hidden="true"
          viewBox="0 0 16 16"
          fill="none"
          {...stylex.props(styles.chevron)}
        >
          <path
            d="M4.75 6.75 8 10.25l3.25-3.5"
            stroke="currentColor"
            strokeWidth={1.5}
            strokeLinecap="round"
            strokeLinejoin="round"
            vectorEffect="non-scaling-stroke"
            {...stylex.props(styles.chevronPath)}
          />
        </svg>
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
}

type AccordionContentProps = Omit<AccordionPrimitive.Panel.Props, "className" | "style"> &
  Overrides & { style?: CSSProperties }

function AccordionContent({ xstyle, style, className, children, ...props }: AccordionContentProps) {
  const sx = stylex.props(styles.contentInner, xstyle)
  return (
    <AccordionPrimitive.Panel
      data-slot="accordion-content"
      {...props}
      {...stateStyles(
        (state) => [
          styles.content,
          (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
            styles.contentHidden,
        ],
        {
          style,
        },
      )}
    >
      <div data-slot="accordion-content-inner" {...sx} className={clsx(sx.className, className)}>
        {children}
      </div>
    </AccordionPrimitive.Panel>
  )
}

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }

Update the import paths to match your project setup.

Usage

import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
<Accordion defaultValue={["item-1"]}>
  <AccordionItem value="item-1">
    <AccordionTrigger>Is it accessible?</AccordionTrigger>
    <AccordionContent>Yes. It follows the WAI-ARIA disclosure pattern.</AccordionContent>
  </AccordionItem>
</Accordion>

value and defaultValue are arrays, one entry per open item, even when only one item can be open at a time.

Composition

Accordion
├── AccordionItem
│   ├── AccordionTrigger
│   └── AccordionContent
└── AccordionItem
    ├── AccordionTrigger
    └── AccordionContent

AccordionTrigger renders the Base UI Header and Trigger together, and AccordionContent renders the Panel around an inner element that holds the padding.

Multiple

Use the multiple prop to keep more than one item open at a time.

Manage how you receive notifications. You can enable email alerts for updates or push notifications for mobile devices.

Control your privacy settings and security preferences. Enable two-factor authentication, manage connected devices, and review active sessions.

import * as stylex from "@stylexjs/stylex"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"

const styles = stylex.create({
  accordion: { maxWidth: "32rem" },
})

const items = [
  {
    value: "notifications",
    trigger: "Notification Settings",
    content:
      "Manage how you receive notifications. You can enable email alerts for updates or push notifications for mobile devices.",
  },
  {
    value: "privacy",
    trigger: "Privacy & Security",
    content:
      "Control your privacy settings and security preferences. Enable two-factor authentication, manage connected devices, and review active sessions.",
  },
  {
    value: "billing",
    trigger: "Billing & Subscription",
    content:
      "View your current plan, payment history, and upcoming invoices. Update your payment method or cancel your subscription.",
  },
]

export default function AccordionMultiple() {
  return (
    <Accordion multiple defaultValue={["notifications", "privacy"]} xstyle={styles.accordion}>
      {items.map((item) => (
        <AccordionItem key={item.value} value={item.value}>
          <AccordionTrigger>{item.trigger}</AccordionTrigger>
          <AccordionContent>{item.content}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

Disabled

Use the disabled prop on AccordionItem to disable a single item, or on Accordion to disable all of them.

import * as stylex from "@stylexjs/stylex"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"

const styles = stylex.create({
  accordion: { maxWidth: "32rem" },
})

export default function AccordionDisabled() {
  return (
    <Accordion xstyle={styles.accordion}>
      <AccordionItem value="history">
        <AccordionTrigger>Can I access my account history?</AccordionTrigger>
        <AccordionContent>
          Yes. Your transactions, plan changes, and support tickets are in the Account History
          section of your dashboard.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="premium" disabled>
        <AccordionTrigger>Premium feature information</AccordionTrigger>
        <AccordionContent>
          This section covers premium features. Upgrade your plan to read it.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="email">
        <AccordionTrigger>How do I update my email address?</AccordionTrigger>
        <AccordionContent>
          Change it in your account settings. A verification email goes to the new address to
          confirm the change.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

Borders

Items draw a rule along their bottom edge, except the last. Add a frame through xstyle on the Accordion and inline padding on each AccordionItem.

Monthly and annual plans are charged at the start of each cycle. Cancel any time. Every plan includes automatic backups and unlimited team members.

import * as stylex from "@stylexjs/stylex"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  accordion: {
    maxWidth: "32rem",
    borderWidth: 1,
    borderStyle: "solid",
    borderRadius: radius.xl,
  },
  item: { paddingInline: space.s4 },
})

const items = [
  {
    value: "billing",
    trigger: "How does billing work?",
    content:
      "Monthly and annual plans are charged at the start of each cycle. Cancel any time. Every plan includes automatic backups and unlimited team members.",
  },
  {
    value: "security",
    trigger: "Is my data secure?",
    content:
      "We use end-to-end encryption, SOC 2 Type II compliance, and third-party security audits. Data is encrypted at rest and in transit.",
  },
  {
    value: "integration",
    trigger: "What integrations do you support?",
    content:
      "We integrate with 500+ tools including Slack, Zapier, Salesforce, and HubSpot. Build your own with the REST API and webhooks.",
  },
]

export default function AccordionBorders() {
  return (
    <Accordion defaultValue={["billing"]} xstyle={styles.accordion}>
      {items.map((item) => (
        <AccordionItem key={item.value} value={item.value} xstyle={styles.item}>
          <AccordionTrigger>{item.trigger}</AccordionTrigger>
          <AccordionContent>{item.content}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Accordion part of the same name and accepts its props. See the Base UI reference for the full list. Each part also takes xstyle and className.

Accordion

PropTypeDefault
valuestring[]—
defaultValuestring[]—
onValueChange(value: string[], details) => void—
multiplebooleanfalse
disabledbooleanfalse
keepMountedbooleanfalse
hiddenUntilFoundbooleanfalse

hiddenUntilFound hides a closed panel with hidden="until-found" instead of unmounting it, which lets the browser's find-in-page reach the text and open the item. It overrides keepMounted.

AccordionItem

PropTypeDefault
valuestring—
disabledboolean—
onOpenChange(open: boolean, details) => void—

An item with no value gets a generated one. Set it when you control the accordion or want an item open on first render.

AccordionTrigger

PropTypeDefault
headerPropsAccordion.Header.Props—
styleCSSProperties—

The heading is the primitive's <h3>. Pass headerProps={{ render: <h2 /> }} to fit the page's outline.

style is real inline CSS, merged over the compiled styles.

AccordionContent

xstyle and className apply to the inner element. style takes real inline CSS on the panel. The panel animates its height between 0 and the measured content height, and a padded box of height 0 still draws its padding.

Data attributes

AttributeOnEffect
data-slot="accordion-content"the panelTargets the animated panel
data-starting-style, data-ending-stylethe panelBase UI writes them while the panel animates
data-open, data-disableditem, panelBase UI writes them
data-panel-open, data-disabledthe triggerBase UI writes them; the chevron flips
data-slot="accordion-trigger-icon"the chevronTargets the chevron from a parent

CSS variables

VariableOnEffect
--accordion-panel-heightthe panelBase UI writes the measured height; the panel transitions to it