Skip to content

Accordion

ui

Preview

Is it accessible?
Yes. Native disclosure widget pattern.
Is it styled?
Yes. Matches shadcn design tokens.
<div class="${accordionClass()} max-w-md">
  <details name="ex-accordion" class=${accordionItemClass()}>
    <summary class=${accordionTriggerClass()}>
      <span>Is it accessible?</span>
      <svg class="size-4 shrink-0 transition-transform group-open:rotate-180"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
           stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m6 9 6 6 6-6"/>
      </svg>
    </summary>
    <div class=${accordionContentClass()}>Yes. Native disclosure widget pattern.</div>
  </details>
  <details name="ex-accordion" class=${accordionItemClass()}>
    <summary class=${accordionTriggerClass()}>
      <span>Is it styled?</span>
      <svg class="size-4 shrink-0 transition-transform group-open:rotate-180"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
           stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m6 9 6 6 6-6"/>
      </svg>
    </summary>
    <div class=${accordionContentClass()}>Yes. Matches shadcn design tokens.</div>
  </details>
</div>

Installation

webjs ui add accordion

API Reference

Parts

Name Description
<details name="..."> One row. Items sharing the same name attribute form an exclusive group (Radix type="single"); omit name for independent items (type="multiple").
<summary> Clickable header inside a <details>. Apply accordionTriggerClass() and hide the native ::marker.
accordionClass() Wrapper around the column of <details> rows.
accordionItemClass() Applied to each <details>. Adds `group` so the trigger chevron can rotate on `group-open:`.
accordionTriggerClass() Applied to each <summary>. Hides the native disclosure marker.
accordionContentClass() Applied to the content wrapper inside <details>.

Props

Prop Type Default Description
open boolean (HTML attribute on <details>) absent Set on a <details> to render it expanded on first paint.
name string (HTML attribute on <details>) absent Items sharing a name form an exclusive group (only one open at a time).
disabled boolean (argument to accordionTriggerClass) false Visual disabled state on the <summary>. Combine with the standard `inert` attribute on the <details> for full keyboard prevention, native <details> has no `disabled` attribute.
orientation="horizontal" , not supported Native <details>/<summary> is always vertical (summary above, content below). For a horizontal disclosure, use <ui-tabs> instead.

Source: components/ui/accordion.ts

/**
 * Accordion: vertical collapsible list built on native <details>/<summary>.
 *
 * Tier-1 (no custom element). Exclusive open behaviour (Radix's
 * `type="single"`) comes from giving every <details> the same
 * `name="..."` attribute. Independent open (`type="multiple"`) is the
 * default when `name` is omitted. Both modes give `collapsible` for
 * free: clicking the open <summary> closes it.
 *
 * shadcn parity:
 *   <Accordion type="single" collapsible> → <div class=${accordionClass()}> wrapping
 *                                            <details name="..."> items
 *   <Accordion type="multiple">           → same, omit `name`
 *   <AccordionItem>                       → <details class=${accordionItemClass()}>
 *   <AccordionTrigger>                    → <summary class=${accordionTriggerClass()}>
 *   <AccordionContent>                    → <div class=${accordionContentClass()}>
 *
 * Initial state: add `open` on the <details> that should render expanded
 * on first paint. Programmatic toggling: `el.open = true | false`.
 *
 * `<details name="X">` is the platform's exclusive-accordion primitive:
 * Chrome 120+, Safari 17.2+, Firefox 130+. Migrated from the prior
 * <ui-accordion> custom element set.
 *
 * Design tokens used: --border, --ring, --foreground.
 *
 * @example
 * ```html
 * <div class=${accordionClass()}>
 *   <details name="faq" class=${accordionItemClass()} open>
 *     <summary class=${accordionTriggerClass()}>
 *       <span>Is it accessible?</span>
 *       <svg class="size-4 shrink-0 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
 *     </summary>
 *     <div class=${accordionContentClass()}>Yes, it uses a native disclosure widget.</div>
 *   </details>
 *   <details name="faq" class=${accordionItemClass()}>
 *     <summary class=${accordionTriggerClass()}>
 *       <span>Is it styled?</span>
 *       <svg class="size-4 shrink-0 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
 *     </summary>
 *     <div class=${accordionContentClass()}>Yes, with the shadcn design tokens.</div>
 *   </details>
 * </div>
 * ```
 */

/** Root wrapper. Holds the column-of-items rhythm; no display: rules. */
export const accordionClass = (): string => 'w-full';

/**
 * Item: each <details>. The `group` utility lets the trigger's chevron
 * rotate on open via `group-open:rotate-180`. `last:border-b-0` cleans
 * the trailing edge.
 */
export const accordionItemClass = (): string => 'group border-b last:border-b-0';

/**
 * Trigger: applied to <summary>. Hides the native disclosure triangle so
 * authors can compose their own chevron icon (typical pattern: trailing
 * lucide chevron with `group-open:rotate-180`).
 *
 * `disabled: true` returns the visual disabled state (greyed out,
 * not-allowed cursor, no pointer events). For true keyboard prevention
 *, the native disabled-disclosure-widget gap, add the standard
 * `inert` attribute to the <details> element. shadcn's React `disabled`
 * prop combines both; native HTML has no `disabled` on <details>.
 */
export const accordionTriggerClass = (opts: { disabled?: boolean } = {}): string => {
  // Hover dims the label instead of underlining it. An underline reads as a
  // link affordance, and this is a disclosure control on a <summary>, not a
  // navigation. A slight drop in text weight reads as "this responds to you"
  // without borrowing another control's vocabulary, and it pairs with the
  // chevron that rotates on open. (shadcn's React accordion underlines here;
  // this is a deliberate divergence in presentation only, so the API parity
  // that matters, variant and size names and the data attributes, is
  // untouched.)
  //
  // The label carries `text-foreground` explicitly rather than inheriting it,
  // because the hover target has to be a known starting colour: inheriting an
  // author's own colour and then dimming to 80% of the THEME foreground would
  // shift the hue, not just the weight.
  const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-4 py-4 text-left text-sm font-medium text-foreground outline-none transition-all hover:text-foreground/80 focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden';
  if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`;
  return base;
};

/**
 * Content: <details> hides this entirely when not [open], so all we add
 * is the typography rhythm matching shadcn (bottom padding, small text).
 */
export const accordionContentClass = (): string => 'pb-4 text-sm';