sections that open in place, one at a time or several
npx shadcn add @ag/accordionAlready installed? Preview an update
npx shadcn add @ag/accordion --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
- A glass accordion standing alone is a glass
Paneldrawn as its root.
the demo above, verbatim
"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
"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>
)
}