shadimage
cubby-ui
uiaccordionsource: ok · hit

Accordion

A accordion component.

npx shadcn add "https://www.cubby-ui.dev/r/accordion.json"
free

Interaction

accordion

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@hugeicons/react@hugeicons/core-free-icons

Files1 · source cached 10/13/2026, 10:27:05 AM

registry/default/accordion/accordion.tsx
import * as React from "react";
import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
import { cn } from "@/lib/utils";

import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDownIcon } from "@hugeicons/core-free-icons";
type AccordionVariant =
  | "default"
  | "split"
  | "outline"
  | "nested"
  | "isolated-bordered"
  | "isolated-filled"
  | "isolated-filled-bordered";

function Accordion({
  className,
  multiple = false,
  variant = "default",
  ...props
}: BaseAccordion.Root.Props & { variant?: AccordionVariant }) {
  return (
    <BaseAccordion.Root
      data-slot="accordion"
      data-variant={variant}
      multiple={multiple}
      className={cn(
        "group flex w-full flex-col",
        variant === "split" && "space-y-2",
        variant === "outline" && "bg-card overflow-hidden rounded-lg border",
        variant === "nested" && "bg-muted rounded-lg border p-1",
        className,
      )}
      {...props}
    />
  );
}

function AccordionItem({ className, ...props }: BaseAccordion.Item.Props) {
  return (
    <BaseAccordion.Item
      data-slot="accordion-item"
      className={cn(
        "ease-out-expo transition-[margin,border-radius,border] duration-250",
        // Default variant styles
        "group-data-[variant=default]:border-b group-data-[variant=default]:last:border-b-0",
        // Split variant styles (individual cards)
        "group-data-[variant=split]:bg-card group-data-[variant=split]:border-border group-data-[variant=split]:overflow-hidden group-data-[variant=split]:rounded-lg group-data-[variant=split]:border",
        // Outline variant styles (items within single card)
        "group-data-[variant=outline]:border-border group-data-[variant=outline]:border-b group-data-[variant=outline]:last:border-b-0",
        // Nested variant styles - items within the single outer card
        "group-data-[variant=nested]:mt-0.5 group-data-[variant=nested]:first:mt-0",
        // Isolated bordered variant styles - dynamically separates open items from closed ones
        // Base styles: all items have borders and background to look like cards
        "group-data-[variant=isolated-bordered]:bg-card group-data-[variant=isolated-bordered]:border-border group-data-[variant=isolated-bordered]:overflow-hidden group-data-[variant=isolated-bordered]:border",
        // Closed items: group together by overlapping borders with negative margin
        // Only apply to closed items that follow another closed item (not open items, first item, or items after open items)
        "group-data-[variant=isolated-bordered]:relative group-data-[variant=isolated-bordered]:not-first:not-data-open:not-[[data-open]+&]:-mt-px",
        // Opened item - separated with margins
        "group-data-[variant=isolated-bordered]:data-open:mt-2 group-data-[variant=isolated-bordered]:data-open:mb-2 group-data-[variant=isolated-bordered]:data-open:rounded-lg",
        // First item
        "group-data-[variant=isolated-bordered]:first:rounded-t-lg group-data-[variant=isolated-bordered]:data-open:first:mt-0",
        // Last item
        "group-data-[variant=isolated-bordered]:last:rounded-b-lg group-data-[variant=isolated-bordered]:data-open:last:mb-0",
        // Item after open item: round top corners (start of new group)
        "group-data-[variant=isolated-bordered]:[[data-open]+&]:rounded-t-lg",
        // Item before open item: round bottom corners (end of group)
        "group-data-[variant=isolated-bordered]:[&:has(+_[data-open])]:rounded-b-lg",
        // Isolated filled variant
        "group-data-[variant=isolated-filled]:bg-muted group-data-[variant=isolated-filled]:overflow-hidden",
        // Opened item - separated with margins and rounded
        "group-data-[variant=isolated-filled]:data-open:my-2 group-data-[variant=isolated-filled]:data-open:rounded-lg",
        // First item
        "group-data-[variant=isolated-filled]:first:rounded-t-lg group-data-[variant=isolated-filled]:data-open:first:mt-0",
        // Last item
        "group-data-[variant=isolated-filled]:last:rounded-b-lg group-data-[variant=isolated-filled]:data-open:last:mb-0",
        // Item after open item: round top corners (start of new group)
        "group-data-[variant=isolated-filled]:[[data-open]+&]:rounded-t-lg",
        // Item before open item: round bottom corners (end of group)
        "group-data-[variant=isolated-filled]:[&:has(+_[data-open])]:rounded-b-lg",
        // Isolated filled bordered variant - filled background with borders between closed items
        "group-data-[variant=isolated-filled-bordered]:bg-muted group-data-[variant=isolated-filled-bordered]:overflow-hidden",
        // All items except last have border-bottom to prevent layout shift, default to visible border
        "group-data-[variant=isolated-filled-bordered]:not-last:border-border group-data-[variant=isolated-filled-bordered]:not-last:border-b",
        // Make border transparent (invisible) for: items before open items, and open items themselves
        "group-data-[variant=isolated-filled-bordered]:[&:has(+_[data-open])]:border-transparent",
        "group-data-[variant=isolated-filled-bordered]:data-open:border-transparent",
        // Opened item - separated with margins and rounded
        "group-data-[variant=isolated-filled-bordered]:data-open:my-2 group-data-[variant=isolated-filled-bordered]:data-open:rounded-lg",
        // First item
        "group-data-[variant=isolated-filled-bordered]:first:rounded-t-lg group-data-[variant=isolated-filled-bordered]:data-open:first:mt-0",
        // Last item
        "group-data-[variant=isolated-filled-bordered]:last:rounded-b-lg group-data-[variant=isolated-filled-bordered]:data-open:last:mb-0",
        // Item after open item: round top corners (start of new group)
        "group-data-[variant=isolated-filled-bordered]:[[data-open]+&]:rounded-t-lg",
        // Item before open item: round bottom corners (end of group)
        "group-data-[variant=isolated-filled-bordered]:[&:has(+_[data-open])]:rounded-b-lg",

        className,
      )}
      {...props}
    />
  );
}

function AccordionHeader({ className, ...props }: BaseAccordion.Header.Props) {
  return (
    <BaseAccordion.Header
      data-slot="accordion-header"
      // `not-prose` guards the trigger from document typography when the
      // accordion is rendered inside a prose/markdown container (e.g. MDX docs),
      // which would otherwise style this heading with large margins and an
      // inflated line-height. Inert outside a prose context.
      className={cn("not-prose", className)}
      {...props}
    />
  );
}

interface AccordionTriggerProps extends BaseAccordion.Trigger.Props {
  showIndicator?: boolean;
  indicatorType?: "chevron" | "plus";
  indicatorPosition?: "start" | "end";
  icon?: React.ReactNode;
  subtitle?: React.ReactNode;
}

function AccordionTrigger({
  children,
  className,
  showIndicator = true,
  indicatorType = "plus",
  indicatorPosition = "end",
  icon,
  subtitle,
  ...props
}: AccordionTriggerProps) {
  const hasStartIndicator = showIndicator && indicatorPosition === "start";
  const hasEndIndicator = showIndicator && indicatorPosition === "end";

  const renderIndicator = () => {
    if (!showIndicator) return null;

    const indicatorIcon =
      indicatorType === "chevron" ? (
        <HugeiconsIcon
          icon={ChevronDownIcon}
          data-slot="accordion-indicator"
          className="text-muted-foreground ease-out-expo size-4 shrink-0 transition-transform duration-200"
          strokeWidth={2}
        />
      ) : (
        <PlusIcon
          data-slot="accordion-indicator"
          className="text-muted-foreground ease-out-expo size-4 shrink-0 transition-transform duration-200"
        />
      );

    return (
      <span className="text-muted-foreground shrink-0" aria-hidden="true">
        {indicatorIcon}
      </span>
    );
  };

  return (
    <AccordionHeader>
      <BaseAccordion.Trigger
        data-slot="accordion-trigger"
        data-has-icon={hasStartIndicator ? "true" : undefined}
        className={cn(
          "group/trigger flex w-full cursor-pointer items-center justify-between gap-3 p-3.5 text-left text-sm font-medium outline-none disabled:pointer-events-none disabled:opacity-60",
          // Default variant styles
          "group-data-[variant=default]:px-0",
          // Split variant styles
          "group-data-[variant=split]:rounded-t-lg",
          // Nested variant styles - trigger with subtle interaction
          "group-data-[variant=nested]:hover:bg-background group-data-[variant=nested]:dark:hover:bg-background/50 group-data-[variant=nested]:rounded-sm group-data-[variant=nested]:px-3 group-data-[variant=nested]:py-2.5",
          // Indicator rotation animations
          indicatorType === "chevron" &&
            "[&[data-panel-open]_[data-slot=accordion-indicator]]:rotate-180",
          indicatorType === "plus" &&
            "[&[data-panel-open]_[data-slot=accordion-indicator]]:rotate-90",
          className,
        )}
        {...props}
      >
        {hasStartIndicator && renderIndicator()}

        {/* icon prop — only rendered when indicator is at end position */}
        {icon && !hasStartIndicator && (
          <span className="text-muted-foreground shrink-0" aria-hidden="true">
            {icon}
          </span>
        )}

        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <span className="underline-offset-2 group-hover/trigger:underline">
            {children}
          </span>
          {subtitle && (
            <span className="text-muted-foreground text-sm no-underline">
              {subtitle}
            </span>
          )}
        </div>

        {hasEndIndicator && renderIndicator()}
      </BaseAccordion.Trigger>
    </AccordionHeader>
  );
}

function AccordionContent({
  children,
  className,
  ...props
}: BaseAccordion.Panel.Props) {
  return (
    <BaseAccordion.Panel
      data-slot="accordion-content"
      className={cn(
        "ease-out-expo h-(--accordion-panel-height) overflow-hidden text-sm transition-[height,opacity] duration-250 data-ending-style:h-0 data-ending-style:opacity-0 data-starting-style:h-0 data-starting-style:opacity-0",
      )}
      {...props}
    >
      <div
        className={cn(
          "text-muted-foreground p-3.5",
          "group-data-[variant=default]:px-0 group-data-[variant=default]:pt-0",
          "group-data-[variant=split]:pt-0",
          "group-data-[variant=outline]:pt-0",
          // Nested variant
          "group-data-[variant=nested]:bg-background group-data-[variant=nested]:mt-1 group-data-[variant=nested]:mb-0.5 group-data-[variant=nested]:rounded-sm group-data-[variant=nested]:border group-data-[variant=nested]:p-3 group-data-[variant=nested]:[[data-slot=accordion-item]:last-child_&]:mb-0",
          // Isolated bordered variant
          "group-data-[variant=isolated-bordered]:pt-0",
          // Isolated filled variant
          "group-data-[variant=isolated-filled]:pt-0",
          // Isolated filled bordered variant
          "group-data-[variant=isolated-filled-bordered]:pt-0",
          // Icon alignment - add left padding when parent item contains a trigger with icon
          "[[data-slot=accordion-item]:has([data-has-icon])_&]:pl-[calc(1rem+0.75rem)]",
          "[[data-slot=accordion-item]:has([data-has-icon])_&]:group-data-[variant=default]:pl-[calc(1rem+0.75rem)]",
          className,
        )}
      >
        {children}
      </div>
    </BaseAccordion.Panel>
  );
}

export {
  Accordion,
  AccordionItem,
  AccordionHeader,
  AccordionTrigger,
  AccordionContent,
};

function PlusIcon(props: React.ComponentProps<"svg">) {
  return (
    <svg viewBox="0 0 24 24" fill="none" {...props}>
      {/* Horizontal bar - fades out when accordion opens */}
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M2.75 12C2.75 11.3096 3.30964 10.75 4 10.75H20C20.6904 10.75 21.25 11.3096 21.25 12C21.25 12.6904 20.6904 13.25 20 13.25H4C3.30964 13.25 2.75 12.6904 2.75 12Z"
        fill="currentColor"
        className="ease-out-expo transition-opacity duration-200 in-data-panel-open:opacity-0"
      />
      {/* Vertical bar - remains visible (becomes horizontal after 90° rotation) */}
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M12 2.75C12.6904 2.75 13.25 3.30964 13.25 4V20C13.25 20.6904 12.6904 21.25 12 21.25C11.3096 21.25 10.75 20.6904 10.75 20V4C10.75 3.30964 11.3096 2.75 12 2.75Z"
        fill="currentColor"
      />
    </svg>
  );
}

More from cubby-ui

Browse all components in this library →