shadimage
mischief
uiaccordionsource: ok · hit

Accordion

A list of disclosures built on native details elements.

npx shadcn add "https://ui.tinkererslabs.com/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
lucide-react@^1.31.0

Registry dependencies

utils

Files1 · source cached 10/9/2026, 10:55:37 PM

registry/default/accordion/accordion.tsx
"use client"

import * as React from "react"
import { Plus } from "lucide-react"

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

export type AccordionItem = {
  id: string
  title: React.ReactNode
  content: React.ReactNode
}

export type AccordionProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children" | "defaultValue"
> & {
  items: readonly AccordionItem[]
  /** Only one panel stays open at a time. Defaults to true. */
  exclusive?: boolean
  /** Ids open on first render. */
  defaultOpen?: readonly string[]
  icon?: React.ReactNode
  onToggle?: (id: string, open: boolean) => void
  /**
   * Classes for the parts inside. The shape here is a sensible default, not a
   * design: a disclosure list belongs to the page it sits on.
   */
  classNames?: {
    item?: string
    trigger?: string
    marker?: string
    content?: string
  }
}

/**
 * Built on native disclosure elements, so the open and closed state, keyboard
 * handling, and find-in-page expansion come from the browser rather than from
 * scripted state. Exclusivity uses the shared name attribute for the same
 * reason.
 */
export function Accordion({
  items,
  exclusive = true,
  defaultOpen,
  icon,
  onToggle,
  classNames,
  className,
  ...rootProps
}: AccordionProps) {
  const groupName = React.useId()
  const open = React.useMemo(() => new Set(defaultOpen ?? []), [defaultOpen])

  return (
    <div
      data-slot="accordion"
      className={cn(
        "border-border divide-border min-w-0 divide-y overflow-hidden rounded-[calc(var(--radius)+0.15rem)] border",
        className
      )}
      {...rootProps}
    >
      {items.map((item) => (
        <details
          key={item.id}
          data-slot="accordion-item"
          name={exclusive ? groupName : undefined}
          open={open.has(item.id) || undefined}
          className={cn("group bg-card/40 open:bg-card", classNames?.item)}
          onToggle={(event) =>
            onToggle?.(
              item.id,
              (event.currentTarget as HTMLDetailsElement).open
            )
          }
        >
          <summary
            data-slot="accordion-trigger"
            className={cn(
              "text-foreground hover:bg-muted/60 flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 text-sm transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-inset motion-reduce:transition-none [&::-webkit-details-marker]:hidden",
              classNames?.trigger
            )}
          >
            <span className="min-w-0 flex-1">{item.title}</span>
            <span
              aria-hidden="true"
              className={cn(
                "text-muted-foreground shrink-0 transition-transform duration-200 group-open:rotate-45 motion-reduce:transition-none",
                classNames?.marker
              )}
            >
              {icon ?? <Plus size={18} strokeWidth={1.8} />}
            </span>
          </summary>

          <div
            data-slot="accordion-content"
            className={cn(
              "text-muted-foreground px-4 pb-4 text-sm leading-relaxed",
              classNames?.content
            )}
          >
            {item.content}
          </div>
        </details>
      ))}
    </div>
  )
}

More from mischief

Browse all components in this library →