Skip to content

Stacked sections that grow to fit, with answers find-in-page can still reach.

Disclosure@base-ui/react

01Preview

You’re charged the prorated difference today, and the next invoice reflects the new plan. Downgrades apply as a credit on your next invoice rather than a refund.

Closed answers are still searchable: press ⌘F and look for “read-only”.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react

03Usage

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

<Accordion defaultValue={["prorate"]}>
  <AccordionItem value="prorate">
    <AccordionTrigger>What happens when I change plans mid-cycle?</AccordionTrigger>
    <AccordionPanel>You’re charged the prorated difference today.</AccordionPanel>
  </AccordionItem>
  <AccordionItem value="seats">
    <AccordionTrigger>Do I pay for guests and viewers?</AccordionTrigger>
    <AccordionPanel>No. Only members who can edit count as seats.</AccordionPanel>
  </AccordionItem>
</Accordion>

// Several open at once, bare rows, a plus that becomes a minus.
<Accordion multiple variant="flush" indicator="plus"></Accordion>

04Source

"use client";
import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
import { createContext, use } from "react";
import { cn } from "@/lib/cn";

type Variant = "card" | "flush";
type Indicator = "chevron" | "plus";

const AccordionContext = createContext<{ variant: Variant; indicator: Indicator }>({ variant: "card", indicator: "chevron" });

export type AccordionProps<Value = string> = Omit<BaseAccordion.Root.Props<Value>, "className"> & {
  /** A bordered card with hairlines between items, or bare rows that sit in the page's own column. */
  variant?: Variant;
  /** A chevron that turns over, or a plus whose upright stroke lies down into a minus. */
  indicator?: Indicator;
  className?: string;
};

/**
 * Single by default; pass `multiple` to let several stay open.
 * Closed panels stay in the DOM as `hidden="until-found"`, so the browser's
 * find-in-page can search them and opens the one that matches.
 */
export function Accordion<Value = string>({
  variant = "card",
  indicator = "chevron",
  hiddenUntilFound = true,
  className,
  ...rest
}: AccordionProps<Value>) {
  return (
    <AccordionContext value={{ variant, indicator }}>
      <BaseAccordion.Root<Value>
        data-variant={variant}
        hiddenUntilFound={hiddenUntilFound}
        className={cn(
          "flex w-full min-w-0 flex-col",
          variant === "card" && "overflow-hidden rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)]",
          className,
        )}
        {...rest}
      />
    </AccordionContext>
  );
}

export type AccordionItemProps = Omit<BaseAccordion.Item.Props, "className"> & { className?: string };

export function AccordionItem({ className, ...rest }: AccordionItemProps) {
  const { variant } = use(AccordionContext);
  return (
    <BaseAccordion.Item
      className={cn(
        "group/item relative min-w-0",
        variant === "card" ? "border-t border-line first:border-t-0" : "border-b border-line",
        className,
      )}
      {...rest}
    />
  );
}

export type AccordionTriggerProps = Omit<BaseAccordion.Trigger.Props, "className" | "children"> & {
  children: React.ReactNode;
  /** 16px icon before the title. */
  icon?: React.ReactNode;
  /** Short text or a count before the indicator: "3 open", "Pro". Read as part of the button's name. */
  hint?: React.ReactNode;
  /** The heading level wrapping the trigger, so the outline of the page stays correct. */
  level?: 2 | 3 | 4 | 5 | 6;
  className?: string;
};

export function AccordionTrigger({ children, icon, hint, level = 3, className, ...rest }: AccordionTriggerProps) {
  const { variant, indicator } = use(AccordionContext);
  const Heading = `h${level}` as const;
  return (
    <BaseAccordion.Header render={<Heading />} className="m-0 flex">
      <BaseAccordion.Trigger
        className={cn(
          // Accessories align to the first line, so a title that wraps keeps its icon and chevron beside its opening words.
          "group/trigger relative flex min-h-11 min-w-0 flex-1 select-none items-start gap-3 text-left",
          "touch-manipulation [-webkit-tap-highlight-color:transparent]",
          "text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg",
          "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3",
          "transition-[background-color,color] duration-150 ease-out",
          "data-disabled:cursor-not-allowed data-disabled:text-fg-4",
          variant === "card"
            ? // Full-bleed rows: the wash fills the row, the ring sits just inside it.
              "px-4 py-[13px] focus-visible:-outline-offset-2 focus-visible:rounded-[10px] hover:not-data-disabled:bg-fg/[0.035] active:not-data-disabled:bg-fg/[0.06]"
            : // Bare rows: text aligns with the column; the wash reaches 8px past it on each side.
              "-mx-2 rounded-lg px-2 py-[13px] focus-visible:-outline-offset-1 hover:not-data-disabled:bg-hover active:not-data-disabled:bg-fg/[0.06]",
          className,
        )}
        {...rest}
      >
        {icon && (
          <span data-accordion-icon="" className="grid h-[18px] w-4 shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2 group-data-[panel-open]/trigger:text-fg group-data-disabled/trigger:text-fg-4">
            {icon}
          </span>
        )}
        <span className="min-w-0 flex-1 text-balance">{children}</span>
        {hint != null && <span className="shrink-0 text-[12px] font-normal leading-[18px] tabular text-fg-3 group-data-disabled/trigger:text-fg-4">{hint}</span>}
        <span className="-mr-0.5 grid h-[18px] w-5 shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg group-data-disabled/trigger:text-fg-4">
          {indicator === "plus" ? <PlusMinus /> : <Chevron />}
        </span>
      </BaseAccordion.Trigger>
    </BaseAccordion.Header>
  );
}

/**
 * Turns over with the panel. On hover it leans 1px the way it will go,
 * so the row says which direction it opens before it is pressed.
 */
function Chevron() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.4}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className={cn(
        "transition-[rotate,translate] duration-[260ms] ease-in-out-quart motion-reduce:transition-none",
        "group-hover/trigger:translate-y-px group-data-[panel-open]/trigger:rotate-180 group-hover/trigger:group-data-[panel-open]/trigger:-translate-y-px",
        "group-data-disabled/trigger:translate-y-0!",
      )}
    >
      <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
    </svg>
  );
}

/** The upright stroke turns a quarter and fades as it lies on the crossbar: plus becomes minus without a doubled stroke. */
function PlusMinus() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" aria-hidden>
      <path d="M3.5 8h9" />
      <path
        d="M8 3.5v9"
        className="origin-center transition-[rotate,opacity] duration-[260ms] ease-in-out-quart [transform-box:fill-box] motion-reduce:transition-none group-data-[panel-open]/trigger:rotate-90 group-data-[panel-open]/trigger:opacity-0"
      />
    </svg>
  );
}

export type AccordionPanelProps = Omit<BaseAccordion.Panel.Props, "className"> & {
  className?: string;
  /** Classes for the padded inner box. */
  contentClassName?: string;
};

/**
 * Height grows from the measured `--accordion-panel-height`; the words fade up
 * 60ms later, once there is room for them, so text never slides out from under a clip.
 */
export function AccordionPanel({ className, contentClassName, children, ...rest }: AccordionPanelProps) {
  const { variant } = use(AccordionContext);
  return (
    <BaseAccordion.Panel
      className={cn(
        "group/panel h-(--accordion-panel-height) overflow-hidden",
        "transition-[height] duration-[260ms] ease-out-quart data-ending-style:duration-200 data-ending-style:ease-in-out-quart",
        "data-starting-style:h-0 data-ending-style:h-0 motion-reduce:transition-none",
        className,
      )}
      {...rest}
    >
      <div
        className={cn(
          "text-[13px] leading-[1.6] text-fg-2 text-pretty",
          "transition-[opacity,translate] delay-[60ms] duration-[280ms] ease-out-expo",
          "group-data-[starting-style]/panel:-translate-y-1 group-data-[starting-style]/panel:opacity-0",
          // Leaving: no delay and no travel, just out of the way before the height closes over it.
          "group-data-[ending-style]/panel:opacity-0 group-data-[ending-style]/panel:delay-0 group-data-[ending-style]/panel:duration-[140ms]",
          "motion-reduce:translate-y-0 motion-reduce:transition-[opacity] motion-reduce:delay-0",
          // With a leading icon, the answer lines up under the title, not under the icon.
          variant === "card" ? "px-4 pb-4 group-has-[[data-accordion-icon]]/item:pl-11" : "pb-4 group-has-[[data-accordion-icon]]/item:pl-7",
          contentClassName,
        )}
      >
        {children}
      </div>
    </BaseAccordion.Panel>
  );
}

05Props

Accordion

PropTypeDefaultDescription
defaultValueValue[]Items open on first render, by value. Uncontrolled.
valueValue[]Open items, controlled. Pair with onValueChange.
onValueChange(value: Value[], details) => voidCalled with the new list of open items.
multiplebooleanfalseLet several items stay open. Otherwise opening one closes the other.
variant"card" | "flush""card"A bordered card with hairlines between items, or bare rows aligned to the page column.
indicator"chevron" | "plus""chevron"A chevron that turns over, or a plus that becomes a minus.
hiddenUntilFoundbooleantrueKeep closed panels in the DOM as hidden="until-found" so find-in-page searches them and opens the match.
disabledbooleanfalseDisable every item.

AccordionItem

PropTypeDefaultDescription
valueValueIdentifies the item in value and defaultValue. Generated when omitted.
disabledbooleanfalseDims the row and ignores presses, but stays readable.
onOpenChange(open: boolean, details) => voidCalled when this item opens or closes.

AccordionTrigger

PropTypeDefaultDescription
children*ReactNodeThe title. Wraps rather than truncates.
iconReactNode16px icon before the title. The panel's text indents to line up under the title.
hintReactNodeShort trailing text or a count before the indicator, in tabular figures.
level2 | 3 | 4 | 5 | 63Heading level wrapping the button, to keep the page outline right.

AccordionPanel

PropTypeDefaultDescription
contentClassNamestringClasses for the padded inner box that holds the content.
keepMountedbooleanfalseKeep the closed panel mounted (ignored while hiddenUntilFound is on).

06Notes

Behavior

  • Closed answers stay in the DOM as hidden="until-found": ⌘F finds text inside them and opens the panel that holds the match.
  • Height is measured, never guessed with max-height, and returns to auto once open, so content that grows inside an open panel is never clipped.
  • Long titles wrap with a balanced last line; the icon, hint and indicator stay beside the first line instead of floating to the middle.
  • Interrupting a panel mid-open reverses it from where it is, because every part is a CSS transition rather than a keyframe.

Motion

  • Height opens in 260ms on ease-out-quart and closes in 200ms on ease-in-out-quart.
  • The text follows 60ms behind the height, fading up 4px over 280ms on ease-out-expo, so words never slide out from under the clip; on close it fades in 140ms before the height shuts.
  • The chevron turns over in 260ms and leans 1px toward where it will go on hover; the plus lays its upright stroke flat and fades it, so the minus is never drawn twice.
  • Reduced motion drops the height transition, the rotation and the 4px travel, keeping only the text's fade.

Accessibility

  • Each trigger is a native button inside a heading (h3 by default), with aria-expanded and aria-controls pointing at its region.
  • Tab moves between triggers; Enter and Space toggle. Disabled items stay readable but ignore input.
  • Hints are part of the button's name, so “Domains 2” is read as one control.