Data Display

Accordion

A Radix accordion with measured-height open/close animation (250ms out, 200ms exit), a rotating chevron, and a plus-minus variant whose lines morph instead of swapping.

Install

npx shadcn@latest add @paragon/accordion

accordion.tsx

"use client";

import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

/**
 * Radix drives open/close with a measured height custom property; these
 * keyframes are the one sanctioned height animation (the value is known,
 * so there is no layout thrash). Open 250ms ease-out, close 200ms
 * ease-exit — the close never retraces the open. Reduced motion collapses
 * both to an instant cut.
 */
const ACCORDION_KEYFRAMES = `
@keyframes paragon-accordion-open {
  from { height: 0; }
  to { height: var(--radix-accordion-content-height); }
}
@keyframes paragon-accordion-close {
  from { height: var(--radix-accordion-content-height); }
  to { height: 0; }
}
@media (prefers-reduced-motion: reduce) {
  [data-slot="accordion-content"] { animation: none !important; }
}
`;

export function Accordion({
  className,
  children,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      className={cn("w-full", className)}
      {...props}
    >
      <style href="paragon-accordion" precedence="paragon">{ACCORDION_KEYFRAMES}</style>
      {children}
    </AccordionPrimitive.Root>
  );
}

export function AccordionItem({
  className,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-item"
      className={cn("border-b last:border-b-0", className)}
      {...props}
    />
  );
}

export interface AccordionTriggerProps
  extends React.ComponentProps<typeof AccordionPrimitive.Trigger> {
  /**
   * Trailing indicator. `plus-minus` morphs its vertical line 90° into
   * the horizontal one — a rotation, not an icon swap.
   */
  icon?: "chevron" | "plus-minus";
}

export function AccordionTrigger({
  icon = "chevron",
  className,
  children,
  ...props
}: AccordionTriggerProps) {
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group flex flex-1 items-center justify-between gap-4 rounded-md py-4 text-left text-sm font-medium",
          className,
        )}
        {...props}
      >
        {children}
        {icon === "chevron" ? (
          <ChevronDown
            aria-hidden
            className="size-4 shrink-0 text-muted-foreground transition-[rotate] duration-(--duration-base) ease-(--ease-out) group-data-[state=open]:rotate-180 motion-reduce:transition-none"
          />
        ) : (
          <span aria-hidden className="relative size-3.5 shrink-0 text-muted-foreground">
            <span className="absolute top-1/2 left-0 h-[1.5px] w-full -translate-y-1/2 rounded-full bg-current" />
            <span className="absolute top-0 left-1/2 h-full w-[1.5px] -translate-x-1/2 rounded-full bg-current transition-[rotate] duration-(--duration-base) ease-(--ease-in-out) group-data-[state=open]:rotate-90 motion-reduce:transition-none" />
          </span>
        )}
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  );
}

export function AccordionContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
  return (
    <AccordionPrimitive.Content
      data-slot="accordion-content"
      className="overflow-hidden text-sm data-[state=closed]:animate-[paragon-accordion-close_200ms_var(--ease-exit)] data-[state=open]:animate-[paragon-accordion-open_250ms_var(--ease-out)]"
      {...props}
    >
      <div className={cn("pb-4 text-muted-foreground", className)}>
        {children}
      </div>
    </AccordionPrimitive.Content>
  );
}