Sidebar
A collapsible application sidebar with persistent state, accessible navigation, and an optional draggable resize handle (clamped, localStorage-persisted width).
Preview
Dashboard
Fold the sidebar from the trigger; the rail keeps every destination as a centred icon with its name as a title.
<div class="flex h-[32rem] w-full overflow-hidden rounded-lg border border-border" data-surface="admin">
<x-ui.sidebar>
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Acme Inc') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.group-label>{{ __('Platform') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Dashboard')"><svg 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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Inbox')" trailing="12"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Team')"><svg 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"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Analytics')"><svg 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"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
<x-ui.sidebar.group collapsible>
<x-ui.sidebar.group-label collapsible>{{ __('Projects') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Projects')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Website relaunch')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
<x-ui.sidebar.menu-sub>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Design')" />
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Content')" />
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu-sub>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Mobile app')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
<x-ui.sidebar.footer>
<x-ui.sidebar.menu-button href="#" :label="__('Settings')"><svg 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"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.footer>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<h2 class="text-lg font-semibold text-foreground">{{ __('Dashboard') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ __('Fold the sidebar from the trigger; the rail keeps every destination as a centred icon with its name as a title.') }}</p>
</x-ui.sidebar.inset>
</div>
Installation
php artisan ui:add sidebar
Note
This component ships an Alpine behavior module at
resources/js/ui/sidebar.js. Import it once from your bundle so it registers on alpine:init:
import './sidebar.js';
Registry contract
php artisan ui:add sidebar
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.
-
resources/views/components/ui/sidebar.blade.php -
resources/views/components/ui/sidebar/trigger.blade.php -
resources/views/components/ui/sidebar/header.blade.php -
resources/views/components/ui/sidebar/content.blade.php -
resources/views/components/ui/sidebar/footer.blade.php -
resources/views/components/ui/sidebar/group.blade.php -
resources/views/components/ui/sidebar/group-label.blade.php -
resources/views/components/ui/sidebar/menu.blade.php -
resources/views/components/ui/sidebar/menu-item.blade.php -
resources/views/components/ui/sidebar/menu-button.blade.php -
resources/views/components/ui/sidebar/separator.blade.php -
resources/views/components/ui/sidebar/inset.blade.php -
resources/js/ui/sidebar.js -
resources/views/components/ui/sidebar/menu-sub.blade.php -
resources/views/components/ui/sidebar/menu-count.blade.php -
resources/views/components/ui/sidebar/mobile.blade.php -
public/vendor/brok/sidebar-group-boot.js
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.
# Brok UI: Sidebar (`sidebar`)
A collapsible application sidebar with persistent state, accessible navigation, and an optional draggable resize handle (clamped, localStorage-persisted width).
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add sidebar
```
## Usage
```blade
<div class="flex h-[32rem] w-full overflow-hidden rounded-lg border border-border" data-surface="admin">
<x-ui.sidebar>
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Acme Inc') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.group-label>{{ __('Platform') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Dashboard')"><svg 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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Inbox')" trailing="12"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Team')"><svg 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"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Analytics')"><svg 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"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
<x-ui.sidebar.group collapsible>
<x-ui.sidebar.group-label collapsible>{{ __('Projects') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Projects')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Website relaunch')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
<x-ui.sidebar.menu-sub>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Design')" />
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Content')" />
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu-sub>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Mobile app')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
<x-ui.sidebar.footer>
<x-ui.sidebar.menu-button href="#" :label="__('Settings')"><svg 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"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.footer>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<h2 class="text-lg font-semibold text-foreground">{{ __('Dashboard') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ __('Fold the sidebar from the trigger; the rail keeps every destination as a centred icon with its name as a title.') }}</p>
</x-ui.sidebar.inset>
</div>
```
## Props
- `resizable` (bool, default `false`) — Adds a keyboard-operable separator on the inline-end edge that drags the expanded width between minWidth and maxWidth (Arrow keys step 8px, Shift 32px, Home/End to the limits, double-click resets).
- `minWidth` (int, default `200`) — Narrowest expanded width in px when resizable.
- `maxWidth` (int, default `480`) — Widest expanded width in px when resizable.
- `defaultWidth` (int, default `256`) — Expanded width in px before the visitor resizes, and the double-click reset target.
- `storageKey` (string|null, default `null`) — localStorage key that persists the resized width; null keeps the width for the session only. Reads and writes are guarded, so blocked storage never breaks the sidebar.
- `stateKey` (string, default `sidebar:state`) — localStorage key for the expanded/collapsed state. Give each shell its own key (for example brok:app-shell-06:sidebar) when one app renders several shells that should not follow each other.
- `variant` (sidebar|inset, default `sidebar`) — `sidebar` is a card-coloured rail with an inline-end border. `inset` puts the rail straight on the canvas colour with no border, for a shell whose content sits in a raised panel (pair it with `inset.variant`). Unknown values fall back to `sidebar`.
- `disabled` (false|true, default `false`) — Documented catalog control used by the preview workbench.
- `collapsible` (bool, default `false`) — Declared by @props in the registry Blade source.
- `open` (bool, default `true`) — Declared by @props in the registry Blade source.
- `bootSrc` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `nonce` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `label` (string, default `Primary`) — Declared by @props in the registry Blade source.
- `href` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `active` (bool, default `false`) — Declared by @props in the registry Blade source.
- `size` (string, default `md`) — Declared by @props in the registry Blade source.
- `trailing` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `count` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `countExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `countLabel` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `soon` (bool, default `false`) — Declared by @props in the registry Blade source.
- `type` (string, default `button`) — Declared by @props in the registry Blade source.
- `shortcut` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `when` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `nav` (array, default `[]`) — Declared by @props in the registry Blade source.
- `bare` (bool, default `false`) — Declared by @props in the registry Blade source.
- `id` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
## Use when
- Use to orient users and help them move across pages, sections, or commands.
- Building an application shell with persistent side navigation, grouped menu items, and a collapsible rail.
- Needing the sidebar's expanded/collapsed state and width to persist across visits via localStorage.
## Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The navigation is a small, flat set of links; use navigation-menu or a plain nav instead of the full app-shell sidebar.
- The page is a marketing or docs page rather than an authenticated app shell; use table-of-contents or a simple nav.
## Anti-patterns
- Hiding primary wayfinding in novelty interactions
## Rules
- Use the `<brok:sidebar>` tag (or `<x-ui.sidebar>`) 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/sidebar
- Registry JSON (files, props, contract): https://brokui.dev/r/open/sidebar.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Live counts and destinations that are not built yet. The Inbox count
follows an Alpine value (a Livewire property works the same way) and
hides at zero; Reports is marked Soon and is not a link. --}}
<div class="flex h-[28rem] w-full overflow-hidden rounded-lg border border-border" x-data="{ unread: 3 }">
<x-ui.sidebar>
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Workspace') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Inbox')" :count="3" count-expr="unread" :count-label="__('unread')"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Library')" :count="128"><svg 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"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Archive')" :count="0"><svg 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"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Reports')" soon><svg 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"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<div class="flex flex-wrap gap-2">
<x-ui.button size="sm" variant="outline" x-on:click="unread++">{{ __('New item') }}</x-ui.button>
<x-ui.button size="sm" variant="outline" x-on:click="unread = 0">{{ __('Mark all read') }}</x-ui.button>
</div>
</x-ui.sidebar.inset>
</div>
<div class="flex h-[32rem] w-full overflow-hidden rounded-lg border border-border bg-canvas" data-surface="admin">
<x-ui.sidebar variant="inset">
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Acme Inc') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.group-label>{{ __('Platform') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Dashboard')"><svg 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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Inbox')" trailing="12"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Team')"><svg 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"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Analytics')"><svg 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"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
<x-ui.sidebar.group collapsible>
<x-ui.sidebar.group-label collapsible>{{ __('Projects') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Projects')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Website relaunch')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
<x-ui.sidebar.menu-sub>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Design')" />
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Content')" />
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu-sub>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Mobile app')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
<x-ui.sidebar.footer>
<x-ui.sidebar.menu-button href="#" :label="__('Settings')"><svg 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"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.footer>
</x-ui.sidebar>
<x-ui.sidebar.inset variant="inset" class="min-w-0">
<header class="flex h-12 shrink-0 items-center border-b border-border px-6">
<h2 class="text-sm font-semibold text-foreground">{{ __('Dashboard') }}</h2>
</header>
<div class="flex-1 overflow-auto p-6">
<p class="text-sm text-muted-foreground">{{ __('The rail sits on the canvas colour and the content is a raised panel. Collapse the rail with the trigger; the panel keeps its margin and rounded corners.') }}</p>
</div>
</x-ui.sidebar.inset>
</div>
Mobile Bare
{{-- `bare` renders only the trigger and its sheet, so the trigger sits inside an app's own top
bar next to the brand and the page actions. It is md:hidden in an app; `md:inline-flex!`
only keeps it visible at every width on this page. --}}
@php
$nav = [
['heading' => __('Workspace'), 'items' => [
['label' => __('Inbox'), 'href' => '#', 'current' => true],
['label' => __('Projects'), 'href' => '#'],
['label' => __('Reports'), 'href' => '#'],
]],
];
@endphp
<div class="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-background">
<header class="flex h-14 items-center gap-2 border-b border-border px-2">
<x-ui.sidebar.mobile bare :nav="$nav" :label="__('Northwind Studio')" id="sidebar-mobile-bare-demo" class="md:inline-flex!">
<x-slot:brand>
<x-ui.brand as="span" size="sm" :label="__('Northwind Studio')" />
</x-slot:brand>
</x-ui.sidebar.mobile>
<x-ui.brand size="sm" :label="__('Northwind Studio')" class="min-w-0" />
<x-ui.button variant="outline" size="sm" class="ms-auto">{{ __('New project') }}</x-ui.button>
</header>
<p class="p-4 text-sm text-muted-foreground">{{ __('The trigger has no border, padding or label of its own.') }}</p>
</div>
Mobile Brand
{{-- The phone bar with a brand lockup: the `brand` slot replaces the text label in the bar and
in the sheet title, and `label` stays the sheet title's accessible name. The bar is md:hidden
in an app; `md:flex!` only keeps it visible at every width on this page. --}}
@php
$nav = [
['heading' => __('Workspace'), 'items' => [
['label' => __('Inbox'), 'href' => '#', 'current' => true, 'badge' => '4'],
['label' => __('Projects'), 'href' => '#'],
['label' => __('Reports'), 'href' => '#'],
]],
['heading' => __('Account'), 'items' => [
['label' => __('Settings'), 'href' => '#'],
]],
];
@endphp
<div class="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-background">
<x-ui.sidebar.mobile :nav="$nav" :label="__('Northwind Studio')" id="sidebar-mobile-brand-demo" class="md:flex!">
<x-slot:brand>
<x-ui.brand as="span" size="sm" :label="__('Northwind Studio')" />
</x-slot:brand>
</x-ui.sidebar.mobile>
<p class="p-4 text-sm text-muted-foreground">{{ __('Open the menu: the sheet title shows the same lockup.') }}</p>
</div>
Mobile Counts
{{-- The phone sheet shows the live counts the desktop rail shows: a nav
item takes count, countExpr and countLabel, rendered by the same
sidebar.menu-count part as a menu button. The bar is md:hidden in an
app; `md:flex!` only keeps it visible at every width on this page. --}}
@php
$nav = [
['heading' => __('Workspace'), 'items' => [
['label' => __('Inbox'), 'href' => '#', 'current' => true, 'count' => 3, 'countExpr' => 'unread', 'countLabel' => __('unread')],
['label' => __('Library'), 'href' => '#', 'count' => 128],
['label' => __('Archive'), 'href' => '#', 'count' => 0],
]],
];
@endphp
<div class="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-background" x-data="{ unread: 3 }">
<x-ui.sidebar.mobile :nav="$nav" :label="__('Workspace')" id="sidebar-mobile-counts-demo" class="md:flex!" />
<div class="flex flex-wrap gap-2 p-4">
<x-ui.button size="sm" variant="outline" x-on:click="unread++">{{ __('New item') }}</x-ui.button>
<x-ui.button size="sm" variant="outline" x-on:click="unread = 0">{{ __('Mark all read') }}</x-ui.button>
</div>
</div>
Mobile Footer
{{-- The phone sheet with a footer: the `footer` slot sits at the foot of the sheet, under the nav,
here holding the theme switcher. The bar is md:hidden in an app; `md:flex!` only keeps it
visible at every width on this page. --}}
@php
$nav = [
['heading' => __('Workspace'), 'items' => [
['label' => __('Inbox'), 'href' => '#', 'current' => true],
['label' => __('Search'), 'href' => '#'],
]],
];
@endphp
<div class="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-background">
<x-ui.sidebar.mobile :nav="$nav" :label="__('Northwind Studio')" id="sidebar-mobile-footer-demo" class="md:flex!">
<x-slot:footer>
<x-ui.theme-switcher size="sm" class="w-full justify-between" />
</x-slot:footer>
</x-ui.sidebar.mobile>
<p class="p-4 text-sm text-muted-foreground">{{ __('Open the menu: the theme switcher sits at the foot of the sheet.') }}</p>
</div>
Remembered Groups
{{-- Collapsible groups that remember their open state per browser. Fold a group, reload: it stays folded
without a flash. Fold the sidebar: the rail still shows every row's icon. --}}
<div class="flex h-[32rem] w-full overflow-hidden rounded-lg border border-border" data-surface="admin">
<x-ui.sidebar state-key="brok:docs:sidebar-remembered">
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Acme Inc') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group collapsible storage-key="brok:docs:sidebar-group:favorites">
<x-ui.sidebar.group-label collapsible>{{ __('Favorites') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Favorites')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Website relaunch')"><x-ui.icon name="star-filled" /></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Quarterly invoices')"><x-ui.icon name="star-filled" /></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
<x-ui.sidebar.group collapsible :open="false" storage-key="brok:docs:sidebar-group:workspace">
<x-ui.sidebar.group-label collapsible>{{ __('Workspace') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Workspace')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Projects')"><x-ui.icon name="folder" /></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Time')"><x-ui.icon name="clock" /></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Invoices')"><x-ui.icon name="receipt" /></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<h2 class="text-lg font-semibold text-foreground">{{ __('Remembered groups') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ __('Workspace starts folded. Open or fold a group and reload: this browser keeps the choice.') }}</p>
</x-ui.sidebar.inset>
</div>
Shortcuts
{{-- Shortcut chips: `shortcut="mod+k"` shows ⌘ K on macOS and iOS and
Ctrl K elsewhere, with the matching aria-keyshortcuts. The chips hide
while the rail is collapsed and on a touch screen; bind the keys
themselves with x-hotkey or the hotkeys store. --}}
<div class="flex h-[28rem] w-full overflow-hidden rounded-lg border border-border">
<x-ui.sidebar>
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Workspace') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button :label="__('Commands')" shortcut="mod+k"><svg 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"><path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Inbox')" shortcut="mod+1" :count="3" :count-label="__('unread')"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('New capture')" shortcut="shift+n"><svg 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"><path d="M5 12h14"/><path d="M12 5v14"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<p class="text-sm text-muted-foreground">{{ __('Collapse the rail to see the chips give way to the icons.') }}</p>
</x-ui.sidebar.inset>
</div>
{{-- A menu button that submits a form: Sign out is a POST, so the row is a
`<button type="submit">` with the look of every other row. The
`form="…"` attribute points it at a form outside the sidebar; a row
inside a form needs no attribute. This page stops the submit and only
says it happened. --}}
<div class="flex h-[24rem] w-full overflow-hidden rounded-lg border border-border" x-data="{ signedOut: false }">
<x-ui.sidebar>
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Workspace') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Inbox')"><svg 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="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
<x-ui.sidebar.footer>
<x-ui.sidebar.menu-button type="submit" form="sidebar-sign-out-demo" :label="__('Sign out')"><svg 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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.footer>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<x-ui.form id="sidebar-sign-out-demo" action="#" x-on:submit.prevent="signedOut = true" />
<p class="text-sm text-muted-foreground" role="status" x-text="signedOut ? @js(__('The sign-out form was submitted.')) : @js(__('Sign out submits a form with a POST.'))">{{ __('Sign out submits a form with a POST.') }}</p>
</x-ui.sidebar.inset>
</div>
<div class="flex h-[32rem] w-full overflow-hidden rounded-lg border border-border" data-surface="admin">
<x-ui.sidebar resizable :min-width="180" :max-width="360" :default-width="256" storage-key="demo:sidebar-width">
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Acme Inc') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.group-label>{{ __('Platform') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Dashboard')"><svg 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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Inbox')" trailing="12"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Team')"><svg 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"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Analytics')"><svg 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"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
<x-ui.sidebar.group collapsible>
<x-ui.sidebar.group-label collapsible>{{ __('Projects') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Projects')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Website relaunch')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
<x-ui.sidebar.menu-sub>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Design')" />
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Content')" />
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu-sub>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Mobile app')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
<x-ui.sidebar.footer>
<x-ui.sidebar.menu-button href="#" :label="__('Settings')"><svg 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"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.footer>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<h2 class="text-lg font-semibold text-foreground">{{ __('Dashboard') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ __('Drag the edge of the sidebar to resize it, or focus the handle and use the arrow keys. The width is remembered.') }}</p>
</x-ui.sidebar.inset>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| resizable | bool | false | Adds a keyboard-operable separator on the inline-end edge that drags the expanded width between minWidth and maxWidth (Arrow keys step 8px, Shift 32px, Home/End to the limits, double-click resets). |
| minWidth | int | 200 | Narrowest expanded width in px when resizable. |
| maxWidth | int | 480 | Widest expanded width in px when resizable. |
| defaultWidth | int | 256 | Expanded width in px before the visitor resizes, and the double-click reset target. |
| storageKey | string | null | null | localStorage key that persists the resized width; null keeps the width for the session only. Reads and writes are guarded, so blocked storage never breaks the sidebar. |
| stateKey | string | sidebar:state | localStorage key for the expanded/collapsed state. Give each shell its own key (for example brok:app-shell-06:sidebar) when one app renders several shells that should not follow each other. |
| variant | sidebar | inset | sidebar | `sidebar` is a card-coloured rail with an inline-end border. `inset` puts the rail straight on the canvas colour with no border, for a shell whose content sits in a raised panel (pair it with `inset.variant`). Unknown values fall back to `sidebar`. |
| disabled | false | true | false | Documented catalog control used by the preview workbench. |
| collapsible | bool | false | Declared by @props in the registry Blade source. |
| open | bool | true | Declared by @props in the registry Blade source. |
| bootSrc | mixed | null | null | Declared by @props in the registry Blade source. |
| nonce | mixed | null | null | Declared by @props in the registry Blade source. |
| label | string | Primary | Declared by @props in the registry Blade source. |
| href | mixed | null | null | Declared by @props in the registry Blade source. |
| active | bool | false | Declared by @props in the registry Blade source. |
| size | string | md | Declared by @props in the registry Blade source. |
| trailing | mixed | null | null | Declared by @props in the registry Blade source. |
| count | mixed | null | null | Declared by @props in the registry Blade source. |
| countExpr | mixed | null | null | Declared by @props in the registry Blade source. |
| countLabel | mixed | null | null | Declared by @props in the registry Blade source. |
| soon | bool | false | Declared by @props in the registry Blade source. |
| type | string | button | Declared by @props in the registry Blade source. |
| shortcut | mixed | null | null | Declared by @props in the registry Blade source. |
| when | mixed | null | null | Declared by @props in the registry Blade source. |
| nav | array | [] | Declared by @props in the registry Blade source. |
| bare | bool | false | Declared by @props in the registry Blade source. |
| id | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default— Compositional parts: header, content (with groups and menus), footer, and inset.mobile.brand— `<x-ui.sidebar.mobile>`: an optional lockup (for example `<x-ui.brand as="span">`, a monogram and a name) shown in the bar instead of the text label and as the sheet title. `label` stays the sheet title's accessible name as screen-reader text; the lockup is rendered twice, so it must not carry ids.mobile.footer— `<x-ui.sidebar.mobile>`: optional settings at the foot of the sheet, under the nav, for example `<x-ui.theme-switcher size="sm" />` so a phone reaches the theme. Rendered only when the slot has content (comments and Livewire block markers do not count). The theme switcher is a separate item: add it with `ui:add theme-switcher`.x-ui.sidebar.trigger— Installed subcomponent from the registry item.x-ui.sidebar.header— Installed subcomponent from the registry item.x-ui.sidebar.content— Installed subcomponent from the registry item.x-ui.sidebar.footer— Installed subcomponent from the registry item.x-ui.sidebar.group— Installed subcomponent from the registry item.x-ui.sidebar.group-label— Installed subcomponent from the registry item.x-ui.sidebar.menu— Installed subcomponent from the registry item.x-ui.sidebar.menu-item— Installed subcomponent from the registry item.x-ui.sidebar.menu-button— Installed subcomponent from the registry item.x-ui.sidebar.separator— Installed subcomponent from the registry item.x-ui.sidebar.inset— Installed subcomponent from the registry item.x-ui.sidebar.menu-sub— Installed subcomponent from the registry item.x-ui.sidebar.menu-count— Installed subcomponent from the registry item.x-ui.sidebar.mobile— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Both parts carry `data-variant` (`sidebar` or `inset`), so a shell can style its own regions from the chosen variant.
- `<x-ui.sidebar.mobile>` renders the `md:hidden` bar + start-side sheet that stands in for the desktop rail below the medium breakpoint; pass a default slot to render custom content instead of the generated nav list. `bare` drops the bar chrome so the trigger can sit in an existing top bar; the `brand` slot replaces the text label in the bar and the sheet title.
- A folded collapsible group hides its menu only while the sidebar is expanded: in the collapsed icon rail, where group labels are hidden, every row keeps its icon. The fold follows the group's `data-open` and the sidebar's `data-state`, so the server-rendered and remembered state is right before Alpine starts.
- Menu button counts: count / count-expr render a tabular number at the row end (data-slot="sidebar-menu-count"), hidden at zero and while the rail is collapsed; count-expr keeps it live from Alpine or Livewire state. count-label adds visually hidden words after the number.
- Menu button type: without an href the row is a <button type="button">; type="submit" (with an optional form="…" attribute) makes it a submit button for a POST action such as sign out. A disabled or soon submit row is a disabled button, so it never submits.
- Mobile counts: a mobile.nav item with count / countExpr / countLabel renders the same data-slot="sidebar-menu-count" markup as a desktop menu button (through sidebar.menu-count), with the same live expression, zero hiding and screen-reader words. Custom sheet content can also use sidebar.menu-button rows, which read as expanded outside the rail.
- Menu button shortcut: shortcut renders data-slot="sidebar-menu-shortcut" with one <kbd> per key (left to right, also in RTL) after the label, hidden while the rail is collapsed, on a touch screen (pointer: coarse) and on a disabled or soon row. The server names mod from Sec-CH-UA-Platform, then the User-Agent; a page in a shared full-page cache must vary on the platform.
- Menu button disabled / soon: the row stays in the list with its icon and name but is not a working target: a link has no href, aria-disabled="true" and tabindex="-1"; a button is disabled. soon adds an outline Soon badge (data-slot="sidebar-menu-soon") while expanded.
- Persists expanded/collapsed state and exposes data-state for responsive layout styling.
- Trigger updates aria-expanded and the menu remains navigable when collapsed.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, reducedMotion, responsive, rtl, darkMode, localized.
Guidance
Orient users and move between destinations.
Use when
- Use to orient users and help them move across pages, sections, or commands.
- Building an application shell with persistent side navigation, grouped menu items, and a collapsible rail.
- Needing the sidebar's expanded/collapsed state and width to persist across visits via localStorage.
Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The navigation is a small, flat set of links; use navigation-menu or a plain nav instead of the full app-shell sidebar.
- The page is a marketing or docs page rather than an authenticated app shell; use table-of-contents or a simple nav.
Use instead
- Visible links and local navigation
Anti-patterns
- Hiding primary wayfinding in novelty interactions
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- A menu button shortcut is exposed as aria-keyshortcuts on the row (Meta+K on Apple platforms, Control+K elsewhere for mod+k) and the visible keys are aria-hidden, so the row name stays the label.
- The resize handle is keyboard-operable (Arrow keys step 8px, Shift 32px, Home/End to the limits, double-click resets), so width is adjustable without a pointer.
- Below the medium breakpoint the sidebar becomes a sheet with the same accessible trigger and focus-trap behaviour as any other sheet, rather than a cramped rail.
- With a `brand` slot the sheet title reads the `label` text (screen-reader only) and hides the visual lockup from assistive technology, so the dialog keeps a plain-text name.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="sidebar-{{ $record->id }}">
<div class="flex h-[32rem] w-full overflow-hidden rounded-lg border border-border" data-surface="admin">
<x-ui.sidebar>
<x-ui.sidebar.header>
<x-ui.sidebar.trigger />
<span x-show="expanded" class="truncate text-sm font-semibold text-foreground">{{ __('Acme Inc') }}</span>
</x-ui.sidebar.header>
<x-ui.sidebar.content>
<x-ui.sidebar.group>
<x-ui.sidebar.group-label>{{ __('Platform') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :active="true" :label="__('Dashboard')"><svg 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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Inbox')" trailing="12"><svg 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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Team')"><svg 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"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Analytics')"><svg 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"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
<x-ui.sidebar.group collapsible>
<x-ui.sidebar.group-label collapsible>{{ __('Projects') }}</x-ui.sidebar.group-label>
<x-ui.sidebar.menu :label="__('Projects')">
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Website relaunch')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
<x-ui.sidebar.menu-sub>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Design')" />
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" size="sm" :label="__('Content')" />
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu-sub>
</x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-item>
<x-ui.sidebar.menu-button href="#" :label="__('Mobile app')"><svg 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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.menu-item>
</x-ui.sidebar.menu>
</x-ui.sidebar.group>
</x-ui.sidebar.content>
<x-ui.sidebar.footer>
<x-ui.sidebar.menu-button href="#" :label="__('Settings')"><svg 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"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></x-ui.sidebar.menu-button>
</x-ui.sidebar.footer>
</x-ui.sidebar>
<x-ui.sidebar.inset class="p-6">
<h2 class="text-lg font-semibold text-foreground">{{ __('Dashboard') }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ __('Fold the sidebar from the trigger; the rail keeps every destination as a centred icon with its name as a title.') }}</p>
</x-ui.sidebar.inset>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:sidebar
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.
@props([
'resizable' => false,
'minWidth' => 200,
'maxWidth' => 480,
'defaultWidth' => 256,
'storageKey' => null,
// localStorage key for the expanded/collapsed state. Every sidebar shares
// `sidebar:state` by default; give each shell its own key when one app
// renders several shells that should not follow each other.
'stateKey' => 'sidebar:state',
'variant' => 'sidebar',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant) === 'inset' ? 'inset' : 'sidebar';
$surface = $variant === 'inset'
? 'bg-canvas text-foreground'
: 'border-e border-border bg-card text-card-foreground';
@endphp
<aside
x-data="uiSidebar({
resizable: @js((bool) $resizable),
minWidth: @js((int) $minWidth),
maxWidth: @js((int) $maxWidth),
defaultWidth: @js((int) $defaultWidth),
storageKey: @js($storageKey),
stateKey: @js((string) $stateKey),
resizeLabel: @js(__('Resize sidebar')),
})"
x-ref="sidebar"
data-slot="sidebar"
data-variant="{{ $variant }}"
{{-- Server-rendered so the width is never `auto` before Alpine starts. --}}
data-state="expanded"
:data-state="expanded ? 'expanded' : 'collapsed'"
:data-resizable="resizable ? 'true' : 'false'"
:style="(resizable && expanded) ? ('--sidebar-width: ' + width + 'px') : ''"
{{ $attributes->merge(['class' => 'group/sidebar relative flex h-full flex-col '.$surface.' duration-200 data-[sidebar-motion=ready]:transition-[width] data-[state=collapsed]:w-16 data-[state=expanded]:w-64 data-[resizable=true]:data-[state=expanded]:w-[var(--sidebar-width)] motion-reduce:transition-none']) }}
>
{{ $slot }}
<template x-if="resizable">
<div
data-slot="sidebar-resize-handle"
x-show="expanded"
role="separator"
aria-orientation="vertical"
:aria-label="resizeLabel"
:aria-valuemin="minWidth"
:aria-valuemax="maxWidth"
:aria-valuenow="width"
:tabindex="expanded ? 0 : -1"
@pointerdown="startResize($event)"
@keydown="onResizeKey($event)"
@dblclick="resetWidth()"
class="group absolute inset-y-0 end-0 z-20 flex w-2 translate-x-1/2 cursor-col-resize touch-none items-center justify-center focus-visible:outline-none rtl:-translate-x-1/2"
>
<span
aria-hidden="true"
class="h-full w-0.5 bg-border transition-colors group-hover:bg-ring group-focus-visible:bg-ring group-focus-visible:ring-2 group-focus-visible:ring-ring motion-reduce:transition-none"
></span>
</div>
</template>
</aside>
<button
type="button"
data-slot="sidebar-trigger"
@click="toggle()"
:aria-expanded="expanded"
aria-label="{{ __('Toggle sidebar') }}"
{{ $attributes->merge(['class' => 'inline-flex size-8 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none']) }}
>
<svg class="size-4" 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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
</button>
<div
data-slot="sidebar-header"
{{ $attributes->merge(['class' => 'flex items-center gap-2 p-2 group-data-[state=collapsed]/sidebar:justify-center']) }}
>
{{ $slot }}
</div>
<div
data-slot="sidebar-content"
{{ $attributes->merge(['class' => 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-2']) }}
>
{{ $slot }}
</div>
<div
data-slot="sidebar-footer"
{{ $attributes->merge(['class' => 'flex flex-col gap-2 p-2 group-data-[state=collapsed]/sidebar:items-center']) }}
>
{{ $slot }}
</div>
@props([
// Collapsible section: the label becomes a disclosure button with a
// chevron and the rows fold away. `open` sets the initial state.
'collapsible' => false,
'open' => true,
// localStorage key that remembers the open state per browser (stored as
// `open` or `closed`); null keeps it for the page only. A blocking file
// script applies the stored state while the page parses, so a folded
// group never flashes open.
'storageKey' => null,
// URL and CSP nonce of that boot script, like theme-script.
'bootSrc' => null,
'nonce' => null,
])
@php
$remembers = $collapsible && filled($storageKey);
$bootSrc ??= asset('vendor/brok/sidebar-group-boot.js');
@endphp
<div
data-slot="sidebar-group"
@if ($collapsible)
x-data="uiSidebarGroup({ open: @js((bool) $open), storageKey: @js($remembers ? (string) $storageKey : null) })"
data-open="{{ $open ? 'true' : 'false' }}"
:data-open="groupOpen ? 'true' : 'false'"
@if ($remembers) data-storage-key="{{ $storageKey }}" @endif
@endif
{{ $attributes->merge(['class' => 'group/sidebar-group flex flex-col gap-1']) }}
>
@if ($remembers)
<script data-slot="sidebar-group-boot" src="{{ $bootSrc }}" @if (filled($nonce)) nonce="{{ $nonce }}" @endif></script>
@endif
{{ $slot }}
</div>
@props([
// Set on the label of a collapsible group: renders it as the disclosure
// button that folds the group's rows.
'collapsible' => false,
])
@php
$base = 'flex h-8 items-center gap-1 px-2 text-xs font-medium text-muted-foreground';
@endphp
@if ($collapsible)
<button
type="button"
data-slot="sidebar-group-label"
x-show="expanded"
@click="groupOpen = !groupOpen"
:aria-expanded="groupOpen"
{{ $attributes->merge(['class' => $base.' w-full rounded-md outline-none transition-colors hover:text-foreground focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none']) }}
>
<span class="min-w-0 flex-1 truncate text-start">{{ $slot }}</span>
<svg class="size-4 shrink-0 transition-transform group-data-[open=false]/sidebar-group:-rotate-90 motion-reduce:transition-none rtl:group-data-[open=false]/sidebar-group:rotate-90" 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>
</button>
@else
<div
data-slot="sidebar-group-label"
x-show="expanded"
{{ $attributes->merge(['class' => $base]) }}
>
{{ $slot }}
</div>
@endif
@props(['label' => 'Primary'])
{{-- In a folded collapsible group the rows hide, except in the collapsed icon
rail: the rail has no group labels to reopen a group, so it keeps every
icon. Driven by data attributes, so the server-rendered and boot-script
state is right before Alpine starts. --}}
<nav aria-label="{{ __($label) }}" data-slot="sidebar-menu" {{ $attributes->merge(['class' => 'flex flex-col gap-1 group-data-[open=false]/sidebar-group:not-group-data-[state=collapsed]/sidebar:hidden']) }}>
<ul class="flex flex-col gap-1">
{{ $slot }}
</ul>
</nav>
<li
data-slot="sidebar-menu-item"
{{ $attributes->merge(['class' => 'relative']) }}
>
{{ $slot }}
</li>
@props([
'href' => null,
'active' => false,
// Row density: md is the app-shell rail (40px), sm the dense docs/tree rail.
'size' => 'md',
// The row's text. With `label`, the slot is the icon: the label hides when
// the sidebar collapses (kept for assistive tech, and as the row's title),
// so a collapsed rail is a centred column of icons with names.
'label' => null,
// Trailing content (a count badge, a shortcut) shown while expanded.
'trailing' => null,
// A number shown at the row end (unread items, open tasks). Zero, null or
// an empty value hides it. `count-expr` is an Alpine expression for a
// live count ($store.inbox.unread, a Livewire-entangled property); the
// server `count` is its first value. `count-label` is read after the
// number by screen readers ("12 unread").
'count' => null,
'countExpr' => null,
'countLabel' => null,
// A destination that cannot be used yet: the row keeps its place and name
// but is not a link or button target (aria-disabled, out of the tab
// order). `soon` also marks it with a "Soon" badge (true, or the badge
// text) and implies disabled.
'disabled' => false,
'soon' => false,
// Button type when the row has no href: `submit` (or `reset`) makes the
// row submit its enclosing form, or the form named by a `form="…"`
// attribute (a sign-out POST), with the look of every other row.
'type' => 'button',
// A keyboard shortcut hint, keys joined by "+" ("mod+k", "shift+n"):
// drawn as <kbd> keys at the row end while the rail is
// expanded and exposed as aria-keyshortcuts. `mod` is the platform's
// command key, as in button: ⌘ (Meta) for a request from macOS or iOS,
// else Ctrl (Control). A hint only; bind the key with x-hotkey or the
// hotkeys store. Hidden on a touch screen (pointer: coarse).
'shortcut' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$sidebar = $styles['sidebar'];
$classes = trim($sidebar['menuButton'].' '.($sidebar['menuButtonSizes'][$size] ?? $sidebar['menuButtonSizes']['md']));
$soonLabel = match (true) {
$soon === true, $soon === 'true' => __('Soon'),
is_string($soon) && trim($soon) !== '' && $soon !== 'false' => $soon,
default => null,
};
$isDisabled = ($disabled === true || $disabled === 'true') || $soonLabel !== null;
$tag = $href ? 'a' : 'button';
$buttonType = in_array($type, ['submit', 'reset'], true) ? $type : 'button';
$expanded = 'typeof expanded === \'undefined\' || expanded';
$shortcutKeys = [];
$shortcutAria = null;
if (filled($shortcut)) {
$keyNames = [
'ctrl' => ['Ctrl', 'Control'], 'control' => ['Ctrl', 'Control'], 'cmd' => ['⌘', 'Meta'], 'meta' => ['⌘', 'Meta'],
'alt' => ['Alt', 'Alt'], 'option' => ['⌥', 'Alt'], 'shift' => ['Shift', 'Shift'], 'enter' => ['Enter', 'Enter'],
'escape' => ['Esc', 'Escape'], 'esc' => ['Esc', 'Escape'], 'space' => ['Space', 'Space'], 'tab' => ['Tab', 'Tab'],
'backspace' => ['Backspace', 'Backspace'], 'delete' => ['Delete', 'Delete'],
'up' => ['↑', 'ArrowUp'], 'down' => ['↓', 'ArrowDown'], 'left' => ['←', 'ArrowLeft'], 'right' => ['→', 'ArrowRight'],
];
$shortcutParts = array_filter(explode('+', trim((string) $shortcut)), 'strlen');
if (in_array('mod', array_map('mb_strtolower', $shortcutParts), true)) {
// The server picks the label for `mod`, as button does:
// Sec-CH-UA-Platform first (Chromium), then the User-Agent. A page
// in a shared full-page cache must vary on it, or name the key.
$platformHint = trim((string) request()->header('Sec-CH-UA-Platform'), "\" ");
$isApplePlatform = $platformHint !== ''
? in_array(mb_strtolower($platformHint), ['macos', 'ios'], true)
: preg_match('/Macintosh|Mac OS X|iPhone|iPad|iPod/i', (string) request()->userAgent()) === 1;
$keyNames['mod'] = $isApplePlatform ? ['⌘', 'Meta'] : ['Ctrl', 'Control'];
}
$ariaParts = [];
foreach ($shortcutParts as $part) {
[$keyLabel, $keyAria] = $keyNames[mb_strtolower($part)] ?? [mb_strtoupper($part), mb_strtoupper($part)];
$shortcutKeys[] = $keyLabel;
$ariaParts[] = $keyAria;
}
$shortcutAria = $ariaParts === [] ? null : implode('+', $ariaParts);
}
// A consumer's own aria-keyshortcuts (several chords, "Meta+K Control+K") wins.
if ($attributes->has('aria-keyshortcuts')) {
$shortcutAria = null;
}
@endphp
<{{ $tag }}
@if ($tag === 'a')
@if ($isDisabled) aria-disabled="true" tabindex="-1" @else href="{{ $href }}" @endif
@else
type="{{ $buttonType }}"
@disabled($isDisabled)
@endif
data-slot="sidebar-menu-button"
data-active="{{ $active ? 'true' : 'false' }}"
@if ($isDisabled) data-disabled="true" @endif
@if ($soonLabel !== null) data-soon="true" @endif
@if ($active) aria-current="page" @endif
@if ($shortcutAria !== null && ! $isDisabled) aria-keyshortcuts="{{ $shortcutAria }}" @endif
@if ($label !== null) :title="typeof expanded === 'undefined' || expanded ? null : @js($label)" @endif
{{ $attributes->class(['text-start' => $tag === 'button', 'cursor-not-allowed opacity-60 hover:bg-transparent' => $isDisabled])->merge(['class' => $classes]) }}
>
{{ $slot }}
@if ($label !== null)
<span class="min-w-0 flex-1 truncate" :class="typeof expanded !== 'undefined' && !expanded ? 'sr-only' : ''">{{ $label }}</span>
@if ($trailing)
<span class="ms-auto shrink-0 text-xs text-muted-foreground" x-show="typeof expanded === 'undefined' || expanded">{{ $trailing }}</span>
@endif
@endif
@if ($shortcutKeys !== [] && ! $isDisabled)
<span
data-slot="sidebar-menu-shortcut"
dir="ltr"
aria-hidden="true"
x-show="{{ $expanded }}"
@class(['inline-flex shrink-0 items-center gap-1 pointer-coarse:hidden', 'ms-auto' => ! ($label !== null && $trailing)])
>@foreach ($shortcutKeys as $key)<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-border bg-background px-1 font-body text-xs font-medium leading-none text-muted-foreground">{{ $key }}</kbd>@endforeach</span>
@endif
@if ($soonLabel !== null)
<span data-slot="sidebar-menu-soon" class="ms-auto shrink-0" x-show="{{ $expanded }}"><x-ui.badge variant="outline" size="sm">{{ $soonLabel }}</x-ui.badge></span>
@else
<x-ui.sidebar.menu-count :count="$count" :count-expr="$countExpr" :count-label="$countLabel" :when="$expanded" />
@endif
</{{ $tag }}>
<div
data-slot="sidebar-separator"
role="separator"
{{ $attributes->merge(['class' => 'mx-2 h-px bg-border']) }}
></div>
@props([
'variant' => 'sidebar',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant) === 'inset' ? 'inset' : 'sidebar';
$surface = $variant === 'inset'
? ' bg-background md:my-2 md:me-2 md:overflow-hidden md:rounded-lg md:border md:border-border md:shadow-xs'
: '';
@endphp
<main
data-slot="sidebar-inset"
data-variant="{{ $variant }}"
{{ $attributes->merge(['class' => 'flex flex-1 flex-col'.$surface]) }}
>
{{ $slot }}
</main>
/**
* Sidebar behavior (spec §19 behavior layer).
*
* Holds the expanded/collapsed state on the root component and persists it to
* localStorage under `stateKey` (`sidebar:state` by default) so the chosen
* layout survives navigation and reloads; a shell passes its own key when
* several shells in one app should not follow each other.
*
* When the sidebar is `resizable`, it also tracks the expanded `width` (px,
* clamped to [minWidth, maxWidth]). A focusable `role="separator"` handle on the
* inline-END edge drives the width via pointer drag or Arrow keys; the chosen
* width persists to localStorage under `storageKey` when one is provided and is
* restored on init. Dragging is direction-aware: under `dir="rtl"` the inline
* end is on the left, so the geometry mirrors and the handle always tracks the
* pointer. All drag listeners are removed in `destroy()` so unmounting mid-drag
* cannot leak `pointermove`/`pointerup` handlers.
*
* `uiSidebarGroup` backs a collapsible `sidebar.group`; with a `storageKey` it
* remembers the group's open state per browser.
*
* All localStorage access is guarded for privacy-mode / SSR safety.
*
* 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).
*/
// localStorage throws in private windows and when site data is blocked. Both
// helpers swallow ONLY that — a persisted-state failure must never break the
// sidebar — and keep the policy in one place instead of four try/catch copies.
function readStorage(key) {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function writeStorage(key, value) {
try {
window.localStorage.setItem(key, value);
} catch {
// Session-only: nothing to do.
}
}
// A remembered group state is stored as 'open' or 'closed'; 'true' and
// 'false' are read too. sidebar-group-boot.js applies the same rule before
// Alpine starts (SidebarGroupStateTest keeps the two in step).
function readGroupOpen(key, fallback) {
const value = readStorage(key);
if (value === 'open' || value === 'true') return true;
if (value === 'closed' || value === 'false') return false;
return fallback;
}
document.addEventListener('alpine:init', () => {
// A collapsible `sidebar.group`: `groupOpen` folds its menu. With a
// `storageKey` the state is read before the first binding (so Alpine never
// flips what the boot script applied) and written on every change.
window.Alpine.data('uiSidebarGroup', (config = {}) => {
const groupStorageKey = config.storageKey || null;
const open = config.open ?? true;
return {
groupOpen: groupStorageKey ? readGroupOpen(groupStorageKey, open) : open,
groupStorageKey,
init() {
if (!this.groupStorageKey) return;
this.$watch('groupOpen', (value) => {
writeStorage(this.groupStorageKey, value ? 'open' : 'closed');
});
},
};
});
window.Alpine.data('uiSidebar', (config = {}) => ({
expanded: true,
// Resize config (only meaningful when `resizable` is true).
resizable: config.resizable ?? false,
minWidth: config.minWidth ?? 200,
maxWidth: config.maxWidth ?? 480,
defaultWidth: config.defaultWidth ?? 256,
storageKey: config.storageKey ?? null,
stateKey: config.stateKey || 'sidebar:state',
width: config.defaultWidth ?? 256,
resizeLabel: config.resizeLabel ?? 'Resize sidebar',
// Live drag listeners, tracked so destroy() can detach them.
_onMove: null,
_onUp: null,
init() {
// Storage can be unavailable (private mode, blocked site data): the
// sidebar then simply keeps its server-rendered state for the session.
const stored = readStorage(this.stateKey);
if (stored !== null) {
this.expanded = stored === 'true';
}
if (this.resizable) {
this.width = this.clampWidth(this.defaultWidth);
if (this.storageKey) {
const parsed = parseInt(readStorage(this.storageKey) ?? '', 10);
if (!Number.isNaN(parsed)) {
this.width = this.clampWidth(parsed);
}
}
}
// The restored state paints once without motion; the width
// transition turns on the frame after, so only a user's toggle
// animates, not the page load.
requestAnimationFrame(() => requestAnimationFrame(() => {
this.$el.dataset.sidebarMotion = 'ready';
}));
},
toggle() {
this.expanded = !this.expanded;
writeStorage(this.stateKey, String(this.expanded));
},
clampWidth(w) {
return Math.min(this.maxWidth, Math.max(this.minWidth, Math.round(w)));
},
persistWidth() {
if (!this.storageKey) return;
writeStorage(this.storageKey, String(this.width));
},
applyWidth(w) {
this.width = this.clampWidth(w);
},
resetWidth() {
this.applyWidth(this.defaultWidth);
this.persistWidth();
},
startResize(e) {
if (!this.resizable || !this.expanded) return;
e.preventDefault();
const rtl = getComputedStyle(this.$refs.sidebar).direction === 'rtl';
const startX = e.clientX;
const startWidth = this.width;
this._onMove = (ev) => {
// Pointer moving toward the inline-END edge widens the sidebar.
// In LTR the inline end is on the right (delta = ev - start);
// in RTL it is on the left, so the delta is inverted.
let delta = ev.clientX - startX;
if (rtl) delta = -delta;
this.applyWidth(startWidth + delta);
};
this._onUp = () => {
this.persistWidth();
this._teardownDrag();
};
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
},
onResizeKey(e) {
if (!this.resizable || !this.expanded) return;
const rtl = getComputedStyle(this.$refs.sidebar).direction === 'rtl';
const step = e.shiftKey ? 32 : 8;
const dec = () => this.applyWidth(this.width - step);
const inc = () => this.applyWidth(this.width + step);
let handled = true;
switch (e.key) {
// Toward the inline-end edge (= wider) is Right in LTR, Left in RTL.
case 'ArrowLeft':
rtl ? inc() : dec();
break;
case 'ArrowRight':
rtl ? dec() : inc();
break;
case 'ArrowUp':
inc();
break;
case 'ArrowDown':
dec();
break;
case 'Home':
this.applyWidth(this.minWidth);
break;
case 'End':
this.applyWidth(this.maxWidth);
break;
default:
handled = false;
}
if (handled) {
e.preventDefault();
this.persistWidth();
}
},
_teardownDrag() {
if (this._onMove) {
window.removeEventListener('pointermove', this._onMove);
this._onMove = null;
}
if (this._onUp) {
window.removeEventListener('pointerup', this._onUp);
this._onUp = null;
}
},
destroy() {
this._teardownDrag();
},
}));
});
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
@endphp
{{-- Nested rows under a parent row: a hairline down the start edge, hidden
while the sidebar is collapsed. --}}
<ul
data-slot="sidebar-menu-sub"
x-show="typeof expanded === 'undefined' || expanded"
{{ $attributes->merge(['class' => $styles['sidebar']['menuSub']]) }}
>
{{ $slot }}
</ul>
@props([
// A number shown at the row end. Zero, null or an empty value hides it.
'count' => null,
// An Alpine expression for a live count ($store.inbox.unread, a
// Livewire-entangled property); `count` is its first server value.
'countExpr' => null,
// Words read after the number by screen readers ("12 unread").
'countLabel' => null,
// An extra Alpine condition the count also needs to show. The desktop rail
// passes its expanded state; the mobile sheet passes none.
'when' => null,
])
{{-- The row-end count that sidebar.menu-button and the sidebar.mobile nav
share, so both evaluate the live expression and name the number the
same way. Renders nothing without a count or an expression. --}}
@php
$initialCount = is_numeric($count) ? (int) $count : 0;
$isLive = filled($countExpr);
$condition = filled($when) ? (string) $when : null;
@endphp
@if ($isLive || $initialCount > 0)
<span
data-slot="sidebar-menu-count"
{{ $attributes->merge(['class' => 'ms-auto shrink-0 text-xs tabular-nums text-muted-foreground']) }}
@if ($isLive)
x-data="{ get count() { const value = Number({{ $countExpr }}); return Number.isFinite(value) && value > 0 ? value : 0 } }"
x-show="{{ $condition !== null ? '('.$condition.') && ' : '' }}count > 0"
@elseif ($condition !== null)
x-show="{{ $condition }}"
@endif
@if ($initialCount <= 0) style="display: none" @endif
><span @if ($isLive) x-text="count" @endif>{{ $initialCount > 0 ? $initialCount : '' }}</span>@if (filled($countLabel))<span class="sr-only"> {{ __($countLabel) }}</span>@endif</span>
@endif
@props([
// Groups of { heading, items: [{ label, href, icon?, current?, badge?,
// count?, countExpr?, countLabel? }] }. `icon`, when present, is trusted
// raw SVG path/shape markup (see the shell's own icon map) rendered inside
// a 24x24 viewBox — never feed user input through it. `count`,
// `countExpr` and `countLabel` render the same live row-end count as
// sidebar.menu-button. Ignored once a default slot is supplied.
'nav' => [],
// Sheet title, usually the org/workspace name. With a `brand` slot it
// stays the accessible name of the sheet title (screen-reader only).
'label' => null,
// Bare: only the trigger and its sheet, with no bar chrome (no border,
// padding or label), for placing the trigger inside an existing top bar.
'bare' => false,
// Sheet content id (and the trigger's aria-controls). Auto-generated when omitted.
'id' => null,
])
@php
$id ??= 'sidebar-mobile-'.\Illuminate\Support\Str::random(6);
$bare = filter_var($bare, FILTER_VALIDATE_BOOLEAN);
// Optional named slot: a visual lockup (monogram + name) shown in the bar
// and as the sheet title instead of the plain text label.
$hasBrand = isset($brand) && trim((string) $brand) !== '';
// Optional named slot: settings at the foot of the sheet, under the nav —
// for example <x-ui.theme-switcher size="sm" />, which is not reachable
// from the collapsed desktop sidebar on a phone.
$hasFooter = isset($footer) && ($footer instanceof \Illuminate\View\ComponentSlot ? $footer->hasActualContent() : trim((string) $footer) !== '');
@endphp
<div
data-slot="sidebar-mobile"
@if ($bare) data-bare="true" @endif
{{ $attributes->merge(['class' => $bare ? 'inline-flex shrink-0 items-center md:hidden' : 'flex items-center gap-2 border-b border-border p-2 md:hidden']) }}
>
<x-ui.sheet>
<x-ui.sheet.trigger
:aria-label="__('Open navigation')"
aria-controls="{{ $id }}"
class="size-9 rounded-md text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
>
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</x-ui.sheet.trigger>
<x-ui.sheet.content id="{{ $id }}" side="start" class="flex flex-col overflow-y-auto">
<x-ui.sheet.header>
<x-ui.sheet.title>
@if ($hasBrand)
<span data-slot="sidebar-mobile-brand" @if (filled($label)) aria-hidden="true" @endif class="flex min-w-0 items-center gap-2">{{ $brand }}</span>
@if (filled($label))
<span class="sr-only">{{ __($label) }}</span>
@endif
@else
{{ __($label) }}
@endif
</x-ui.sheet.title>
</x-ui.sheet.header>
<x-ui.sheet.close />
@if (trim($slot) !== '')
{{ $slot }}
@else
<nav aria-label="{{ __('Primary') }}" class="mt-4 flex flex-col gap-4">
@foreach ($nav as $group)
<div>
<p class="mb-2 px-2 text-xs font-medium text-muted-foreground">{{ __($group['heading']) }}</p>
<ul class="flex flex-col gap-0.5">
@foreach ($group['items'] as $item)
<li>
<a
href="{{ $item['href'] }}"
@if ($item['current'] ?? false) aria-current="page" @endif
@class([
'flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring',
'bg-muted text-foreground' => $item['current'] ?? false,
'text-muted-foreground' => ! ($item['current'] ?? false),
])
>
@isset($item['icon'])
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{!! $item['icon'] !!}</svg>
@endisset
<span class="truncate">{{ __($item['label']) }}</span>
@isset($item['badge'])
<x-ui.badge variant="secondary" class="ms-auto">{{ $item['badge'] }}</x-ui.badge>
@endisset
<x-ui.sidebar.menu-count :count="$item['count'] ?? null" :count-expr="$item['countExpr'] ?? null" :count-label="$item['countLabel'] ?? null" />
</a>
</li>
@endforeach
</ul>
</div>
@endforeach
</nav>
@endif
@if ($hasFooter)
<div data-slot="sidebar-mobile-footer" class="mt-auto flex flex-col gap-2 border-t border-border pt-4">
{{ $footer }}
</div>
@endif
</x-ui.sheet.content>
</x-ui.sheet>
@unless ($bare)
@if ($hasBrand)
<div data-slot="sidebar-mobile-brand" class="flex min-w-0 items-center gap-2">{{ $brand }}</div>
@else
<span class="truncate text-sm font-semibold">{{ __($label) }}</span>
@endif
@endunless
</div>
/**
* Sidebar group boot: applies a remembered open or closed state to a
* collapsible `sidebar.group` while the page parses, so a folded group never
* flashes open before Alpine starts.
*
* A classic, blocking script (no module, no Alpine) that `sidebar.group` with a
* `storage-key` renders as its first child, from
* `public/vendor/brok/sidebar-group-boot.js`. It is a file, not an inline
* script, so it runs under a strict Content-Security-Policy (`script-src
* 'self'`, no nonce, no 'unsafe-inline'). The group's rows come after it in the
* document, so they are parsed with the right `data-open` already set.
*
* The rule is the same as `uiSidebarGroup` in sidebar.js (the two files cannot
* import each other, and SidebarGroupStateTest fails when they drift):
* - the group's `data-storage-key` names the localStorage key;
* - 'open' or 'true' opens the group, 'closed' or 'false' folds it;
* - a missing or unknown value keeps the server-rendered `data-open`.
*
* localStorage access is wrapped in try/catch, so blocked storage (private
* mode, file://) keeps the server-rendered state instead of throwing.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
(function () {
const script = document.currentScript;
const group = script ? script.parentElement : null;
const key = group ? group.getAttribute('data-storage-key') : null;
if (!key) {
return;
}
let value = null;
try {
value = window.localStorage.getItem(key);
} catch (e) {
value = null;
}
if (value === 'open' || value === 'true') {
group.setAttribute('data-open', 'true');
} else if (value === 'closed' || value === 'false') {
group.setAttribute('data-open', 'false');
}
})();
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.13.0 - 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