Skip to content
ssntl/ui

Accordion

Disclosure sections that open first and let their lines follow through.

Installation

$ pnpm dlx shadcn@latest add @ssntl/accordion

Usage

tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"

<Accordion type="single" collapsible>
  <AccordionItem value="shipping">
    <AccordionTrigger>Shipping</AccordionTrigger>
    <AccordionContent><p>Same-day delivery…</p></AccordionContent>
  </AccordionItem>
</Accordion>

Rules

What this component expects from you, as it ships in components/ui/accordion.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

components/ui/accordion.md
## Rules for Accordion

- **Follow through & overlap.** The panel opens first (220ms), then each line lifts in 40ms after the last.
- Closing is faster and all at once, because leaving needs less attention than arriving.
- Headers are real buttons inside headings; arrow keys move between them (Radix).
- The chevron turns on `--ease-in-out`; with reduced motion nothing moves and the content just appears.
Open the full rules file (opens in a new tab)

Code

components/ui/accordion.tsx
"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

// Disclosure sections (Radix Accordion): arrow keys move between headers,
// Enter and Space open them, and each header is a real button in a heading.
// Follow through & overlap: the panel opens first (collapsible), then its rows
// lift in 40ms apart (follow-through), so the motion settles; closing is
// faster and all at once. Both turn off under reduced motion.
const Accordion = AccordionPrimitive.Root

function AccordionItem({
  className,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-item"
      className={cn("not-first:border-t not-first:border-border", className)}
      {...props}
    />
  )
}

function AccordionTrigger({
  className,
  children,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group flex min-h-14 flex-1 cursor-pointer items-center justify-between gap-4 rounded-xs py-3 text-start text-label",
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
          className
        )}
        {...props}
      >
        {children}
        <ChevronDown
          aria-hidden
          strokeWidth={1.6}
          className="size-5 shrink-0 opacity-80 transition-[rotate] duration-(--duration-menu) ease-in-out group-data-[state=open]:rotate-180 motion-reduce:transition-none"
        />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
}

function AccordionContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
  return (
    <AccordionPrimitive.Content
      data-slot="accordion-content"
      className="collapsible"
      {...props}
    >
      <div
        className={cn(
          "grid follow-through gap-2 pb-4 text-body text-muted-foreground",
          className
        )}
      >
        {children}
      </div>
    </AccordionPrimitive.Content>
  )
}

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }