shadimage
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 · light
ui-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;
}

More from sona-ui

Browse all components in this library →