Navigation
Segmented nav
Links between sibling pages or views, drawn like tabs or a segmented control, that navigate for real.
- Built on
<nav><a href>aria-current- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.7 kB
- Built from
- Icon
Try its options
Loading its options.
Code
import { SegmentedNav } from "@/components/equip/segmented-nav";
export default function Demo() {
return (
<SegmentedNav
aria-label="Billing"
items={[
{ label: "Plan", href: "#plan", current: true },
{ label: "Usage", href: "#usage" },
{ label: "Invoices", href: "#invoices" },
]}
/>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<nav aria-label="Billing" class="eq-segmented-nav" data-variant="segmented">
<ul class="eq-segmented-nav-list">
<li class="eq-segmented-nav-item">
<a href="#plan" class="eq-segmented-nav-link" aria-current="page">
<span class="eq-segmented-nav-label">Plan</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#usage" class="eq-segmented-nav-link">
<span class="eq-segmented-nav-label">Usage</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#invoices" class="eq-segmented-nav-link">
<span class="eq-segmented-nav-label">Invoices</span>
</a>
</li>
</ul>
</nav>Examples
Project sections
The underline variant suits the row under a page title; each link is a separate page, so the browser's back button walks through them.
Code
import { SegmentedNav } from "@/components/equip/segmented-nav";
export default function Example() {
return (
<SegmentedNav
aria-label="Project"
variant="underline"
style={{ width: "100%" }}
items={[
{ label: "Overview", href: "#overview", icon: "layout", current: true },
{ label: "Deployments", href: "#deployments", icon: "rocket", count: 128 },
{ label: "Logs", href: "#logs", icon: "terminal" },
{ label: "Analytics", href: "#analytics", icon: "bar-chart" },
{ label: "Settings", href: "#settings", icon: "settings" },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav aria-label="Project" style="width:100%" class="eq-segmented-nav" data-variant="underline">
<ul class="eq-segmented-nav-list">
<li class="eq-segmented-nav-item">
<a href="#overview" class="eq-segmented-nav-link" aria-current="page">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z" />
<path d="M4 9.5h16" />
<path d="M9.5 9.5V20" />
</svg>
<span class="eq-segmented-nav-label">Overview</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#deployments" class="eq-segmented-nav-link">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.5c3 2 4.5 5.5 4.5 9.5v4.5h-9V12c0-4 1.5-7.5 4.5-9.5z" />
<path d="M7.5 12.5 5 15v3.5h2.5M16.5 12.5 19 15v3.5h-2.5" />
<path d="M10.5 9a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0 -3 0" />
<path d="M12 19.25v2" />
</svg>
<span class="eq-segmented-nav-label">Deployments</span>
<span class="eq-segmented-nav-count">128</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#logs" class="eq-segmented-nav-link">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 4.5h14a2 2 0 0 1 2 2v11a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2z" />
<path d="M7 9.5l3 2.5-3 2.5" />
<path d="M12.5 15h4.5" />
</svg>
<span class="eq-segmented-nav-label">Logs</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#analytics" class="eq-segmented-nav-link">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.5 20h17" />
<path d="M7.5 20v-7.5M12 20V4M16.5 20V9.5" />
</svg>
<span class="eq-segmented-nav-label">Analytics</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#settings" class="eq-segmented-nav-link">
<svg aria-hidden="true" focusable="false" class="eq-icon" data-size="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.44 10.57L20.9 10.67A9 9 0 0 1 20.9 13.33L18.44 13.43A6.6 6.6 0 0 1 17.57 15.55L19.23 17.35A9 9 0 0 1 17.35 19.23L15.55 17.57A6.6 6.6 0 0 1 13.43 18.44L13.33 20.9A9 9 0 0 1 10.67 20.9L10.57 18.44A6.6 6.6 0 0 1 8.45 17.57L6.65 19.23A9 9 0 0 1 4.77 17.35L6.43 15.55A6.6 6.6 0 0 1 5.56 13.43L3.1 13.33A9 9 0 0 1 3.1 10.67L5.56 10.57A6.6 6.6 0 0 1 6.43 8.45L4.77 6.65A9 9 0 0 1 6.65 4.77L8.45 6.43A6.6 6.6 0 0 1 10.57 5.56L10.67 3.1A9 9 0 0 1 13.33 3.1L13.43 5.56A6.6 6.6 0 0 1 15.55 6.43L17.35 4.77A9 9 0 0 1 19.23 6.65L17.57 8.45A6.6 6.6 0 0 1 18.44 10.57z" />
<path d="M9.25 12a2.75 2.75 0 1 0 5.5 0a2.75 2.75 0 1 0 -5.5 0" />
</svg>
<span class="eq-segmented-nav-label">Settings</span>
</a>
</li>
</ul>
</nav>Filtered views
Each view is a URL with a query, so it can be shared and bookmarked; when the row is wider than its box it scrolls sideways inside itself.
Code
import { SegmentedNav } from "@/components/equip/segmented-nav";
export default function Example() {
return (
<SegmentedNav
aria-label="Issues"
items={[
{ label: "Open", href: "#issues?state=open", count: 24, current: true },
{ label: "Assigned to me", href: "#issues?assignee=me", count: 3 },
{ label: "Closed", href: "#issues?state=closed", count: 412 },
]}
/>
);
}HTMLworks in any framework, with the part's CSS
<nav aria-label="Issues" class="eq-segmented-nav" data-variant="segmented">
<ul class="eq-segmented-nav-list">
<li class="eq-segmented-nav-item">
<a href="#issues?state=open" class="eq-segmented-nav-link" aria-current="page">
<span class="eq-segmented-nav-label">Open</span>
<span class="eq-segmented-nav-count">24</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#issues?assignee=me" class="eq-segmented-nav-link">
<span class="eq-segmented-nav-label">Assigned to me</span>
<span class="eq-segmented-nav-count">3</span>
</a>
</li>
<li class="eq-segmented-nav-item">
<a href="#issues?state=closed" class="eq-segmented-nav-link">
<span class="eq-segmented-nav-label">Closed</span>
<span class="eq-segmented-nav-count">412</span>
</a>
</li>
</ul>
</nav>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
items | { label: ReactNode; href: string; current?: boolean; icon?: IconName | ReactElement; count?: ReactNode }[] | none | The sibling pages in order. Any other anchor attribute on an item goes to its link. |
variant | "segmented" | "underline" | "segmented" | segmented fills the current page inside a track; underline draws an ink line under it, for the row under a page title. |
aria-label | string | none | Names the landmark, such as "Project". Give one whenever a page has more than one nav. |
...rest | ComponentProps<"nav"> | none | Every native nav attribute. |
Anatomy
| root | .eq-segmented-nav | A nav landmark with data-variant. |
|---|---|---|
| list | .eq-segmented-nav-list | Scrolls sideways inside itself when the links do not fit. |
| item | .eq-segmented-nav-item | |
| link | .eq-segmented-nav-link | The current page has aria-current="page". |
| label | .eq-segmented-nav-label | |
| count | .eq-segmented-nav-count |
Keyboard
| Tab | Moves through the links in order. |
|---|---|
| Enter | Follows the focused link. |
Notes
- Reach for Tabs when the panels live on one page and switch in place; reach for this when each item is its own URL.
- There are no tab roles and no arrow-key handling, because these are links: the browser's back button, new tabs and bookmarks all work.
- The current page is marked with aria-current="page", so screen readers say which one you are on.
- A count is part of the link text, so "Deployments 128" is what is read; keep it short.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/segmented-nav.tsx44 lines
import type { ComponentProps, ReactElement, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import "./segmented-nav.css";
export type SegmentedNavItem = Omit<ComponentProps<"a">, "children"> & {
label: ReactNode;
href: string;
/** Marks the page the person is on with aria-current="page". */
current?: boolean;
/** An Icon name, or your own 16px element, before the label. */
icon?: IconName | ReactElement;
/** A count after the label, such as open issues. */
count?: ReactNode;
};
export type SegmentedNavProps = ComponentProps<"nav"> & {
/** The sibling pages or sections, in order. */
items: SegmentedNavItem[];
/** segmented draws a track with the current page filled; underline draws a line under it. */
variant?: "segmented" | "underline";
};
export function SegmentedNav({ items, variant = "segmented", className, ...rest }: SegmentedNavProps) {
return (
<nav {...rest} className={["eq-segmented-nav", className].filter(Boolean).join(" ")} data-variant={variant}>
<ul className="eq-segmented-nav-list">
{items.map(({ label, href, current = false, icon, count, className: linkClass, ...link }) => (
<li key={href} className="eq-segmented-nav-item">
<a
{...link}
href={href}
className={["eq-segmented-nav-link", linkClass].filter(Boolean).join(" ")}
aria-current={current ? "page" : undefined}
>
{icon != null && (typeof icon === "string" ? <Icon name={icon} size="s" /> : icon)}
<span className="eq-segmented-nav-label">{label}</span>
{count != null && <span className="eq-segmented-nav-count">{count}</span>}
</a>
</li>
))}
</ul>
</nav>
);
}components/equip/segmented-nav.css132 lines
@layer equip {
.eq-segmented-nav {
min-width: 0;
max-width: 100%;
font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
}
/* Scrolls sideways inside itself when the items do not fit, so the page never does.
The padding keeps focus rings inside the scroll area. */
.eq-segmented-nav-list {
box-sizing: border-box;
display: flex;
gap: 2px;
max-width: 100%;
margin: 0;
padding: 3px;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
list-style: none;
}
.eq-segmented-nav-list::-webkit-scrollbar {
display: none;
}
.eq-segmented-nav[data-variant="segmented"] .eq-segmented-nav-list {
width: max-content;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
}
.eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-list {
gap: var(--eq-space-1);
padding: 3px 3px 0;
border-bottom: 1px solid var(--eq-line);
}
.eq-segmented-nav-item {
display: flex;
flex: none;
}
.eq-segmented-nav-link {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
height: var(--eq-h-s);
padding-inline: var(--eq-space-3);
border-radius: var(--eq-radius-s);
color: var(--eq-ink-2);
text-decoration: none;
white-space: nowrap;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-segmented-nav-link > svg {
flex: none;
width: 16px;
height: 16px;
}
.eq-segmented-nav-count {
display: inline-grid;
place-items: center;
box-sizing: border-box;
min-width: 20px;
height: 20px;
padding-inline: var(--eq-space-1);
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: inherit;
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
}
.eq-segmented-nav[data-variant="segmented"] .eq-segmented-nav-link[aria-current="page"] {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-segmented-nav[data-variant="segmented"] .eq-segmented-nav-link[aria-current="page"] .eq-segmented-nav-count {
background: color-mix(in srgb, var(--eq-solid-ink) 18%, transparent);
}
/* Underline: the link sits on the list's line and draws a 2px ink bar over it when current. */
.eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link {
height: var(--eq-h-m);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s) var(--eq-radius-s) 0 0;
}
.eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link::after {
content: "";
position: absolute;
inset: auto var(--eq-space-2) 0;
height: 2px;
border-radius: 1px;
background: var(--eq-ink);
scale: 0 1;
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link[aria-current="page"] {
color: var(--eq-ink);
}
.eq-segmented-nav[data-variant="underline"] .eq-segmented-nav-link[aria-current="page"]::after {
scale: 1 1;
}
@media (hover: hover) {
.eq-segmented-nav-link:hover:not([aria-current="page"]) {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-segmented-nav-link:active {
scale: var(--eq-press);
}
.eq-segmented-nav-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
}