Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
npx shadcn@latest add @velaui/accordionpnpm dlx velaui add @velaui/accordionyarn dlx shadcn@latest add @velaui/accordionbunx --bun shadcn@latest add @velaui/accordionveni add accordionInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/accordionpnpm dlx velaui add @velaui/accordionyarn dlx shadcn@latest add @velaui/accordionbunx --bun shadcn@latest add @velaui/accordionExamples
- Default
- Outline fill indicator
- Fill numbered
- Icon leading
- Disabled
Usage
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
export default function Example() {
return (
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">
<AccordionTrigger>What is your refund policy?</AccordionTrigger>
<AccordionContent>
You can request a full refund within 30 days of purchase.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Do you offer support?</AccordionTrigger>
<AccordionContent>
Yes, our team is available around the clock via chat and email.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Can I cancel anytime?</AccordionTrigger>
<AccordionContent>
Absolutely — cancel your subscription at any time from settings.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
export default function Example() {
return (
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">
<AccordionTrigger>What is your refund policy?</AccordionTrigger>
<AccordionContent>
You can request a full refund within 30 days of purchase.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Do you offer support?</AccordionTrigger>
<AccordionContent>
Yes, our team is available around the clock via chat and email.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Can I cancel anytime?</AccordionTrigger>
<AccordionContent>
Absolutely — cancel your subscription at any time from settings.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}Props
Accordion
| Prop | Type | Default |
|---|---|---|
size | AccordionSize | "md" |
variant | "fill" | "underline" | "outline" | "underline" |
Takes these and every prop of AccordionProps.
AccordionItem
| Prop | Type | Default |
|---|---|---|
variant | "fill" | "underline" | "outline" | — |
Takes these and every prop of AccordionItemProps.
AccordionTrigger
| Prop | Type | Default |
|---|---|---|
indicator | "fill" | "default" | "default" |
size | AccordionSize | — |
Takes these and every prop of AccordionTriggerProps.
AccordionLeading
Takes every prop of AccordionLeadingProps.
AccordionContent
| Prop | Type | Default |
|---|---|---|
size | AccordionSize | — |
Takes these and every prop of AccordionContentProps.
Accordion
| Prop | Type | Default |
|---|---|---|
size | AccordionSize | "md" |
variant | "underline" | "outline" | "fill" | "underline" |
Takes these and every prop of AccordionProps.
AccordionItem
| Prop | Type | Default |
|---|---|---|
variant | "underline" | "outline" | "fill" | — |
Takes these and every prop of AccordionItemProps.
AccordionTrigger
| Prop | Type | Default |
|---|---|---|
indicator | "fill" | "default" | "default" |
size | AccordionSize | — |
Takes these and every prop of AccordionTriggerProps.
AccordionLeading
Takes every prop of AccordionLeadingProps.
AccordionContent
| Prop | Type | Default |
|---|---|---|
size | AccordionSize | — |
Takes these and every prop of AccordionContentProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0- Installs with it
- 1 icon
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1- Installs with it
- 1 icon
Source
components/ui/accordion.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDown } from "@/components/icons/chevron-down";
import { cn } from "@/lib/utils";
type AccordionVariant = "underline" | "outline" | "fill";
type AccordionSize = "sm" | "md" | "lg";
/**
* Style and Size are properties of the whole accordion in the design — every
* item in an example frame carries the same pair — so the root publishes them
* and each part reads them, the way ToggleGroup does upstream.
*/
const AccordionContext = React.createContext<{
variant: AccordionVariant;
size: AccordionSize;
}>({ variant: "underline", size: "md" });
const accordionVariants = cva("flex w-full flex-col", {
variants: {
variant: {
// Underline items butt together — the shared hairline IS the divider.
underline: "gap-0",
outline: "gap-1",
fill: "gap-1",
},
},
defaultVariants: {
variant: "underline",
},
});
const accordionItemVariants = cva("data-disabled:pointer-events-none", {
variants: {
variant: {
underline:
"border-b border-b-border-default data-open:border-b-border-active",
// Outline draws no surface at rest; both open and disabled add one.
outline:
"rounded-xl border border-border-default px-4 data-open:bg-bg-surface-1 data-disabled:bg-bg-surface-1",
fill: "rounded-xl bg-bg-surface-1 px-4",
},
},
defaultVariants: {
variant: "underline",
},
});
const accordionTriggerVariants = cva(
"group/accordion-trigger flex flex-1 items-center gap-2 rounded-lg border border-transparent py-4 text-left font-semibold leading-[1.4] text-text-strong transition-all outline-none hover:underline focus-visible:border-ring-neutral-outline focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow disabled:pointer-events-none disabled:cursor-not-allowed disabled:text-text-disabled **:data-[slot=accordion-trigger-icon]:ml-auto",
{
variants: {
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: {
size: "md",
},
},
);
const accordionTriggerIconVariants = cva(
"pointer-events-none flex shrink-0 items-center justify-center transition-transform duration-200 group-aria-expanded/accordion-trigger:rotate-180",
{
variants: {
indicator: {
default:
"text-icon-strong group-disabled/accordion-trigger:text-icon-disabled",
fill: "rounded-full bg-bg-foreground p-1 text-icon-inverse",
},
size: {
sm: "size-4",
md: "size-5",
lg: "size-6",
},
},
defaultVariants: {
indicator: "default",
size: "md",
},
},
);
const accordionContentVariants = cva(
"h-(--radix-accordion-content-height) pt-0 pb-4 leading-[1.4] text-text-muted [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-text-strong [&_p:not(:last-child)]:mb-2.5",
{
variants: {
size: {
sm: "text-sm",
// The design caps body copy at 16 — lg titles keep md body text.
md: "text-base",
lg: "text-base",
},
},
defaultVariants: {
size: "md",
},
},
);
export type AccordionProps = React.ComponentProps<
typeof AccordionPrimitive.Root
> &
VariantProps<typeof accordionVariants> & {
size?: AccordionSize;
};
function Accordion({
className,
variant = "underline",
size = "md",
...props
}: AccordionProps) {
const context = React.useMemo(
() => ({ variant: variant ?? "underline", size }),
[variant, size],
);
return (
<AccordionContext value={context}>
<AccordionPrimitive.Root
data-slot="accordion"
data-variant={variant}
data-size={size}
className={cn(accordionVariants({ variant }), className)}
{...props}
/>
</AccordionContext>
);
}
export type AccordionItemProps = React.ComponentProps<
typeof AccordionPrimitive.Item
> &
VariantProps<typeof accordionItemVariants>;
function AccordionItem({ className, variant, ...props }: AccordionItemProps) {
const context = React.useContext(AccordionContext);
const resolved = variant ?? context.variant;
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
data-variant={resolved}
className={cn(accordionItemVariants({ variant: resolved }), className)}
{...props}
/>
);
}
export type AccordionTriggerProps = React.ComponentProps<
typeof AccordionPrimitive.Trigger
> & {
size?: AccordionSize;
/** Chevron treatment — bare glyph, or glyph on a filled disc. */
indicator?: "default" | "fill";
};
function AccordionTrigger({
className,
children,
size,
indicator = "default",
...props
}: AccordionTriggerProps) {
const context = React.useContext(AccordionContext);
const resolved = size ?? context.size;
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
data-size={resolved}
className={cn(accordionTriggerVariants({ size: resolved }), className)}
{...props}
>
{children}
<span
data-slot="accordion-trigger-icon"
className={cn(
accordionTriggerIconVariants({ indicator, size: resolved }),
)}
>
<ChevronDown className="size-full" />
</span>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
export type AccordionLeadingProps = React.ComponentProps<"span">;
/**
* Fixed-width leading slot in the trigger — holds the icon or the ordinal of
* the design's `icon` / `number` types. The caller supplies the content.
*/
function AccordionLeading({
className,
children,
...props
}: AccordionLeadingProps) {
return (
<span
data-slot="accordion-leading"
className={cn(
"block w-6 shrink-0 text-center text-sm font-medium leading-[1.4] text-text-note group-disabled/accordion-trigger:text-text-disabled",
className,
)}
{...props}
>
<span className="inline-flex items-center justify-center align-middle [&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:text-icon-strong group-disabled/accordion-trigger:[&_svg]:text-icon-disabled">
{children}
</span>
</span>
);
}
export type AccordionContentProps = React.ComponentProps<
typeof AccordionPrimitive.Content
> & {
size?: AccordionSize;
};
function AccordionContent({
className,
children,
size,
...props
}: AccordionContentProps) {
const context = React.useContext(AccordionContext);
const resolved = size ?? context.size;
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
data-size={resolved}
className={cn(accordionContentVariants({ size: resolved }), className)}
>
{children}
</div>
</AccordionPrimitive.Content>
);
}
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionLeading,
AccordionContent,
accordionVariants,
accordionItemVariants,
accordionTriggerVariants,
accordionContentVariants,
};components/ui/accordion.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDown } from "@/components/icons/chevron-down";
import { cn } from "@/lib/utils";
type AccordionVariant = "underline" | "outline" | "fill";
type AccordionSize = "sm" | "md" | "lg";
/**
* Style and Size are properties of the whole accordion in the design — every
* item in an example frame carries the same pair — so the root publishes them
* and each part reads them, the way ToggleGroup does upstream.
*/
const AccordionContext = React.createContext<{
variant: AccordionVariant;
size: AccordionSize;
}>({ variant: "underline", size: "md" });
const accordionVariants = cva("flex w-full flex-col", {
variants: {
variant: {
// Underline items butt together — the shared hairline IS the divider.
underline: "gap-0",
outline: "gap-1",
fill: "gap-1",
},
},
defaultVariants: {
variant: "underline",
},
});
const accordionItemVariants = cva("data-disabled:pointer-events-none", {
variants: {
variant: {
underline:
"border-b border-b-border-default data-open:border-b-border-active",
// Outline draws no surface at rest; both open and disabled add one.
outline:
"rounded-xl border border-border-default px-4 data-open:bg-bg-surface-1 data-disabled:bg-bg-surface-1",
fill: "rounded-xl bg-bg-surface-1 px-4",
},
},
defaultVariants: {
variant: "underline",
},
});
const accordionTriggerVariants = cva(
"group/accordion-trigger flex flex-1 items-center gap-2 rounded-lg border border-transparent py-4 text-left font-semibold leading-[1.4] text-text-strong transition-all outline-none hover:underline focus-visible:border-ring-neutral-outline focus-visible:ring-3 focus-visible:ring-ring-neutral-shadow data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:text-text-disabled **:data-[slot=accordion-trigger-icon]:ml-auto",
{
variants: {
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: {
size: "md",
},
},
);
const accordionTriggerIconVariants = cva(
"pointer-events-none flex shrink-0 items-center justify-center transition-transform duration-200 group-aria-expanded/accordion-trigger:rotate-180",
{
variants: {
indicator: {
default:
"text-icon-strong group-data-disabled/accordion-trigger:text-icon-disabled",
fill: "rounded-full bg-bg-foreground p-1 text-icon-inverse",
},
size: {
sm: "size-4",
md: "size-5",
lg: "size-6",
},
},
defaultVariants: {
indicator: "default",
size: "md",
},
},
);
const accordionContentVariants = cva(
"pt-0 pb-4 leading-[1.4] text-text-muted [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-text-strong [&_p:not(:last-child)]:mb-2.5",
{
variants: {
size: {
sm: "text-sm",
// The design caps body copy at 16 — lg titles keep md body text.
md: "text-base",
lg: "text-base",
},
},
defaultVariants: {
size: "md",
},
},
);
export type AccordionProps = React.ComponentProps<
typeof AccordionPrimitive.Root
> &
VariantProps<typeof accordionVariants> & {
size?: AccordionSize;
};
function Accordion({
className,
variant = "underline",
size = "md",
...props
}: AccordionProps) {
const context = React.useMemo(
() => ({ variant: variant ?? "underline", size }),
[variant, size],
);
return (
<AccordionContext value={context}>
<AccordionPrimitive.Root
data-slot="accordion"
data-variant={variant}
data-size={size}
className={cn(accordionVariants({ variant }), className)}
{...props}
/>
</AccordionContext>
);
}
export type AccordionItemProps = React.ComponentProps<
typeof AccordionPrimitive.Item
> &
VariantProps<typeof accordionItemVariants>;
function AccordionItem({ className, variant, ...props }: AccordionItemProps) {
const context = React.useContext(AccordionContext);
const resolved = variant ?? context.variant;
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
data-variant={resolved}
className={cn(accordionItemVariants({ variant: resolved }), className)}
{...props}
/>
);
}
export type AccordionTriggerProps = React.ComponentProps<
typeof AccordionPrimitive.Trigger
> & {
size?: AccordionSize;
/** Chevron treatment — bare glyph, or glyph on a filled disc. */
indicator?: "default" | "fill";
};
function AccordionTrigger({
className,
children,
size,
indicator = "default",
...props
}: AccordionTriggerProps) {
const context = React.useContext(AccordionContext);
const resolved = size ?? context.size;
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
data-size={resolved}
className={cn(accordionTriggerVariants({ size: resolved }), className)}
{...props}
>
{children}
<span
data-slot="accordion-trigger-icon"
className={cn(
accordionTriggerIconVariants({ indicator, size: resolved }),
)}
>
<ChevronDown className="size-full" />
</span>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
export type AccordionLeadingProps = React.ComponentProps<"span">;
/**
* Fixed-width leading slot in the trigger — holds the icon or the ordinal of
* the design's `icon` / `number` types. The caller supplies the content.
*/
function AccordionLeading({
className,
children,
...props
}: AccordionLeadingProps) {
return (
<span
data-slot="accordion-leading"
className={cn(
"block w-6 shrink-0 text-center text-sm font-medium leading-[1.4] text-text-note group-data-disabled/accordion-trigger:text-text-disabled",
className,
)}
{...props}
>
<span className="inline-flex items-center justify-center align-middle [&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:text-icon-strong group-data-disabled/accordion-trigger:[&_svg]:text-icon-disabled">
{children}
</span>
</span>
);
}
export type AccordionContentProps = React.ComponentProps<
typeof AccordionPrimitive.Panel
> & {
size?: AccordionSize;
};
function AccordionContent({
className,
children,
size,
...props
}: AccordionContentProps) {
const context = React.useContext(AccordionContext);
const resolved = size ?? context.size;
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="h-(--accordion-panel-height) overflow-hidden transition-[height] duration-200 ease-out data-starting-style:h-0 data-ending-style:h-0"
{...props}
>
<div
data-size={resolved}
className={cn(accordionContentVariants({ size: resolved }), className)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
}
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionLeading,
AccordionContent,
accordionVariants,
accordionItemVariants,
accordionTriggerVariants,
accordionContentVariants,
};