Accordion
A shadcn-style accordion component built with Ark UI primitives.
example/ and point ComponentPreview at the file name.import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionDemo = () => {
return (
<Accordion
collapsible
defaultValue={["basics"]}
className="w-full max-w-md"
>
<AccordionItem value="basics">
<AccordionTrigger>What is Ark CN?</AccordionTrigger>
<AccordionPanel>
Ark UI primitives styled like shadcn/ui—ready to paste into your app
and theme with Tailwind.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="preview">
<AccordionTrigger>How do previews work?</AccordionTrigger>
<AccordionPanel>
Drop a demo in{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">example/</code>{" "}
and point{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
ComponentPreview
</code>{" "}
at the file name.
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default AccordionDemo;
Installation
npx shadcn@latest add @ark-cn/accordionInstall the following dependency:
npm install @ark-ui/react lucide-react tw-animate-cssCopy the component into your project (for example under components/ui):
"use client";
import {
Accordion as AccordionPrimitive,
useAccordion,
} from "@ark-ui/react/accordion";
import { ChevronDownIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const Accordion = ({
className,
...props
}: AccordionPrimitive.RootProps) => {
return (
<AccordionPrimitive.Root
className={cn(
"flex w-full data-[orientation=vertical]:flex-col data-[orientation=horizontal]:max-h-[calc(100vh-8rem)] data-[orientation=horizontal]:h-80 data-[orientation=horizontal]:flex-row",
className,
)}
{...props}
/>
);
};
export const AccordionItem = ({
className,
...props
}: AccordionPrimitive.ItemProps) => {
return (
<AccordionPrimitive.Item
className={cn(
"[overflow-anchor:none] border-b border-border last:border-b-0 data-[orientation=horizontal]:flex data-[orientation=horizontal]:border-b-0 data-[orientation=horizontal]:border-e data-[orientation=horizontal]:last:border-e-0",
className,
)}
{...props}
/>
);
};
export const AccordionTrigger = ({
className,
children,
indicator,
...props
}: AccordionPrimitive.ItemTriggerProps & {
indicator?: ReactNode;
}) => {
return (
<AccordionPrimitive.ItemTrigger
className={cn(
"flex w-full flex-1 cursor-pointer items-center justify-between gap-3 rounded-md border-none bg-transparent px-2 py-4 text-start font-medium text-sm leading-normal outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-[orientation=vertical]:min-h-10 data-[orientation=horizontal]:h-full data-[orientation=horizontal]:min-w-10 data-[orientation=horizontal]:[writing-mode:sideways-lr]",
className,
)}
{...props}
>
{children}
<AccordionPrimitive.ItemIndicator
data-slot="accordion-indicator"
className="inline-flex origin-center items-center justify-center text-foreground transition-transform duration-200 ease-out data-[state=open]:rotate-180 [&_svg]:size-[1.2em] data-[orientation=horizontal]:-rotate-90 data-[orientation=horizontal]:data-[state=open]:rotate-90"
>
{indicator || (
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 opacity-80" />
)}
</AccordionPrimitive.ItemIndicator>
</AccordionPrimitive.ItemTrigger>
);
};
export const AccordionPanel = ({
className,
children,
containerClassName,
...props
}: AccordionPrimitive.ItemContentProps & { containerClassName?: string }) => {
return (
<AccordionPrimitive.ItemContent
className={cn(
"overflow-hidden rounded-lg text-muted-foreground text-sm [--radix-accordion-content-height:var(--height)] data-[orientation=vertical]:data-[state=open]:animate-accordion-down data-[orientation=vertical]:data-[state=closed]:animate-accordion-up data-[orientation=horizontal]:will-change-[width] data-[orientation=horizontal]:data-[state=open]:animate-[accordion-open-x_200ms_ease-out] data-[orientation=horizontal]:data-[state=closed]:animate-[accordion-close-x_200ms_ease-out] data-[orientation=horizontal]:*:whitespace-nowrap data-[orientation=horizontal]:[&>div]:pb-2.5",
className,
)}
{...props}
>
<div className={cn("px-2 pt-0 pb-4 leading-normal", containerClassName)}>
{children}
</div>
</AccordionPrimitive.ItemContent>
);
};
export const AccordionContext = AccordionPrimitive.Context;
export const AccordionProvider = ({
children,
className,
...props
}: AccordionPrimitive.RootProviderProps) => {
return (
<AccordionPrimitive.RootProvider
className={cn("w-full", className)}
{...props}
>
{children}
</AccordionPrimitive.RootProvider>
);
};
export { useAccordion };
Add the following CSS to your stylesheet (e.g. styles.css):
@keyframes accordion-open-x {
from { width: 0; opacity: 0; }
to { width: var(--width); opacity: 1; }
}
@keyframes accordion-close-x {
from { width: var(--width); opacity: 1; }
to { width: 0; opacity: 0; }
}Ensure Tailwind CSS v4, class-variance-authority, and your cn helper match this repo (see the Installation page). Update import paths (@/components/...) to match your app.
Usage
import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion"<Accordion collapsible defaultValue={["item-1"]}>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionPanel>
Yes. It follows the WAI-ARIA accordion pattern via Ark UI.
</AccordionPanel>
</AccordionItem>
</Accordion>Examples
Single item open
multiple defaults to false, so only one section is expanded at a time (unless you set multiple).
multiple={false} (the default).import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionSingleDemo = () => {
return (
<Accordion
collapsible
multiple={false}
defaultValue={["one"]}
className="w-full max-w-md"
>
<AccordionItem value="one">
<AccordionTrigger>First section</AccordionTrigger>
<AccordionPanel>
Only one section stays open at a time when{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
multiple={false}
</code>{" "}
(the default).
</AccordionPanel>
</AccordionItem>
<AccordionItem value="two">
<AccordionTrigger>Second section</AccordionTrigger>
<AccordionPanel>Content for the second item.</AccordionPanel>
</AccordionItem>
<AccordionItem value="three">
<AccordionTrigger>Third section</AccordionTrigger>
<AccordionPanel>Content for the third item.</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default AccordionSingleDemo;
Multiple items open
Set multiple so more than one panel can stay open.
multiple, more than one panel can be open at once.import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionMultipleDemo = () => {
return (
<Accordion
multiple
collapsible
defaultValue={["a", "b"]}
className="w-full max-w-md"
>
<AccordionItem value="a">
<AccordionTrigger>Section A</AccordionTrigger>
<AccordionPanel>
With{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">multiple</code>
, more than one panel can be open at once.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="b">
<AccordionTrigger>Section B</AccordionTrigger>
<AccordionPanel>Content for section B.</AccordionPanel>
</AccordionItem>
<AccordionItem value="c">
<AccordionTrigger>Section C</AccordionTrigger>
<AccordionPanel>Content for section C.</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default AccordionMultipleDemo;
Controlled
Drive the open items with value and onValueChange.
value and onValueChange.Open: open
import { useState } from "react";
import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionControlledDemo = () => {
const [value, setValue] = useState<string[]>(["open"]);
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Accordion
multiple
collapsible
value={value}
onValueChange={(e) => setValue(e.value)}
className="w-full"
>
<AccordionItem value="open">
<AccordionTrigger>Controlled item</AccordionTrigger>
<AccordionPanel>
The open items are driven by React state via{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">value</code>{" "}
and{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
onValueChange
</code>
.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="other">
<AccordionTrigger>Another item</AccordionTrigger>
<AccordionPanel>
Toggle panels and watch the summary below.
</AccordionPanel>
</AccordionItem>
</Accordion>
<p className="text-muted-foreground text-xs">
Open:{" "}
<span className="font-mono text-foreground">
{value.length ? value.join(", ") : "none"}
</span>
</p>
</div>
);
};
export default AccordionControlledDemo;
Horizontal
Set orientation="horizontal" on the root for a row of triggers and side-by-side panels. Height is constrained by the styled root so width-based animations can run. Inner layout uses containerClassName on AccordionPanel so borders and padding stay on the content wrapper.
orientation prop. Triggers sit in a row; panels open beside them per Ark’s horizontal accordion behavior.import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionHorizontalDemo = () => {
return (
<Accordion
orientation="horizontal"
collapsible
defaultValue={["two"]}
className="w-full max-w-3xl data-[orientation=horizontal]:h-60"
>
<AccordionItem value="one">
<AccordionTrigger>Overview</AccordionTrigger>
<AccordionPanel className="border mr-4" containerClassName="p-4">
Horizontal layout uses the root{" "}
<code className="rounded bg-muted px-1 text-xs">orientation</code>{" "}
prop. Triggers sit in a row; panels open beside them per Ark’s
horizontal accordion behavior.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="two">
<AccordionTrigger>Details</AccordionTrigger>
<AccordionPanel className="border mr-4" containerClassName="p-4">
Use ArrowLeft and ArrowRight to move focus between triggers when
orientation is horizontal.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="three">
<AccordionTrigger>Notes</AccordionTrigger>
<AccordionPanel className="border mr-4" containerClassName="p-4">
The root applies fixed height classes for horizontal mode so content
can animate on the width axis.
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default AccordionHorizontalDemo;
Lazy mount
Combine lazyMount with unmountOnExit to defer rendering panel content until an item opens and remove it when it closes.
unmountOnExit, it unmounts when collapsed so heavy children are not left in the tree.import {
Accordion,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionLazyDemo = () => {
return (
<Accordion
lazyMount
unmountOnExit
collapsible
defaultValue={["first"]}
className="w-full max-w-md"
>
<AccordionItem value="first">
<AccordionTrigger>First (mounted)</AccordionTrigger>
<AccordionPanel>
This panel mounts when opened. With{" "}
<code className="rounded bg-muted px-1 text-xs">unmountOnExit</code>,
it unmounts when collapsed so heavy children are not left in the tree.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="second">
<AccordionTrigger>Second (lazy)</AccordionTrigger>
<AccordionPanel>
Content for the second item is not rendered until you expand this
section.
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default AccordionLazyDemo;
Custom indicator
AccordionTrigger accepts an indicator prop that is not part of Ark’s ItemTrigger API—it replaces the default chevron. AccordionPanel supports containerClassName for the inner wrapper (see API reference).
ReactNode to the indicator prop on AccordionTrigger. Rotation styles still apply to the indicator wrapper.indicator to use the built-in chevron.import { MinusIcon, PlusIcon } from "lucide-react";
import {
Accordion,
AccordionContext,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "@/components/ui/accordion";
const AccordionCustomIndicatorDemo = () => {
return (
<Accordion collapsible defaultValue={["a"]} className="w-full max-w-md">
<AccordionContext>
{({ value }) => {
return (
<>
<AccordionItem value="a">
<AccordionTrigger
indicator={
value.includes("a") ? (
<MinusIcon className="pointer-events-none size-4 opacity-80" />
) : (
<PlusIcon className="pointer-events-none size-4 opacity-80" />
)
}
>
Custom indicator
</AccordionTrigger>
<AccordionPanel>
Pass any{" "}
<code className="rounded bg-muted px-1 text-xs">
ReactNode
</code>{" "}
to the{" "}
<code className="rounded bg-muted px-1 text-xs">
indicator
</code>{" "}
prop on{" "}
<code className="rounded bg-muted px-1 text-xs">
AccordionTrigger
</code>
. Rotation styles still apply to the indicator wrapper.
</AccordionPanel>
</AccordionItem>
<AccordionItem value="b">
<AccordionTrigger
indicator={
value.includes("b") ? (
<MinusIcon className="pointer-events-none size-4 opacity-80" />
) : (
<PlusIcon className="pointer-events-none size-4 opacity-80" />
)
}
>
Default chevron
</AccordionTrigger>
<AccordionPanel>
Omit{" "}
<code className="rounded bg-muted px-1 text-xs">
indicator
</code>{" "}
to use the built-in chevron.
</AccordionPanel>
</AccordionItem>
</>
);
}}
</AccordionContext>
</Accordion>
);
};
export default AccordionCustomIndicatorDemo;
API reference
Accordion, AccordionItem, AccordionTrigger, AccordionPanel, AccordionContext, AccordionProvider, and useAccordion mirror @ark-ui/react/accordion. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
AccordionTrigger and AccordionPanel add optional props on top of Ark’s ItemTrigger / ItemContent:
AccordionTrigger
| Prop | Type | Description |
|---|---|---|
| indicator? | ReactNode | Optional node rendered inside ItemIndicator instead of the default ChevronDown icon. |
AccordionPanel
| Prop | Type | Description |
|---|---|---|
| containerClassName? | string | Merged onto the inner padded div that wraps children. Use Ark’s className on ItemContent for the animated outer shell; use containerClassName for inner padding, borders, and body layout. |
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification (WAI-ARIA pattern, keyboard support, and orientation-specific notes).