Skip to content
Brok UI

Loading…

No results

Disclosure

Open source Core

Progressive disclosure — a stacked accordion, a single collapsible section and a clamp-and-reveal show-more.

Version
v1.3.1
Stability
stable
License
MIT
Related
Accordion
Show More

Collapsible — An interactive section that expands and collapses its content.

Preview

Avery starred 3 repositories

brokui/brok
laravel/framework
alpinejs/alpine
previews.components.collapsible.default.blade.php Blade
{{-- The canonical shape: a header row with a chevron toggle, the first item
     always visible, the rest folded away. The trigger's `indicator` gives the
     affordance; the label reads differently open and closed. --}}
<x-ui.collapsible class="w-full max-w-sm space-y-2">
    <div class="flex items-center justify-between gap-2 px-1">
        <p class="text-sm font-medium text-foreground">{{ __('Avery starred 3 repositories') }}</p>
        <x-ui.collapsible.trigger indicator="chevron" class="size-8 justify-center text-muted-foreground hover:bg-accent hover:text-accent-foreground">
            <span class="sr-only" x-text="open ? @js(__('Show fewer')) : @js(__('Show all'))"></span>
        </x-ui.collapsible.trigger>
    </div>
    <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">brokui/brok</div>
    <x-ui.collapsible.content>
        {{-- The content is a clip box; spacing between folded rows lives inside it. --}}
        <div class="space-y-2">
            <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">laravel/framework</div>
            <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">alpinejs/alpine</div>
        </div>
    </x-ui.collapsible.content>
</x-ui.collapsible>

Installation

terminal
php artisan ui:add collapsible

Note

This component ships an Alpine behavior module at resources/js/ui/collapsible.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './collapsible.js';

Registry contract

php artisan ui:add collapsible writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/collapsible.blade.php
  • blade resources/views/components/ui/collapsible/trigger.blade.php
  • blade resources/views/components/ui/collapsible/content.blade.php
  • js resources/js/ui/collapsible.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

collapsible.md
# Brok UI: Collapsible (`collapsible`)

An interactive section that expands and collapses its content.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add collapsible
```

## Usage

```blade
{{-- The canonical shape: a header row with a chevron toggle, the first item
     always visible, the rest folded away. The trigger's `indicator` gives the
     affordance; the label reads differently open and closed. --}}
<x-ui.collapsible class="w-full max-w-sm space-y-2">
    <div class="flex items-center justify-between gap-2 px-1">
        <p class="text-sm font-medium text-foreground">{{ __('Avery starred 3 repositories') }}</p>
        <x-ui.collapsible.trigger indicator="chevron" class="size-8 justify-center text-muted-foreground hover:bg-accent hover:text-accent-foreground">
            <span class="sr-only" x-text="open ? @js(__('Show fewer')) : @js(__('Show all'))"></span>
        </x-ui.collapsible.trigger>
    </div>
    <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">brokui/brok</div>
    <x-ui.collapsible.content>
        {{-- The content is a clip box; spacing between folded rows lives inside it. --}}
        <div class="space-y-2">
            <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">laravel/framework</div>
            <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">alpinejs/alpine</div>
        </div>
    </x-ui.collapsible.content>
</x-ui.collapsible>
```

## Props

- `open` (bool, default `false`) — Initial open state; modelable via x-model or wire:model.
- `indicator` (string, default `none`) — Declared by @props in the registry Blade source.
- `indicatorSide` (string, default `end`) — Declared by @props in the registry Blade source.

## Use when

- Use for secondary or optional content, especially when users usually inspect one short section at a time.
- Hiding a single block of secondary content behind a trigger, such as advanced options or extra details.
- Needing a modelable open state that can bind with x-model or wire:model.

## Avoid when

- Do not hide required workflow content or comparison-heavy information because disclosure controls reduce visibility and increase interaction cost.
- Grouping several sections that share single- or multiple-open behaviour; use accordion instead.
- Truncating a long block of the same content rather than hiding a distinct section; use show-more.

## Anti-patterns

- Hiding required workflow content
- Using accordions for cross-section comparison

## Rules

- Use the `<brok:collapsible>` tag (or `<x-ui.collapsible>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/collapsible
- Registry JSON (files, props, contract): https://brokui.dev/r/open/collapsible.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

bordered.blade.php Blade
<x-ui.collapsible class="w-full max-w-md rounded-lg border border-border">
    <x-ui.collapsible.trigger indicator="chevron" class="w-full rounded-lg px-4 py-3 text-foreground hover:bg-muted/50">
        <span class="flex-1 text-start font-medium">{{ __('Billing address') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="border-t border-border px-4 py-3 text-muted-foreground">
        <p>{{ __('A bordered shell reads as a single, self-contained disclosure tile.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
card.blade.php Blade
<x-ui.collapsible class="w-full max-w-md">
    <x-ui.card class="overflow-hidden">
        <x-ui.collapsible.trigger indicator="chevron" class="w-full px-6 py-4 text-foreground hover:bg-muted/50">
            <span class="flex-1 text-start font-semibold">{{ __('Project settings') }}</span>
        </x-ui.collapsible.trigger>
        <x-ui.collapsible.content>
            <div class="border-t border-border px-6 py-4 text-muted-foreground">
                <p>{{ __('Wrapping the trigger and content in a card keeps the disclosure visually contained.') }}</p>
            </div>
        </x-ui.collapsible.content>
    </x-ui.card>
</x-ui.collapsible>
chevron.blade.php Blade
<x-ui.collapsible class="w-full max-w-md">
    <x-ui.collapsible.trigger indicator="chevron" class="w-full px-4 py-2 text-foreground hover:bg-muted">
        <span class="flex-1 text-start">{{ __('Show details') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="px-4 py-2 text-muted-foreground">
        <p>{{ __('The chevron rotates to point up while the section is open.') }}</p>
        <p>{{ __('It collapses again when you toggle the trigger.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
controlled.blade.php Blade
<x-ui.collapsible class="w-full max-w-md space-y-3">
    <div class="flex items-center justify-between">
        <span class="text-sm text-muted-foreground" aria-live="polite">
            <span x-show="open" x-cloak>{{ __('Panel is open') }}</span>
            <span x-show="!open">{{ __('Panel is closed') }}</span>
        </span>
        <x-ui.button variant="outline" size="sm" type="button" @click="toggle()">
            <span x-show="!open">{{ __('Open') }}</span>
            <span x-show="open" x-cloak>{{ __('Close') }}</span>
        </x-ui.button>
    </div>
    <x-ui.collapsible.trigger indicator="chevron" class="w-full rounded-md border border-border px-4 py-2 text-foreground hover:bg-muted/50">
        <span class="flex-1 text-start">{{ __('Notification preferences') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="rounded-md border border-border px-4 py-3 text-muted-foreground">
        <p>{{ __('State lives on the root, so an outside control and the trigger drive the same panel. setOpen() also emits a collapsible-change event for listeners.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
default-open.blade.php Blade
<x-ui.collapsible :open="true" class="w-full max-w-md">
    <x-ui.collapsible.trigger indicator="chevron" class="w-full px-4 py-2 text-foreground hover:bg-muted">
        <span class="flex-1 text-start">{{ __('Shipping information') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="px-4 py-2 text-muted-foreground">
        <p>{{ __('This section starts expanded via the open prop, then toggles like any other.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
ghost.blade.php Blade
<x-ui.collapsible class="w-full max-w-md">
    <x-ui.collapsible.trigger indicator="chevron" class="w-full px-2 py-2 text-muted-foreground hover:text-foreground">
        <span class="flex-1 text-start">{{ __('Advanced options') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="px-2 py-2 text-muted-foreground">
        <p>{{ __('A borderless, low-emphasis trigger suits secondary or inline disclosures.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
inline.blade.php Blade
{{-- An inline disclosure: the trigger is the heading and its label flips. --}}
<x-ui.collapsible class="w-full max-w-md rounded-md border border-border">
    <x-ui.collapsible.trigger indicator="chevron" class="w-full px-4 py-3 text-foreground hover:bg-muted">
        <span x-text="open ? @js(__('Hide details')) : @js(__('Show details'))">{{ __('Show details') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="border-t border-border px-4 py-3 text-muted-foreground">
        <p>{{ __('This content is hidden until you expand the section.') }}</p>
        <p class="mt-2">{{ __('It collapses again when you toggle the trigger.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
plus-minus.blade.php Blade
<x-ui.collapsible class="w-full max-w-md">
    <x-ui.collapsible.trigger indicator="plus-minus" indicatorSide="start" class="w-full px-4 py-2 text-foreground hover:bg-muted">
        <span class="flex-1 text-start">{{ __('What is included?') }}</span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="ps-10 pe-4 py-2 text-muted-foreground">
        <p>{{ __('A leading plus icon loses its vertical stroke to read as a minus once open.') }}</p>
    </x-ui.collapsible.content>
</x-ui.collapsible>
show-more.blade.php Blade
<x-ui.collapsible class="w-full max-w-md space-y-1 text-sm">
    <p class="text-muted-foreground">{{ __('A collapsible shows and hides a section of content behind an accessible trigger. The trigger keeps its aria-expanded state in sync with the panel.') }}</p>
    <x-ui.collapsible.content class="text-muted-foreground">
        <p>{{ __('When expanded, the rest of the passage is revealed below, and the trigger label flips to offer collapsing it again.') }}</p>
    </x-ui.collapsible.content>
    <x-ui.collapsible.trigger class="px-0 py-1 text-primary hover:underline">
        <span x-show="!open">{{ __('Read more') }}</span>
        <span x-show="open" x-cloak>{{ __('Show less') }}</span>
    </x-ui.collapsible.trigger>
</x-ui.collapsible>
with-count.blade.php Blade
<x-ui.collapsible class="w-full max-w-md rounded-lg border border-border">
    <x-ui.collapsible.trigger indicator="chevron" class="w-full rounded-lg px-4 py-3 text-foreground hover:bg-muted/50">
        <span class="flex flex-1 items-center gap-2 text-start font-medium">
            {{ __('Team members') }}
            <x-ui.badge variant="secondary">3</x-ui.badge>
        </span>
    </x-ui.collapsible.trigger>
    <x-ui.collapsible.content class="border-t border-border px-4 py-3 text-muted-foreground">
        <ul class="space-y-1">
            <li>{{ __('Avery Quinn') }}</li>
            <li>{{ __('Jordan Lee') }}</li>
            <li>{{ __('Sam Rivera') }}</li>
        </ul>
    </x-ui.collapsible.content>
</x-ui.collapsible>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
open bool false Initial open state; modelable via x-model or wire:model.
indicator string none Declared by @props in the registry Blade source.
indicatorSide string end Declared by @props in the registry Blade source.

Slots

  • default — Compositional trigger and content parts.
  • x-ui.collapsible.trigger — Installed subcomponent from the registry item.
  • x-ui.collapsible.content — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

collapsible collapsible-content collapsible-trigger

Behavior

  • Open state lives on the root, so a trigger and its content pair stay in sync and multiple instances on one page work independently.
  • The root scopes a shared id so the trigger's aria-controls and the content's id always resolve to the same value, even with many instances.
  • The trigger accepts an optional visual indicator (none, chevron, or plus-minus) with no change to the open/close behaviour.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Progressive disclosure

Reveal optional or secondary content on demand.

Use when

  • Use for secondary or optional content, especially when users usually inspect one short section at a time.
  • Hiding a single block of secondary content behind a trigger, such as advanced options or extra details.
  • Needing a modelable open state that can bind with x-model or wire:model.

Avoid when

  • Do not hide required workflow content or comparison-heavy information because disclosure controls reduce visibility and increase interaction cost.
  • Grouping several sections that share single- or multiple-open behaviour; use accordion instead.
  • Truncating a long block of the same content rather than hiding a distinct section; use show-more.

Use instead

  • Visible sections
  • Tabs for a few long peer sections

Anti-patterns

  • Hiding required workflow content
  • Using accordions for cross-section comparison
Anatomy
root collapsible-content collapsible-trigger
Theming hooks
collapsible collapsible-content collapsible-trigger

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • The trigger is a real button with aria-expanded and is linked to its content via aria-controls, so assistive tech tracks the open state correctly.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="collapsible-{{ $record->id }}">
    {{-- The canonical shape: a header row with a chevron toggle, the first item
         always visible, the rest folded away. The trigger's `indicator` gives the
         affordance; the label reads differently open and closed. --}}
    <x-ui.collapsible class="w-full max-w-sm space-y-2">
        <div class="flex items-center justify-between gap-2 px-1">
            <p class="text-sm font-medium text-foreground">{{ __('Avery starred 3 repositories') }}</p>
            <x-ui.collapsible.trigger indicator="chevron" class="size-8 justify-center text-muted-foreground hover:bg-accent hover:text-accent-foreground">
                <span class="sr-only" x-text="open ? @js(__('Show fewer')) : @js(__('Show all'))"></span>
            </x-ui.collapsible.trigger>
        </div>
        <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">brokui/brok</div>
        <x-ui.collapsible.content>
            {{-- The content is a clip box; spacing between folded rows lives inside it. --}}
            <div class="space-y-2">
                <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">laravel/framework</div>
                <div class="rounded-md border border-border px-4 py-2 font-mono text-sm text-foreground">alpinejs/alpine</div>
            </div>
        </x-ui.collapsible.content>
    </x-ui.collapsible>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:collapsible
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/collapsible.blade.php Blade
@props(['open' => false])

{{--
    Collapsible (spec §19 behavior layer + §17 accessibility).
    An interactive section that expands and collapses its content. Open state
    lives on the root via the `uiCollapsible` Alpine component so a trigger and
    content pair stay in sync. Multiple instances on one page work independently.

    The root scopes a shared `x-id` so the trigger's `aria-controls` and the
    content's `id` always resolve to the same value, even with many instances.
    The trigger accepts an optional visual `indicator` (none | chevron |
    plus-minus); behaviour and the open/close API are unchanged.
--}}
<div x-data="uiCollapsible({ open: @js((bool) $open) })" x-modelable="open" x-id="['collapsible-content']" data-slot="collapsible" {{ $attributes->merge(['class' => '']) }}>{{ $slot }}</div>
resources/views/components/ui/collapsible/trigger.blade.php Blade
@props([
    'indicator' => 'none',
    'indicatorSide' => 'end',
])

@php
    // Visual-only options; behaviour (toggle/open state) is unchanged.
    //   - indicator:     none | chevron | plus-minus
    //   - indicatorSide: end | start  (logical, mirrors under dir="rtl")
    $indicatorFirst = $indicatorSide === 'start';
@endphp

<button
    type="button"
    data-slot="collapsible-trigger"
    :aria-expanded="open"
    x-bind:aria-controls="$id('collapsible-content')"
    @click="toggle()"
    {{ $attributes->merge(['class' => 'inline-flex items-center justify-between gap-2 rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring']) }}
>
    @if ($indicator === 'plus-minus')
        {{-- Plus that loses its vertical stroke when open, reading as a minus. --}}
        <svg
            aria-hidden="true"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            class="size-4 shrink-0 text-muted-foreground {{ $indicatorFirst ? 'order-first' : 'order-last' }}"
        >
            <path d="M5 12h14" />
            <path
                d="M12 5v14"
                class="origin-center transition-transform duration-200 motion-reduce:transition-none"
                :class="open && 'scale-y-0'"
            />
        </svg>
    @elseif ($indicator === 'chevron')
        {{-- end-side: down-chevron flips to point up when open.
             start-side: end-pointing chevron (logical; mirrors under RTL)
             that rotates a quarter-turn down when open. --}}
        <svg
            aria-hidden="true"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            class="size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none {{ $indicatorFirst ? 'order-first rtl:-scale-x-100' : 'order-last' }}"
            :class="open && '{{ $indicatorFirst ? 'rotate-90' : 'rotate-180' }}'"
        >
            @if ($indicatorFirst)
                <path d="m9 18 6-6-6-6" />
            @else
                <path d="m6 9 6 6 6-6" />
            @endif
        </svg>
    @endif

    {{ $slot }}
</button>
resources/views/components/ui/collapsible/content.blade.php Blade
<div
    data-slot="collapsible-content"
    role="region"
    x-bind:id="$id('collapsible-content')"
    x-cloak
    {{-- inert (not just aria-hidden): the closed panel leaves the a11y tree AND the tab order. --}}
    x-bind:inert="! open"
    {{-- Grid rows 0fr → 1fr animate height without JS measurement; the panel stays rendered. --}}
    :class="open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'"
    {{ $attributes->merge(['class' => 'grid text-sm transition-[grid-template-rows,opacity] duration-base ease-standard motion-reduce:transition-none']) }}
>
    <div class="min-h-0 overflow-hidden">{{ $slot }}</div>
</div>
resources/js/ui/collapsible.js JS
/**
 * Collapsible behavior (spec §19 behavior layer).
 *
 * Open state is held on the root component so a trigger and content pair stay
 * in sync. Each instance is independent, so multiple collapsibles on one page
 * do not interfere with one another.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiCollapsible', (config = {}) => ({
        open: config.open ?? false,

        toggle() {
            this.setOpen(!this.open);
        },

        /**
         * Set open state explicitly and notify listeners. Dispatches a
         * `collapsible-change` event ({ detail: { open } }) on the root so
         * controlled scenarios (an external label, count, or toolbar button)
         * can react without reaching into Alpine internals.
         */
        setOpen(value) {
            this.open = !!value;
            this.$dispatch('collapsible-change', { open: this.open });
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.3.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0