- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from "@/components/ui/accordion";
const items = [
{
"answer": "Yes. Base UI provides heading and panel relationships, keyboard activation, and disabled states.",
"question": "Is it accessible?",
"value": "accessible"
},
{
"answer": "Use the shadcn CLI to install editable source code into your project.",
"question": "How do I install it?",
"value": "installation"
},
{
"answer": "Yes. Enable the multiple prop to keep more than one panel open.",
"question": "Can I open multiple panels?",
"value": "multiple"
}
];
export const AccordionDemo = () => (
<Accordion defaultValue={["accessible"]}>
{items.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
);Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/accordion.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- @base-ui/react
- cn
- lucide-react
- motion
$ pnpm add @base-ui/react cn lucide-react motionUsage
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export function Demo() {
return (
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. Base UI connects each heading to its content panel.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Can I customize it?</AccordionTrigger>
<AccordionContent>
Yes. The registry installs editable source into your project.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}Use your project's configured UI alias if it differs from @/components/ui.
Examples
"use client";
import { accordionItems } from "@/lib/accordion-playground";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export const AccordionDemo = () => (
<Accordion className="max-w-md" defaultValue={["accessible"]}>
{accordionItems.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
);Multiple Panels
Set multiple on Accordion to allow more than one panel to remain open. By default, opening an item closes the previously expanded item. Pressing an expanded trigger closes its panel.
Disabled Items
Set disabled on Accordion to prevent interaction with all items, or on an individual AccordionItem to disable only that item.
Controlled Values
Use value and onValueChange for controlled state. Both value and defaultValue are arrays of item values, even when only one panel can be open. Give each item a unique value.
Keeping Content Mounted
Set keepMounted on AccordionContent to preserve its DOM when closed. Base UI hides closed panels from interaction.
Custom Indicators
Set icon on AccordionTrigger to any decorative React node. A single icon rotates by iconRotation degrees when expanded (default 180). Set iconRotation={45} to turn a Plus into a close mark, or 0 to keep it still. The animation wrapper owns Motion behavior, so your icon component does not need Motion props or ref forwarding.
Supply expandedIcon for a different open-state indicator. Both indicators crossfade with a subtle scale transition; rotation is ignored in this mode. Pass icon={null} to hide the indicator entirely. Indicator content is decorative and hidden from assistive technology; keep the trigger text descriptive and never put interactive elements inside an icon.
"use client";
import { MinusIcon, PlusIcon } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export const AccordionIconsDemo = () => (
<Accordion className="max-w-md">
<AccordionItem value="swap">
<AccordionTrigger icon={<PlusIcon />} expandedIcon={<MinusIcon />}>
Technical details
</AccordionTrigger>
<AccordionContent>
Reference: DEMO-1042. The Plus and Minus indicators crossfade while the
panel expands.
</AccordionContent>
</AccordionItem>
<AccordionItem value="rotate">
<AccordionTrigger icon={<PlusIcon />} iconRotation={45}>
A rotating custom icon
</AccordionTrigger>
<AccordionContent>
A single Plus indicator rotates into a close mark. Use any decorative
React node.
</AccordionContent>
</AccordionItem>
</Accordion>
);<AccordionTrigger icon={<PlusIcon />} expandedIcon={<MinusIcon />}>
Technical details
</AccordionTrigger>Import the example icons from lucide-react. These customizations work with the same controlled state, keyboard activation, disabled behavior, and reduced-motion support as the default Chevron.
Consumer Setup and Optional Stories
Requires React 19, Tailwind CSS 4 with shadcn theme tokens, @base-ui/react, cn, lucide-react, and motion. The CLI follows your configured UI alias; review overwrite prompts before replacing a customized Accordion. The component has its own client boundary and may be imported by a Server Component; interactive callbacks must originate in a client module.
Install Accordion first, then optionally install portable stories:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/accordion-stories.json
Use your existing @storybook/react setup with matching Storybook versions (types and composition checked with 10.6.1), discover **/*.stories.tsx in your configured UI directory, and load your global Tailwind/theme stylesheet. The stories use the component's declared icon dependency, need no additional addons or providers, and do not overwrite the component or Storybook configuration. This project has no Storybook server; Storybook UI verification is not performed.
Accessibility & Transitions
- Triggers are native buttons inside level-three headings, with expanded state and panel relationships managed by Base UI.
- Tab moves between triggers. Enter or Space toggles the focused item.
- Visible focus rings and disabled states use the existing theme tokens.
- Panel height, content opacity, and indicator rotation or crossfade animate together with
motion/reactover240ms, using an ease-out curve without bounce or delayed content. Initially expanded panels render open without a first-paint collapse. - Closing content remains mounted until the animation finishes, but becomes inert and hidden from assistive technology immediately. Rapid toggles reverse from the current animated position.
- Reduced-motion preferences make transitions immediate. Triggers do not scale.
classNameonAccordionContentstyles the inner content wrapper, following the shadcn composition.
See the Base UI API reference for the complete primitive props, including render composition.
Props
| Prop | Type / Description | Default |
|---|---|---|
| defaultValue | Value[]Accordion: initial uncontrolled array of expanded item values. | Not set |
| disabled | booleanAccordion or AccordionItem: prevents interaction. | false |
| expandedIcon | ReactNodeAccordionTrigger: optional expanded indicator. When provided, the two icons crossfade and gently scale instead of rotating. | Not set |
| icon | ReactNodeAccordionTrigger: decorative indicator React node. Pass null to hide it; custom icon components do not need Motion props or refs. | ChevronDownIcon |
| iconRotation | numberAccordionTrigger: expanded rotation in degrees for a single icon. Ignored when expandedIcon is supplied. Use 45 to turn Plus into a close mark, or 0 for no rotation. | 180 |
| iconStyle | string (demo only)Demo composition: default chevron, Plus/Minus crossfade, Plus rotation, or no indicator. Configures AccordionTrigger props. | "chevron" (demo) |
| keepMounted | booleanAccordionContent: keeps closed panels in the DOM. | false |
| multiple | booleanAccordion: allows multiple panels to remain open. | false |
| onValueChange | (value, details) => voidAccordion: receives expanded values and Base UI event details. | Not set |
| value | Value[]Accordion: controlled array of expanded item values. | Not set |
The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.
Source
"use client";
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { ChevronDownIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
const panelContext = createContext({
hiddenUntilFound: false,
keepMounted: false,
});
const transition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
const PanelElement = ({
nativeProps,
render,
state,
ref,
...props
}: ComponentProps<"div"> & {
nativeProps: ComponentProps<"div">;
render?: AccordionPrimitive.Panel.Props["render"];
state: AccordionPrimitive.Panel.State;
}) =>
useRender({
props: mergeProps(nativeProps, props),
ref: [nativeProps.ref ?? null, ref ?? null],
render,
state: { ...state },
});
const MotionPanelElement = motion.create(PanelElement);
const TriggerElement = ({
nativeProps,
state,
render,
children,
}: {
nativeProps: ComponentProps<"button">;
state: AccordionPrimitive.Trigger.State;
render?: AccordionPrimitive.Trigger.Props["render"];
children: ComponentProps<"button">["children"];
}) =>
useRender({
defaultTagName: "button",
props: { ...nativeProps, children },
ref: nativeProps.ref ?? null,
render,
state: { ...state },
});
const AnimatedPanel = ({
nativeProps,
render,
state,
keepMounted,
hiddenUntilFound,
}: {
nativeProps: ComponentProps<"div">;
render?: AccordionPrimitive.Panel.Props["render"];
state: AccordionPrimitive.Panel.State;
keepMounted: boolean;
hiddenUntilFound: boolean;
}) => {
const reduceMotion = useReducedMotion();
const [retained, setRetained] = useState(state.open);
const openRef = useRef(state.open);
useEffect(() => {
openRef.current = state.open;
if (state.open) {
setRetained(true);
}
}, [state.open]);
const visible = state.open || retained;
return (
<MotionPanelElement
nativeProps={{
...nativeProps,
hidden: visible ? false : nativeProps.hidden,
}}
render={render}
state={state}
initial={false}
animate={{ height: state.open ? "auto" : 0, opacity: state.open ? 1 : 0 }}
transition={{
...transition,
duration: reduceMotion ? 0 : transition.duration,
}}
inert={!state.open}
aria-hidden={state.open ? undefined : true}
onAnimationComplete={() => {
if (!openRef.current) {
setRetained(false);
}
}}
>
{visible || keepMounted || hiddenUntilFound ? nativeProps.children : null}
</MotionPanelElement>
);
};
const Accordion = <Value,>({
className,
keepMounted = false,
hiddenUntilFound = false,
...props
}: AccordionPrimitive.Root.Props<Value>) => (
<panelContext.Provider value={{ hiddenUntilFound, keepMounted }}>
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
keepMounted={keepMounted}
hiddenUntilFound={hiddenUntilFound}
/>
</panelContext.Provider>
);
const AccordionItem = ({
className,
...props
}: AccordionPrimitive.Item.Props) => (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
);
type AccordionTriggerProps = AccordionPrimitive.Trigger.Props & {
icon?: ReactNode;
expandedIcon?: ReactNode;
iconRotation?: number;
};
const AccordionTrigger = ({
className,
children,
render,
icon = <ChevronDownIcon />,
expandedIcon,
iconRotation = 180,
...props
}: AccordionTriggerProps) => {
const reduceMotion = useReducedMotion();
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none hover:not-data-disabled:underline focus-visible:ring-[3px] focus-visible:ring-ring/50 data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
render={(nativeProps, state) => (
<TriggerElement
nativeProps={nativeProps}
state={state}
render={render}
>
{children}
{icon !== null && icon !== false && (
<motion.span
aria-hidden="true"
data-slot="accordion-trigger-icon"
className="pointer-events-none relative mt-0.5 inline-grid size-4 shrink-0 place-items-center text-muted-foreground"
initial={false}
animate={{
rotate:
state.open && expandedIcon === undefined ? iconRotation : 0,
}}
transition={{
...transition,
duration: reduceMotion ? 0 : transition.duration,
}}
>
{expandedIcon === undefined ? (
icon
) : (
<>
<motion.span
className="col-start-1 row-start-1 inline-flex"
initial={false}
animate={{
opacity: state.open ? 0 : 1,
scale: state.open ? 0.85 : 1,
}}
transition={{
...transition,
duration: reduceMotion ? 0 : transition.duration,
}}
>
{icon}
</motion.span>
<motion.span
className="col-start-1 row-start-1 inline-flex"
initial={false}
animate={{
opacity: state.open ? 1 : 0,
scale: state.open ? 1 : 0.85,
}}
transition={{
...transition,
duration: reduceMotion ? 0 : transition.duration,
}}
>
{expandedIcon}
</motion.span>
</>
)}
</motion.span>
)}
</TriggerElement>
)}
/>
</AccordionPrimitive.Header>
);
};
const AccordionContent = ({
className,
children,
render,
keepMounted,
hiddenUntilFound,
...props
}: AccordionPrimitive.Panel.Props) => {
const defaults = useContext(panelContext);
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm"
{...props}
keepMounted
hiddenUntilFound={hiddenUntilFound ?? defaults.hiddenUntilFound}
render={(nativeProps, state) => (
<AnimatedPanel
nativeProps={nativeProps}
state={state}
render={render}
keepMounted={keepMounted ?? defaults.keepMounted}
hiddenUntilFound={hiddenUntilFound ?? defaults.hiddenUntilFound}
/>
)}
>
<div
className={cn(
"pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-4 [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
};
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
export type { AccordionTriggerProps };
Credits
- shadcn/ui: Base UI Accordion composition and foundation. Vandor UI adds Motion-powered height, fade, and chevron transitions with reduced-motion support and direct cn integration.