shadimage
velobits
uiaccordionsource: ok · hit

Accordion

The one sanctioned height animation: Radix measures --radix-accordion-content-height, so it does not thrash layout the way an unbounded height:auto transition would.

npx shadcn add "https://ui.velobits.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
radix-ui@velobitsio/icons

Registry dependencies

https://ui.velobits.dev/r/cn.json

Files1 · source cached 10/14/2026, 8:12:54 AM

registry/velobits/ui/accordion.tsx
'use client';

import { Accordion as AccordionPrimitive } from 'radix-ui';

import { ChevronDownIcon } from '@velobitsio/icons';

import { cn } from '@/lib/utils';

/**
 * Radix Accordion, replacing a consumer's hand-rolled accordion.
 *
 * The hand-rolled version was careful and its keyboard handling was correct, but
 * it re-implemented roving focus, wrapping, Home/End and the trigger/panel ARIA
 * by hand. Radix ships all of it, wraps ArrowUp/ArrowDown at both ends (the
 * behaviour the original chose deliberately, on the grounds that a dead-ended
 * ArrowDown on a 4–8 row FAQ reads as a broken key) and skips disabled rows in
 * the collection.
 *
 * ## What carried over from the original, and why
 *
 *  - **`headingLevel`** (2 | 3 | 4, default 3) on `AccordionTrigger`. Radix's
 *    `Accordion.Header` is a hard-coded `<h3>`; the host page decides where the
 *    accordion sits in its outline, and a heading that lies about its depth is
 *    worse than no heading. Kept via `Header asChild`.
 *  - **`min-h-11`** on the trigger , the 44px touch minimum, even if a title
 *    renders unusually short.
 *  - **`border-0`, `bg-transparent`, own padding, own radius** on the trigger.
 *    These look redundant and are not: the dashboard app's `styles.css` styles bare
 *    `button` (border, background, radius, padding) in Tailwind's `components`
 *    layer. Utilities win that cascade, but only for properties one is actually
 *    written for. Drop `rounded-none` and the row grows a 6px-rounded
 *    panel-coloured box inside the container.
 *  - **Collapsing the open row.** `type="single"` defaults `collapsible` to
 *    `true` here, so "nothing open" stays a reachable state as it was before.
 *    Radix's own default is `false`, which would silently make the first-opened
 *    row permanent.
 *
 * ## What changed, and the one thing that did NOT carry over
 *
 *  - The original computed a per-row `border-radius` shorthand (`triggerRadius`)
 *    so only the first row's top and last row's bottom followed the container's
 *    9px *inner* radius. That machinery is gone: `overflow-hidden` on the root
 *    clips at the padding box, whose corner radius is already the outer radius
 *    minus the border width , so the hover band follows the same 9px curve with
 *    no arithmetic and no open/closed special case.
 *  - **Collapsed panels no longer stay in the DOM.** The original kept them
 *    mounted (with `aria-hidden` + `inert`) because that landing page is the
 *    product's only crawlable surface and every FAQ answer had to be in the
 *    served HTML. Radix unmounts closed content. `forceMount` does not
 *    substitute: with it, Radix's Collapsible never applies `hidden` and always
 *    renders children, so the panel does not collapse at all and
 *    `--radix-accordion-content-height` is never set , the caller would have to
 *    re-author both the collapse and the `aria-hidden`/`inert` pair. A crawlable
 *    FAQ is therefore a page-level requirement this shared component
 *    deliberately does not carry.
 */
type AccordionProps = React.ComponentProps<typeof AccordionPrimitive.Root> & {
  /**
   * The container's material. `glass` (the default since 2026-08-06) is Tier S,
   * the same `.glass-surface` a `Card` uses. `panel` is the opaque fallback,
   * and `none` is for an accordion nested inside a Card or a Dialog , glass
   * inside glass composites ~2/255 apart and both layers disappear.
   *
   * Note the border comes from `.glass-surface` itself in the `glass` case, so
   * no `border-border` utility is applied: a `border-*` utility beats the
   * components layer and would silently replace the material's own edge.
   */
  surface?: 'glass' | 'panel' | 'none';
};

function Accordion({ className, surface = 'glass', ...props }: AccordionProps) {
  // `collapsible` exists only on the single-value variant, hence the narrow. The
  // spread comes second so an explicit `collapsible={false}` still wins.
  // Cast to Root's own props, not AccordionProps , `surface` is ours and is
  // already destructured out above; it must not reach the Radix element.
  const rootProps = (
    props.type === 'single' ? { collapsible: true, ...props } : props
  ) as React.ComponentProps<typeof AccordionPrimitive.Root>;

  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      className={cn(
        'divide-y divide-border/60 overflow-hidden rounded-xl',
        surface === 'glass' && 'glass-surface',
        surface === 'panel' && 'border border-border bg-panel shadow-sm',
        className,
      )}
      {...rootProps}
    />
  );
}

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

export interface AccordionTriggerProps extends React.ComponentProps<
  typeof AccordionPrimitive.Trigger
> {
  /**
   * Heading level wrapping the trigger, so the accordion slots into the host
   * page's outline. Default 3.
   */
  headingLevel?: 2 | 3 | 4;
}

/**
 * The heading needs `m-0`: the dashboard app's `styles.css` gives h1–h3 a 0.5rem
 * bottom margin, which would push every row off its divider. `flex` on the heading is
 * what lets the trigger fill the row , a heading is `display: block` and its
 * button child would otherwise shrink to its text.
 *
 * The chevron rotates via a `group-data-[state=open]/accordion-trigger:` selector
 * rather than a descendant selector on the trigger, so it is the chevron and only
 * the chevron that turns , a `[&>svg]` rule would also spin any icon a caller
 * puts inside the title.
 */
function AccordionTrigger({
  className,
  children,
  headingLevel = 3,
  ...props
}: AccordionTriggerProps) {
  const Heading = `h${headingLevel}` as 'h2' | 'h3' | 'h4';

  return (
    <AccordionPrimitive.Header asChild>
      <Heading className="m-0 flex">
        <AccordionPrimitive.Trigger
          data-slot="accordion-trigger"
          className={cn(
            'group/accordion-trigger',
            'flex min-h-11 flex-1 cursor-pointer items-center justify-between gap-4',
            // See the docblock: every one of these four is load-bearing against
            // the consumer's bare-`button` rule in the components layer.
            'rounded-none border-0 bg-transparent px-5 py-4',
            'text-start text-[15px] leading-snug font-semibold text-fg',
            'transition-colors duration-micro ease-out hover:bg-highlight',
            'outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
            'disabled:cursor-not-allowed disabled:opacity-50',
            'sm:px-6 sm:py-[1.15rem]',
            className,
          )}
          {...props}
        >
          {children}
          <ChevronDownIcon
            size={18}
            data-slot="accordion-chevron"
            className={cn(
              'shrink-0 text-muted-foreground',
              'transition-[transform,color] duration-micro ease-out',
              // `text-link` (--primary-text), not `text-primary`: --primary is
              // 3.90:1 on the cream page, which clears the 3:1 non-text bar only
              // just. --primary-text has headroom in both themes and is the same
              // hue family.
              'group-data-[state=open]/accordion-trigger:rotate-180',
              'group-data-[state=open]/accordion-trigger:text-link',
            )}
          />
        </AccordionPrimitive.Trigger>
      </Heading>
    </AccordionPrimitive.Header>
  );
}

/**
 * ## The one sanctioned height animation in the system
 *
 * `animate-accordion-down` / `animate-accordion-up` (from `tw-animate-css`, which
 * `@velobitsio/tokens/theme.css` already imports) interpolate `height` from `0` to
 * `var(--radix-accordion-content-height)`.
 *
 * Animating height is normally banned here because it invalidates layout on every
 * frame. This case is the exception because Radix **measures** the content once,
 * in a layout effect, and publishes the result as that CSS variable , so the
 * keyframes run between two known pixel values. The pathological version is
 * `height: auto` in a transition, which either does not animate at all or forces
 * the engine to re-resolve intrinsic size each frame; and `max-height: 9999px`,
 * which animates a wrong distance and so runs at the wrong speed for every
 * content length. Neither of those is what this is.
 *
 * `overflow-hidden` is what makes the clip work and must stay on this element.
 *
 * ## `className` lands on the inner body, not on the animated element
 *
 * Deliberate, and it matches shadcn's Accordion. The animated element's own
 * classes are load-bearing (`overflow-hidden` plus the two keyframe utilities),
 * and padding there would be height the `0` keyframe cannot remove , leaving a
 * collapsed panel roughly 32px tall. Putting the caller's classes on the body
 * makes the common case (`className="px-6 pb-6"`) correct instead of a bug.
 *
 * `max-w-[70ch]` caps the measure: a 90-character line of 13.5px muted text is
 * unreadable, and the FAQ container is as wide as the page's content grid.
 * Override it with `className="max-w-none"` , `cn` is twMerge-based, so it wins.
 */
function AccordionContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
  return (
    <AccordionPrimitive.Content
      data-slot="accordion-content"
      className={cn(
        'overflow-hidden',
        'data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up',
      )}
      {...props}
    >
      <div
        data-slot="accordion-content-body"
        className={cn(
          'max-w-[70ch] px-5 pb-4 text-[13.5px] leading-relaxed text-muted-foreground sm:px-6 sm:pb-[1.15rem]',
          className,
        )}
      >
        {children}
      </div>
    </AccordionPrimitive.Content>
  );
}

export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

More from velobits

Browse all components in this library →