Stacked sections that expand to reveal their content, one at a time or several at once.
pnpm dlx shadcn@latest add @latheui/accordionFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { duration, ease, useMotionSafe } from "@/lib/motion"
// Spread rather than written as an attribute: in Base UI projects the shadcn CLI
// rewrites a literal `asChild` into a `render` prop, and these are Radix parts.
const slot = { asChild: true } as const
const OpenItemsContext = React.createContext<string[]>([])
const ItemContext = React.createContext("")
export type AccordionProps = Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "dir"
> & {
/** `single` keeps one item open at a time; `multiple` lets any number stay open. */
type?: "single" | "multiple"
/** Open item values. Always an array, including for `single`. */
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
/** With `type="single"`, whether the open item can be closed again. */
collapsible?: boolean
disabled?: boolean
}
function Accordion({
type = "single",
value: valueProp,
defaultValue = [],
onValueChange,
collapsible = true,
disabled,
className,
...props
}: AccordionProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
function onChange(next: string[]) {
if (type === "multiple") return setValue(next)
const opened = next.find((item) => !value.includes(item))
if (opened) setValue([opened])
else if (collapsible) setValue([])
}
return (
<OpenItemsContext.Provider value={value}>
<AccordionPrimitive.Root
data-slot="accordion"
type="multiple"
value={value}
onValueChange={onChange}
disabled={disabled}
className={cn("w-full", className)}
{...props}
/>
</OpenItemsContext.Provider>
)
}
function AccordionItem({
className,
value,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<ItemContext.Provider value={value}>
<AccordionPrimitive.Item
data-slot="accordion-item"
value={value}
className={cn("border-b border-border", className)}
{...props}
/>
</ItemContext.Provider>
)
}
export type AccordionTriggerProps = React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
/** Heading level of the wrapping element. Match it to the page outline. */
headingLevel?: 2 | 3 | 4 | 5 | 6
}
function AccordionTrigger({
className,
children,
headingLevel = 3,
...props
}: AccordionTriggerProps) {
const Heading = `h${headingLevel}` as const
return (
<AccordionPrimitive.Header {...slot}>
<Heading className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group flex min-h-12 flex-1 cursor-pointer items-center justify-between gap-4 rounded-sm py-3 text-left text-sm font-medium outline-none",
"transition-colors hover:text-foreground/75 focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
<ChevronDownIcon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
</AccordionPrimitive.Trigger>
</Heading>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
const open = React.useContext(OpenItemsContext).includes(React.useContext(ItemContext))
const { safe } = useMotionSafe()
return (
<AnimatePresence initial={false}>
{open && (
<AccordionPrimitive.Content {...slot} forceMount {...props}>
<motion.div
data-slot="accordion-content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={safe({ duration: duration.base, ease: ease.out })}
className="overflow-hidden"
>
<div className={cn("pb-4 text-sm leading-6 text-pretty text-muted-foreground", className)}>
{children}
</div>
</motion.div>
</AccordionPrimitive.Content>
)}
</AnimatePresence>
)
}
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }Depends on
Also accepts native div attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "single" | "multiple" | "single" | Whether one or many items can be open. |
| value | string[] | — | Controlled open items. An array for both types. |
| defaultValue | string[] | [] | Initially open items when uncontrolled. |
| onValueChange | (value: string[]) => void | — | Called when the open items change. |
| collapsible | boolean | true | With type single, whether the open item can be closed again. |
| disabled | boolean | false | Disables every item. |
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | string | — | Unique identifier of the item. |
| disabled | boolean | false | Prevents the item from being toggled. |
| Prop | Type | Default | Description |
|---|---|---|---|
| headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Level of the heading that wraps the button. |
className styles the inner content wrapper.
<Accordion className="rounded-lg border border-border px-4 [&>*:last-child]:border-b-0">
…
</Accordion>