sona-ui
uiaccordionsource: ok · refreshed
Accordion
An accessible Base UI accordion with controlled and uncontrolled state, four visual variants, interruptible disclosure transitions, and reduced-motion support.
npx shadcn add "https://sonaui.com/r/accordion.json"free
Where it goes
stats
Interaction
animated-counteraccordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
Registry dependencies
@sona-ui/sona-utils
Files3 · source cached 10/7/2026, 11:56:21 PM
registry/sonaui/accordion/accordion.tsx
"use client";
import { Accordion } from "@base-ui/react/accordion";
import { cva } from "class-variance-authority";
import { createContext, type ReactNode, useContext } from "react";
import { cn } from "@/lib/sona-utils";
import AnimatedPlusMinusButton from "./animated-plus-minus-button";
import styles from "./styles.module.css";
export type AccordionVariant = "default" | "outlined" | "splitted" | "animated";
export interface AccordionProps
extends Omit<Accordion.Root.Props<string>, "className" | "multiple"> {
/** The accordion items. */
children: ReactNode;
/** Allows multiple accordion items to be open simultaneously. @default false */
allowMultiple?: boolean;
/** Controlled values of the currently open items. */
value?: string[];
/** Values of the initially open items for uncontrolled usage. */
defaultValue?: string[];
/** Called when the set of open item values changes. */
onValueChange?: Accordion.Root.Props<string>["onValueChange"];
/** Whether the entire accordion ignores interaction. @default false */
disabled?: boolean;
/** Keeps closed panels mounted in the DOM. @default false */
keepMounted?: boolean;
/** Allows browser find-in-page to reveal matching panel content. @default false */
hiddenUntilFound?: boolean;
/** Additional classes for the accordion root. */
className?: string;
/** Visual style of the accordion. @default "default" */
variant?: AccordionVariant;
}
const accordionWrapperVariants = cva(
"flex w-full flex-col items-stretch overflow-clip rounded-2xl",
{
variants: {
variant: {
default: "overflow-clip rounded-2xl",
outlined: "overflow-clip rounded-2xl",
splitted: "overflow-clip rounded-2xl",
animated: styles.wrapper,
},
},
defaultVariants: { variant: "default" },
},
);
const accordionItemVariants = cva(
"relative w-full min-w-0 overflow-hidden bg-background text-foreground transition-[transform,border-radius] duration-300",
{
variants: {
variant: {
default: "border-b border-border",
outlined:
"border-foreground border-t border-x last:border-b first:rounded-t-2xl last:rounded-b-2xl",
splitted: "rounded-2xl",
animated: styles.animated,
},
},
defaultVariants: { variant: "default" },
},
);
const AccordionContext = createContext<{ variant: AccordionVariant } | null>(
null,
);
function useAccordionContext(component: string) {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(`${component} must be used within AccordionRoot`);
}
return context;
}
export function AccordionRoot({
children,
allowMultiple = false,
className,
variant = "default",
...props
}: AccordionProps) {
return (
<AccordionContext.Provider value={{ variant }}>
<Accordion.Root
multiple={allowMultiple}
className={cn(
accordionWrapperVariants({ variant }),
variant === "splitted" && "gap-y-2",
className,
)}
{...props}
>
{children}
</Accordion.Root>
</AccordionContext.Provider>
);
}
export interface AccordionItemProps
extends Omit<Accordion.Item.Props, "className"> {
/** Stable value used to identify the item. */
value?: string;
/** Additional classes for the accordion item. */
className?: string;
}
export function AccordionItem({
children,
className,
value,
...props
}: AccordionItemProps) {
const { variant } = useAccordionContext("AccordionItem");
return (
<Accordion.Item
value={value}
className={cn(accordionItemVariants({ variant }), className)}
{...props}
>
<div className="relative w-full min-w-0">{children}</div>
</Accordion.Item>
);
}
export interface AccordionItemHeaderProps {
/** Header content displayed before the open-state icon. */
children: ReactNode;
/** Legacy item value; state is now owned by AccordionItem. */
value?: string;
/** Additional classes for the header layout. */
className?: string;
}
export function AccordionItemHeader({
children,
className,
}: AccordionItemHeaderProps) {
useAccordionContext("AccordionItemHeader");
return (
<div
className={cn(
"flex w-full min-w-0 items-center justify-between rounded-xl px-8 py-4 font-medium text-balance",
className,
)}
>
<div className="min-w-0 flex-1">{children}</div>
<AnimatedPlusMinusButton />
</div>
);
}
export interface AccordionItemTriggerProps
extends Omit<Accordion.Trigger.Props, "className"> {
/** Trigger contents. */
children: ReactNode;
/** Legacy item value; state is now owned by AccordionItem. */
value?: string;
/** Additional classes for the trigger. */
className?: string;
}
export function AccordionItemTrigger({
children,
className,
value: _value,
...props
}: AccordionItemTriggerProps) {
useAccordionContext("AccordionItemTrigger");
return (
<Accordion.Header className="w-full">
<Accordion.Trigger
className={cn(
"group block w-full min-w-0 cursor-pointer text-left",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
className,
)}
{...props}
>
{children}
</Accordion.Trigger>
</Accordion.Header>
);
}
export interface AccordionItemContentProps
extends Omit<Accordion.Panel.Props, "className"> {
/** Panel contents. */
children: ReactNode;
/** Legacy item value; state is now owned by AccordionItem. */
value?: string;
/** Additional classes for the panel content. */
className?: string;
}
export function AccordionItemContent({
children,
className,
value: _value,
...props
}: AccordionItemContentProps) {
useAccordionContext("AccordionItemContent");
return (
<Accordion.Panel
className={cn(
"h-[var(--accordion-panel-height)] w-full min-w-0 overflow-hidden text-sm opacity-100",
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]",
"data-starting-style:h-0 data-ending-style:h-0",
"data-starting-style:opacity-0 data-ending-style:opacity-0",
"motion-reduce:transition-none",
)}
{...props}
>
<div className={cn("w-full min-w-0 px-8 pb-4", className)}>
{children}
</div>
</Accordion.Panel>
);
}
export { AccordionContext };
registry/sonaui/accordion/animated-plus-minus-button.tsx
"use client";
export interface AnimatedPlusMinusButtonProps {
/** Icon size in pixels. @default 24 */
size?: number;
}
export default function AnimatedPlusMinusButton({
size = 24,
}: AnimatedPlusMinusButtonProps) {
return (
<span aria-hidden="true" className="flex items-center justify-center">
<svg width={size} height={size} viewBox="0 0 24 24">
<title>Expand or collapse</title>
<line
x1="1"
y1="12"
x2="23"
y2="12"
stroke="currentColor"
strokeWidth="1"
className="origin-center transition-opacity duration-200 group-data-panel-open:opacity-0 motion-reduce:transition-none"
/>
<line
x1="12"
y1="1"
x2="12"
y2="23"
stroke="currentColor"
strokeWidth="1"
className="origin-center transition-transform duration-200 group-data-panel-open:rotate-90 motion-reduce:transition-none"
/>
</svg>
</span>
);
}
registry/sonaui/accordion/styles.module.css
.wrapper {
border-radius: 1rem;
overflow: visible;
}
.animated {
--_margin-gap: 1.5rem;
--ease: linear(
0 0%,
0.2688 9.91%,
0.3859 15%,
0.4917 20.19%,
0.5865 25.5%,
0.6705 30.93%,
0.7441 36.51%,
0.8075 42.26%,
0.8593 47.98%,
0.9022 53.93%,
0.9366 60.13%,
0.963 66.67%,
0.9812 73.4%,
0.9929 80.76%,
0.9986 88.89%,
1 100%
);
--bounce-duration: calc((1 / var(--speed, 1)) * 1s);
--duration: calc((0.36 / var(--speed, 1)) * 1s);
outline: 1px solid;
transition-property: transform, border-radius;
transition-duration: var(--duration);
transition-timing-function: var(--ease);
transform-style: preserve-3d;
perspective: 120px;
}
.animated:has(~ .animated[data-open]) {
transform: translate3d(0, calc(var(--_margin-gap) * -1), 0);
}
.animated[data-open] ~ .animated {
transform: translate3d(0, calc(var(--_margin-gap) * 1), 0);
}
.animated:is(:first-child) {
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
.animated:is(:last-child) {
border-bottom-left-radius: 1rem;
border-bottom-right-radius: 1rem;
}
.animated[data-open] {
border-radius: 1rem;
}
.animated[data-open] + .animated {
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
.animated.animated:has(+ .animated[data-open]) {
border-bottom-left-radius: 1rem;
border-bottom-right-radius: 1rem;
}