← ui

accordion

ui

sections that open in place, one at a time or several

npx shadcn add @ag/accordion
Already installed? Preview an update
npx shadcn add @ag/accordion --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • A glass accordion standing alone is a glass Panel drawn as its root.
Updated 2026-10-14 · release notes

also called collapsible, disclosure

shelf controls

uses material, panel

used by components, deployment

the demo above, verbatim

accordion.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/registry/base-nova/ui/accordion"
import { MaterialProvider } from "@/registry/base-nova/ui/material"

// #region questions
const QUESTIONS = [
  {
    value: "install",
    q: "how do I install a component?",
    a: "npx shadcn add @ag/<name>: the source lands in your project and is yours to change.",
  },
  {
    value: "update",
    q: "and when it changes here?",
    a: "nothing changes in your copy by itself. Preview with --diff, then take what you want with --overwrite.",
  },
  {
    value: "glass",
    q: "can it be glass?",
    a: 'every base control takes material="glass", and a MaterialProvider makes a whole section glass at once.',
  },
]

function Questions({ multiple = false }: { multiple?: boolean }) {
  return (
    <Accordion multiple={multiple} defaultValue={["install"]}>
      {QUESTIONS.map((x) => (
        <AccordionItem key={x.value} value={x.value}>
          <AccordionTrigger>{x.q}</AccordionTrigger>
          <AccordionContent>{x.a}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}
// #endregion

export default function AccordionDemo() {
  return (
    <div className="space-y-8">
      <Sample name="one" glance title="one open at a time" with="questions">
        <div className="max-w-md">
          <Questions />
        </div>
      </Sample>
      <Sample name="several" title="several open" with="questions">
        <div className="max-w-md">
          <Questions multiple />
        </div>
      </Sample>
      <Sample
        name="glass"
        title="in glass"
        description="one pane holding the rows, parted by lit lines"
        with="questions"
      >
        <MaterialProvider material="glass">
          <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
            <div className="max-w-md">
              <Questions />
            </div>
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

accordion.tsx
"use client"

import { Accordion as BaseAccordion } from "@base-ui/react/accordion"
import { ChevronDown, ChevronRight } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Panel, useInFrame } from "@/registry/base-nova/ui/panel"

// Sections that open in place (shadcn's accordion, on Base UI's): each a heading that
// is a button, its panel growing from nothing to its own height and back, never a
// jump. One open at a time, or several (`multiple`). The headings are a real heading
// and a real button, so a keyboard walks them and a screen reader hears what is open.
//
// A heading's chevron is at its end, turning down to up (shadcn's), or at its start,
// turning right to down (`chevron="start"`, a build page's steps). What a section
// offers beside its heading (a count, a button, its state) is its `aside`: in the
// heading's row, after the button, never inside it (a button in a button is no
// button).
//
// Plain, rows on the page parted by hairlines. `material="glass"`: one pane of glass
// holding the rows, parted by lit lines, a row lit in the text's own colour under the
// hand. Inside a frame already drawn (a panel, a tabs box: panel's `InFrame`) it draws
// no pane of its own, its rows the frame's. Every part reads the material from the
// accordion.

export type AccordionProps = Omit<
  React.ComponentProps<typeof BaseAccordion.Root>,
  "className"
> & {
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

export function Accordion({
  material,
  scheme,
  className,
  ref,
  ...props
}: AccordionProps) {
  const m = useMaterial(material)
  const inFrame = useInFrame()
  // Over a picture the pane is read by the part of it under the pane; its rows
  // wear what it reads.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  // In glass, standing alone, the pane is a glass Panel (the one container: its body,
  // spill and edge) drawn as the accordion's root.
  if (m === "glass" && !inFrame)
    return (
      <MaterialProvider material={m} scheme={s}>
        <Panel
          render={<BaseAccordion.Root {...props} />}
          ref={here}
          data-slot="accordion"
          material="glass"
          scheme={s}
          radius="1rem"
          bodyClassName="w-full gap-0 p-0 px-1.5"
          className={className}
        />
      </MaterialProvider>
    )
  return (
    <MaterialProvider material={m} scheme={s}>
      <BaseAccordion.Root
        ref={here}
        data-slot="accordion"
        data-material={m}
        className={cn("flex w-full flex-col", schemeOf(s), className)}
        {...props}
      />
    </MaterialProvider>
  )
}

export function AccordionItem({
  className,
  ...props
}: Omit<React.ComponentProps<typeof BaseAccordion.Item>, "className"> & {
  className?: string
}) {
  const m = useMaterial()
  return (
    <BaseAccordion.Item
      data-slot="accordion-item"
      className={cn(
        "border-b last:border-b-0",
        m === "glass" ? "border-current/12" : "border-border",
        className,
      )}
      {...props}
    />
  )
}

/** The section's heading: its words, a chevron turning as it opens, and what the
 *  section offers beside it (`aside`). */
export function AccordionTrigger({
  chevron = "end",
  aside,
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof BaseAccordion.Trigger>, "className"> & {
  /** Where the chevron is: at the end, turning down to up, or at the start, turning
   *  right to down. */
  chevron?: "start" | "end"
  /** Beside the heading, after its button: counts, buttons, the section's state. */
  aside?: React.ReactNode
  className?: string
}) {
  const m = useMaterial()
  const turn =
    "size-4 shrink-0 text-current/55 transition-transform duration-200 motion-reduce:transition-none"
  return (
    <BaseAccordion.Header className="m-0 flex items-center gap-3">
      <BaseAccordion.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/trigger flex flex-1 cursor-pointer items-center gap-4 py-3 text-left font-medium text-sm outline-none transition-colors data-disabled:cursor-not-allowed data-disabled:opacity-50",
          chevron === "end" ? "justify-between" : "justify-start gap-3",
          m === "glass"
            ? "rounded-xl px-2.5 hover:bg-current/8"
            : "hover:underline",
          className,
        )}
        {...props}
      >
        {chevron === "start" && (
          <ChevronRight
            aria-hidden
            className={cn(turn, "group-data-[panel-open]/trigger:rotate-90")}
          />
        )}
        {children}
        {chevron === "end" && (
          <ChevronDown
            aria-hidden
            className={cn(turn, "group-data-[panel-open]/trigger:rotate-180")}
          />
        )}
      </BaseAccordion.Trigger>
      {aside && (
        <div
          data-slot="accordion-aside"
          className="flex shrink-0 items-center gap-3"
        >
          {aside}
        </div>
      )}
    </BaseAccordion.Header>
  )
}

/** What the section holds: its height grows from nothing to its own and back. */
export function AccordionContent({
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof BaseAccordion.Panel>, "className"> & {
  className?: string
}) {
  const m = useMaterial()
  return (
    <BaseAccordion.Panel
      data-slot="accordion-content"
      className="h-(--accordion-panel-height) overflow-hidden text-sm transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none"
      {...props}
    >
      <div
        className={cn(
          "pb-3 text-current/75",
          m === "glass" && "px-2.5",
          className,
        )}
      >
        {children}
      </div>
    </BaseAccordion.Panel>
  )
}