Segmented Nav
Segmented navigation: one raised pill for the active destination, quiet labels for the rest — the top-bar nav of a product shell, also usable for client-side view switches.
Preview
<x-ui.segmented-nav label="{{ __('Sections') }}">
<x-ui.segmented-nav.item href="#overview" :active="true">
<x-slot:icon><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></x-slot:icon>
{{ __('Overview') }}
</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#transactions">{{ __('Transactions') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#account">{{ __('Account') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#investments">{{ __('Investments') }}</x-ui.segmented-nav.item>
</x-ui.segmented-nav>
Installation
php artisan ui:add segmented-nav
Note
This component ships an Alpine behavior module at
resources/js/ui/segmented-nav.js. Import it once from your bundle so it registers on alpine:init:
import './segmented-nav.js';
Registry contract
php artisan ui:add segmented-nav
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/segmented-nav.blade.php -
resources/views/components/ui/segmented-nav/item.blade.php -
resources/js/ui/segmented-nav.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# Brok UI: Segmented Nav (`segmented-nav`)
Segmented navigation: one raised pill for the active destination, quiet labels for the rest — the top-bar nav of a product shell, also usable for client-side view switches.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add segmented-nav
```
## Usage
```blade
<x-ui.segmented-nav label="{{ __('Sections') }}">
<x-ui.segmented-nav.item href="#overview" :active="true">
<x-slot:icon><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></x-slot:icon>
{{ __('Overview') }}
</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#transactions">{{ __('Transactions') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#account">{{ __('Account') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#investments">{{ __('Investments') }}</x-ui.segmented-nav.item>
</x-ui.segmented-nav>
```
## Props
- `label` (string|null, default `null`) — Accessible name for the group.
- `as` (nav|div, default `nav`) — nav for links between destinations; div (role=group) for buttons that switch client state.
- `size` (sm|md, default `md`) — Control tier for every item.
- `overflowCue` (bool, default `false`) — For a row with more items than the screen has room for. The row keeps its sideways scroll and adds a fade on each edge where items are cut (data-overflow-start / data-overflow-end), scrolls the active item into view on load, and moves a focused item clear of the fade. Needs segmented-nav.js.
- `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.
- `xActive` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `icon` (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.
- Use for a short row of peer destinations in a top bar or page header (Overview / Transactions / Settings).
- Use with `x-active` for a client-side view or filter switch that never navigates.
- Use `overflow-cue` when the row can hold more items than a phone has room for (a status filter with six values): the row scrolls sideways with a faded edge and keeps the active item in view.
## Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- Use `tabs` when the segments switch panels of content on the same page with tab semantics.
- Use `radio-tabs` when the choice must submit in a form.
## Anti-patterns
- Hiding primary wayfinding in novelty interactions
## Rules
- Use the `<brok:segmented-nav>` tag (or `<x-ui.segmented-nav>`) 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/segmented-nav
- Registry JSON (files, props, contract): https://brokui.dev/r/open/segmented-nav.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
<div class="max-w-xs">
<x-ui.segmented-nav label="{{ __('Sections') }}" size="sm">
<x-ui.segmented-nav.item href="#overview" :active="true">{{ __('Overview') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#transactions">{{ __('Transactions and transfers') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#account">{{ __('Account') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#investments">{{ __('Investments') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#settings">{{ __('Settings') }}</x-ui.segmented-nav.item>
</x-ui.segmented-nav>
</div>
{{-- Six statuses in a phone-width column: the row scrolls sideways, fades the
edge where items are cut and starts with the active status in view. --}}
<div class="max-w-xs">
<x-ui.segmented-nav label="{{ __('Status') }}" overflow-cue>
<x-ui.segmented-nav.item href="#all">{{ __('All') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#draft">{{ __('Draft') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#pending">{{ __('Pending') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#approved">{{ __('Approved') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#rejected" :active="true">{{ __('Rejected') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#archived">{{ __('Archived') }}</x-ui.segmented-nav.item>
</x-ui.segmented-nav>
</div>
View Switch
{{-- `as="div"` + `x-active` for a client-side switch that never navigates: the
same recipe drives the pill from Alpine state. --}}
<div class="flex flex-col gap-4" x-data="{ view: 'list' }">
<x-ui.segmented-nav as="div" label="{{ __('View') }}">
<x-ui.segmented-nav.item x-active="view === 'list'" @click="view = 'list'">{{ __('List') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item x-active="view === 'board'" @click="view = 'board'">{{ __('Board') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item x-active="view === 'calendar'" @click="view = 'calendar'">{{ __('Calendar') }}</x-ui.segmented-nav.item>
</x-ui.segmented-nav>
<p class="text-sm text-muted-foreground" x-text="`Showing the ${view} view`"></p>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | null | null | Accessible name for the group. |
| as | nav | div | nav | nav for links between destinations; div (role=group) for buttons that switch client state. |
| size | sm | md | md | Control tier for every item. |
| overflowCue | bool | false | For a row with more items than the screen has room for. The row keeps its sideways scroll and adds a fade on each edge where items are cut (data-overflow-start / data-overflow-end), scrolls the active item into view on load, and moves a focused item clear of the fade. Needs segmented-nav.js. |
| href | mixed | null | null | Declared by @props in the registry Blade source. |
| active | bool | false | Declared by @props in the registry Blade source. |
| xActive | mixed | null | null | Declared by @props in the registry Blade source. |
| icon | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default— One or more <x-ui.segmented-nav.item> segments.icon— Optional leading icon on an item (16px).x-ui.segmented-nav.item— Installed subcomponent from the registry item.
Renders as nav, div.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Server-rendered; `active` sets the raised recipe and aria-current.
- `x-active` takes an Alpine expression and binds the recipe and aria-current client-side.
- With `overflow-cue`, a row that is wider than its container scrolls sideways (touch, trackpad or Tab), fades the cut edge, centres the aria-current item on load and keeps a focused item out of the fade. Only the row scrolls, never the page. RTL-safe.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, 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.
- Use for a short row of peer destinations in a top bar or page header (Overview / Transactions / Settings).
- Use with `x-active` for a client-side view or filter switch that never navigates.
- Use `overflow-cue` when the row can hold more items than a phone has room for (a status filter with six values): the row scrolls sideways with a faded edge and keeps the active item in view.
Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- Use `tabs` when the segments switch panels of content on the same page with tab semantics.
- Use `radio-tabs` when the choice must submit in a form.
Use instead
- Visible links and local navigation
Anti-patterns
- Hiding primary wayfinding in novelty interactions
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The active link carries aria-current="page"; active buttons carry aria-current="true".
- Every item is a real <a> or <button>, so keyboard focus and the global focus ring apply.
- For a phone, `overflow-cue` is the recommended way to fit many items: every item stays a real link or button in one row with one tab order, and the fade is only a visual cue. A select below a breakpoint is not used because a select that navigates on change breaks WCAG 3.2.2 (On Input), it drops the icons and the raised active state, and two controls for one choice double the work for screen-reader users.
- 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="segmented-nav-{{ $record->id }}">
<x-ui.segmented-nav label="{{ __('Sections') }}">
<x-ui.segmented-nav.item href="#overview" :active="true">
<x-slot:icon><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></x-slot:icon>
{{ __('Overview') }}
</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#transactions">{{ __('Transactions') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#account">{{ __('Account') }}</x-ui.segmented-nav.item>
<x-ui.segmented-nav.item href="#investments">{{ __('Investments') }}</x-ui.segmented-nav.item>
</x-ui.segmented-nav>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Accessible name for the group. Rendered as aria-label on the <nav> (or
// on the role="group" wrapper when `as` is not nav).
'label' => null,
// 'nav' for links between destinations (default); 'div' for a group of
// buttons that switch client state and never navigate.
'as' => 'nav',
'size' => 'md',
// Opt-in for a row with more items than a phone has room for: the row
// still scrolls sideways, and now fades the edge where items are cut,
// scrolls the active item into view on load and keeps a focused item
// clear of the fade. Needs segmented-nav.js.
'overflowCue' => false,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$recipe = $styles['segmented-nav'];
$allowedTags = ['nav', 'div'];
$tag = in_array($as, $allowedTags, true) ? $as : 'nav';
$overflowCue = filter_var($overflowCue, FILTER_VALIDATE_BOOL);
@endphp
{{-- One raised pill for the active destination, quiet labels for the rest — the
top-bar navigation every current product shell runs. Items read `size`
via @aware, so the group sets the tier once. --}}
<{{ $tag }}
data-slot="segmented-nav"
data-size="{{ $size }}"
@if ($tag === 'div') role="group" @endif
@if ($label) aria-label="{{ $label }}" @endif
@if ($overflowCue)
x-data="uiSegmentedNav()"
x-bind:data-overflow-start="overflowStart ? 'true' : null"
x-bind:data-overflow-end="overflowEnd ? 'true' : null"
x-bind:style="fadeStyle"
@focusin="reveal($event.target)"
@endif
{{ $attributes->merge(['class' => $recipe['list']]) }}
>
{{ $slot }}
</{{ $tag }}>
@aware(['size' => 'md'])
@props([
'href' => null,
// Server-side active state. Sets the raised recipe and aria-current="page".
'active' => false,
// Client-side active state: an Alpine expression. When set, the raised /
// quiet recipes are bound with :class and aria-current with :aria-current,
// so one recipe also drives filters and view switches that never navigate.
'xActive' => null,
'size' => null,
'icon' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$recipe = $styles['segmented-nav'];
$sizeClass = $recipe['sizes'][$size ?? 'md'] ?? $recipe['sizes']['md'];
$tag = $href !== null ? 'a' : 'button';
$base = trim($recipe['item'].' '.$sizeClass);
$static = $xActive === null ? ' '.($active ? $recipe['active'] : $recipe['inactive']) : '';
@endphp
<{{ $tag }}
data-slot="segmented-nav-item"
@if ($tag === 'a') href="{{ $href }}" @else type="button" @endif
@if ($xActive === null && $active) aria-current="{{ $tag === 'a' ? 'page' : 'true' }}" @endif
@if ($xActive !== null)
:class="({{ $xActive }}) ? @js($recipe['active']) : @js($recipe['inactive'])"
:aria-current="({{ $xActive }}) ? @js($tag === 'a' ? 'page' : 'true') : null"
@endif
{{ $attributes->merge(['class' => $base.$static]) }}
>
@if ($icon)
<span class="shrink-0 [&>svg]:size-4" aria-hidden="true">{{ $icon }}</span>
@endif
{{ $slot }}
</{{ $tag }}>
/**
* Segmented nav overflow cue — for a row with more items than the screen has
* room for (a phone). The row scrolls sideways as before; this adds:
*
* - a fade on the edge where items are cut (data-overflow-start/-end),
* - the active item scrolled into view on load, so "Rejected" is not hidden
* past the edge of a 390px screen,
* - a focused item moved out from under the fade, so the focus ring is never
* half transparent.
*
* Every item stays a real link or button in one row, in the same tab order,
* so keyboard, screen-reader and zoom use do not change. RTL-safe.
*
* 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).
*/
const FADE = 32;
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSegmentedNav', () => ({
overflowStart: false,
overflowEnd: false,
init() {
this.root = this.$el;
this._measure = () => this.measureOverflow();
this.root.addEventListener('scroll', this._measure, { passive: true });
if (typeof ResizeObserver !== 'undefined') {
this._resizeObserver = new ResizeObserver(this._measure);
this._resizeObserver.observe(this.root);
}
this.$nextTick(() => {
const active = this.root.querySelector('[aria-current]:not([aria-current="false"])');
if (active) this.reveal(active, 'center');
this.measureOverflow();
});
},
destroy() {
this.root?.removeEventListener('scroll', this._measure);
this._resizeObserver?.disconnect();
},
/** Whether items are cut at the start or end of the sideways scroll (RTL-safe). */
measureOverflow() {
const el = this.root;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
const position = Math.abs(el.scrollLeft);
this.overflowStart = max > 1 && position > 1;
this.overflowEnd = max > 1 && position < max - 1;
},
/**
* Scroll an item clear of the faded edges. 'nearest' moves it just far
* enough (for focus); 'center' centres it (for the active item on load).
* Only the row scrolls, never the page.
*/
reveal(item, align = 'nearest') {
const el = this.root;
const target = item?.closest('[data-slot="segmented-nav-item"]');
if (!el || !target || el.scrollWidth <= el.clientWidth + 1) return;
const box = el.getBoundingClientRect();
const rect = target.getBoundingClientRect();
let delta = 0;
if (align === 'center') {
delta = rect.left + rect.width / 2 - (box.left + box.width / 2);
} else if (rect.left < box.left + FADE) {
delta = rect.left - (box.left + FADE);
} else if (rect.right > box.right - FADE) {
delta = rect.right - (box.right - FADE);
}
if (Math.abs(delta) > 1) el.scrollLeft += delta;
},
/** A mask that fades the edge where items are cut. */
get fadeStyle() {
if (!this.overflowStart && !this.overflowEnd) return {};
const rtl = getComputedStyle(this.root).direction === 'rtl';
const start = this.overflowStart ? `${FADE}px` : '0px';
const end = this.overflowEnd ? `calc(100% - ${FADE}px)` : '100%';
const mask = `linear-gradient(${rtl ? 'to left' : 'to right'}, transparent, black ${start}, black ${end}, transparent)`;
return { maskImage: mask, webkitMaskImage: mask };
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.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