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 accordionAPI 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';