Skip to content
Brok UI

Loading…

No results

Segmented Nav

Open source

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.

Version
v1.1.1
Stability
stable
License
MIT
Related
Tabs
Radio Tabs
Toggle Group

Preview

previews.components.segmented-nav.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/segmented-nav.blade.php
  • blade resources/views/components/ui/segmented-nav/item.blade.php
  • js resources/js/ui/segmented-nav.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

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

segmented-nav.md
# 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.blade.php Blade
<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>
overflow.blade.php Blade
{{-- 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.blade.php Blade
{{-- `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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

segmented-nav segmented-nav-item

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

Navigation and orientation

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
segmented-nav segmented-nav-item
Theming hooks
segmented-nav recipe in _styles.php: list, item, active, inactive, sizes.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

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

livewire-component.blade.php Blade
<div wire:key="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.

resources/views/components/ui/segmented-nav.blade.php Blade
@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 }}>
resources/views/components/ui/segmented-nav/item.blade.php Blade
@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 }}>
resources/js/ui/segmented-nav.js JS
/**
 * 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