Skip to content

Collapsible

ui

Preview

Show / Hide details
Hidden content revealed when the disclosure widget opens. Real content, real DOM.
<details class="${collapsibleClass()} max-w-md">
  <summary class="${collapsibleTriggerClass()} ${buttonClass({ variant: 'outline' })}">
    Show / Hide details
    <svg class="size-4 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="${collapsibleContentClass()} mt-3 rounded-md border p-4">
    Hidden content revealed when the disclosure widget opens. Real content, real DOM.
  </div>
</details>

Installation

webjs ui add collapsible

API Reference

Parts

Name Description
<details> Root, owns the open state natively. Click or Enter on the <summary> toggles.
<summary> Clickable header. Apply collapsibleTriggerClass() so the native disclosure triangle is hidden and the typography matches.
collapsibleClass() Applied to the <details>. Marks the disclosure as a `group` so descendants react to `group-open:`.
collapsibleTriggerClass() Applied to <summary>. Hides the native ::marker.
collapsibleContentClass() Typography for the content wrapper inside the <details>.

Props

Prop Type Default Description
open boolean (HTML attribute on <details>) absent Initial state. After mount, toggle via `el.open = true | false` or by clicking the summary.
disabled boolean (argument to collapsibleTriggerClass) 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.

Source: components/ui/collapsible.ts

/**
 * Collapsible: togglable content panel built on native <details>/<summary>.
 *
 * Tier-1 (no custom element). The browser handles open/close state,
 * keyboard activation (Enter / Space on <summary>), focus management,
 * and disclosure-widget accessibility, nothing to ship in JS.
 *
 * shadcn parity:
 *   Collapsible         → <details class=${collapsibleClass()}>
 *   CollapsibleTrigger  → <summary class=${collapsibleTriggerClass()}>
 *   CollapsibleContent  → <div class=${collapsibleContentClass()}>
 *
 * Initial state: add `open` on <details> to render expanded on first
 * paint. Programmatic toggling: `el.open = true | false`. Migrated from
 * the prior <ui-collapsible> custom element; the trigger class hides
 * the native disclosure marker so callers can render their own chevron.
 *
 * Design tokens used: --border, --ring, --foreground.
 *
 * @example
 * ```html
 * <details class=${collapsibleClass()}>
 *   <summary class=${collapsibleTriggerClass()}>
 *     Show details
 *     <svg class="size-4 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=${collapsibleContentClass()}>
 *     Hidden until <summary> is clicked, Enter / Space pressed, or the
 *     <details> element's `open` property is set via JS.
 *   </div>
 * </details>
 * ```
 */

/**
 * Root: marks the disclosure widget as a `group` so descendants can use
 * Tailwind's `group-open:` variant to react to the `[open]` attribute
 * (which `<details>` sets natively). No visual styling of its own.
 */
export const collapsibleClass = (): string => 'group';

/**
 * Trigger: hides the native ::marker (and the WebKit -details-marker shim)
 * so the disclosure triangle does not appear; callers wrap their own
 * chevron icon and rotate it on open via `group-open:rotate-180`.
 *
 * `disabled: true` returns the visual disabled state. Native <details>
 * has no `disabled` attribute, so for full keyboard prevention add the
 * standard `inert` attribute on the <details> element. shadcn's React
 * `disabled` prop combines both visual and behavior; we split them.
 */
export const collapsibleTriggerClass = (opts: { disabled?: boolean } = {}): string => {
  const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md text-sm font-medium outline-none transition-all 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> already hides children other than <summary> when
 * not [open], so this is purely typographic spacing. No display rules.
 */
export const collapsibleContentClass = (): string => 'text-sm';