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.
npx shadcn@latest add @paragon/accordion"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>
);
}