Navigation
Side nav
A vertical list of links with sections, icons, counts and nested groups, with an icon-only rail mode.
- Built on
<nav><details>aria-current::details-contentinterpolate-size- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 3.1 kB
- Built from
- Icon
Try its options
Loading its options.
Code
import { SideNav, SideNavGroup, SideNavItem, SideNavSection } from "@/components/equip/side-nav";
export default function Demo() {
return (
<SideNav label="Settings" style={{ width: 200 }}>
<SideNavSection>
<SideNavItem href="#general" icon="settings" current>
General
</SideNavItem>
<SideNavItem href="#members" icon="users" badge={12}>
Members
</SideNavItem>
<SideNavItem href="#billing" icon="credit-card">
Billing
</SideNavItem>
<SideNavGroup label="Security" icon="shield">
<SideNavItem href="#sessions">Sessions</SideNavItem>
<SideNavItem href="#api-keys">API keys</SideNavItem>
</SideNavGroup>
</SideNavSection>
</SideNav>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<nav aria-label="Settings" style="width:200px" class="eq-side-nav">
<div class="eq-side-nav-section">
<ul class="eq-side-nav-list">
<li class="eq-side-nav-item">
<a href="#general" class="eq-side-nav-link" aria-current="page">
<span class="eq-side-nav-icon" aria-hidden="true">
<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>
<span class="eq-side-nav-label">General</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#members" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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 8a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M3 20v-.5a5 5 0 0 1 5-5h2a5 5 0 0 1 5 5v.5" />
<path d="M15.5 5.2a3 3 0 0 1 0 5.6M17.5 14.6a5 5 0 0 1 3.5 4.9v.5" />
</svg>
</span>
<span class="eq-side-nav-label">Members</span>
<span class="eq-side-nav-badge">12</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#billing" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2z" />
<path d="M3 9.5h18" />
<path d="M6.5 15h3" />
</svg>
</span>
<span class="eq-side-nav-label">Billing</span>
</a>
</li>
<li class="eq-side-nav-item">
<details class="eq-side-nav-group">
<summary class="eq-side-nav-summary">
<span class="eq-side-nav-icon" aria-hidden="true">
<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 3.5l7.5 3V12c0 4.5-3.2 7.6-7.5 9-4.3-1.4-7.5-4.5-7.5-9V6.5z" />
</svg>
</span>
<span class="eq-side-nav-label">Security</span>
<svg class="eq-side-nav-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
</summary>
<ul class="eq-side-nav-sublist">
<li class="eq-side-nav-item">
<a href="#sessions" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Sessions</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#api-keys" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">API keys</span>
</a>
</li>
</ul>
</details>
</li>
</ul>
</div>
</nav>Examples
Documentation
Groups are details elements, so they open without JavaScript; open the one that holds the current page and mark it current so it stays marked when closed.
Code
import { SideNav, SideNavGroup, SideNavItem, SideNavSection } from "@/components/equip/side-nav";
export default function Example() {
return (
<SideNav label="Documentation" style={{ width: "100%", maxWidth: 240 }}>
<SideNavSection label="Get started">
<SideNavItem href="#introduction" icon="book-open">
Introduction
</SideNavItem>
<SideNavItem href="#quickstart" icon="rocket">
Quickstart
</SideNavItem>
</SideNavSection>
<SideNavSection label="Guides">
<SideNavGroup label="Deployments" icon="cloud-upload" defaultOpen current>
<SideNavItem href="#previews">Preview deployments</SideNavItem>
<SideNavItem href="#rollbacks" current>
Rollbacks
</SideNavItem>
<SideNavItem href="#regions">Regions</SideNavItem>
</SideNavGroup>
<SideNavGroup label="Data" icon="database">
<SideNavItem href="#postgres">Postgres</SideNavItem>
<SideNavItem href="#object-storage">Object storage</SideNavItem>
</SideNavGroup>
<SideNavItem href="#observability" icon="activity" badge="New">
Observability
</SideNavItem>
</SideNavSection>
</SideNav>
);
}HTMLworks in any framework, with the part's CSS
<nav aria-label="Documentation" style="width:100%;max-width:240px" class="eq-side-nav">
<div class="eq-side-nav-section">
<span class="eq-side-nav-section-label" aria-hidden="true">Get started</span>
<ul class="eq-side-nav-list" aria-label="Get started">
<li class="eq-side-nav-item">
<a href="#introduction" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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 6.5v14" />
<path d="M12 6.5C10 5 7 4.5 3 4.5v13c4 0 7 .5 9 3" />
<path d="M12 6.5c2-1.5 5-2 9-2v13c-4 0-7 .5-9 3" />
</svg>
</span>
<span class="eq-side-nav-label">Introduction</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#quickstart" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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>
<span class="eq-side-nav-label">Quickstart</span>
</a>
</li>
</ul>
</div>
<div class="eq-side-nav-section">
<span class="eq-side-nav-section-label" aria-hidden="true">Guides</span>
<ul class="eq-side-nav-list" aria-label="Guides">
<li class="eq-side-nav-item">
<details class="eq-side-nav-group" open data-current="true">
<summary class="eq-side-nav-summary">
<span class="eq-side-nav-icon" aria-hidden="true">
<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="M7.5 16A4 4 0 0 1 7 8.03A5.5 5.5 0 0 1 17.96 8.63A3.75 3.75 0 0 1 17 16" />
<path d="M12 21V10" />
<path d="M9.5 12.5 12 10l2.5 2.5" />
</svg>
</span>
<span class="eq-side-nav-label">Deployments</span>
<svg class="eq-side-nav-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
</summary>
<ul class="eq-side-nav-sublist">
<li class="eq-side-nav-item">
<a href="#previews" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Preview deployments</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#rollbacks" class="eq-side-nav-link" aria-current="page">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Rollbacks</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#regions" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Regions</span>
</a>
</li>
</ul>
</details>
</li>
<li class="eq-side-nav-item">
<details class="eq-side-nav-group">
<summary class="eq-side-nav-summary">
<span class="eq-side-nav-icon" aria-hidden="true">
<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="M4 5.5a8 2.5 0 1 0 16 0a8 2.5 0 1 0-16 0" />
<path d="M4 5.5v13a8 2.5 0 0 0 16 0v-13" />
<path d="M4 12a8 2.5 0 0 0 16 0" />
</svg>
</span>
<span class="eq-side-nav-label">Data</span>
<svg class="eq-side-nav-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
</summary>
<ul class="eq-side-nav-sublist">
<li class="eq-side-nav-item">
<a href="#postgres" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Postgres</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#object-storage" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Object storage</span>
</a>
</li>
</ul>
</details>
</li>
<li class="eq-side-nav-item">
<a href="#observability" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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="M2.5 12h4l3-7.5 5 15 3-7.5h4" />
</svg>
</span>
<span class="eq-side-nav-label">Observability</span>
<span class="eq-side-nav-badge">New</span>
</a>
</li>
</ul>
</div>
</nav>Rail
On the rail each label floats beside its icon on hover and keyboard focus, and stays the link's accessible name; counts become dots, and nested items without an icon show a small dot.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SideNav, SideNavGroup, SideNavItem, SideNavSection } from "@/components/equip/side-nav";
export default function Example() {
const [rail, setRail] = useState(true);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 12 }}>
<Button size="s" variant="outline" pressed={rail} onClick={() => setRail((value) => !value)}>
Icons only
</Button>
<aside style={{ padding: 6, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)", background: "var(--eq-surface)" }}>
<SideNav label="Workspace" rail={rail} style={rail ? undefined : { width: 200 }}>
<SideNavSection label="Work">
<SideNavItem href="#inbox" icon="inbox" badge={4}>
Inbox
</SideNavItem>
<SideNavItem href="#projects" icon="folder" current>
Projects
</SideNavItem>
<SideNavItem href="#calendar" icon="calendar">
Calendar
</SideNavItem>
<SideNavGroup label="Reports" icon="bar-chart" defaultOpen>
<SideNavItem href="#weekly">Weekly summary</SideNavItem>
<SideNavItem href="#revenue">Revenue</SideNavItem>
</SideNavGroup>
</SideNavSection>
<SideNavSection label="Account">
<SideNavItem href="#settings" icon="settings">
Settings
</SideNavItem>
</SideNavSection>
</SideNav>
</aside>
</div>
);
}In an app layout
Put it in an aside or your own panel. Sidebar is already a nav landmark, so nesting this inside it would give two navs for one list.
Code
import { SideNav, SideNavGroup, SideNavItem, SideNavSection } from "@/components/equip/side-nav";
export default function Example() {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 200px) minmax(120px, 1fr)",
width: "100%",
maxWidth: 520,
minHeight: 220,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
overflow: "hidden",
}}
>
<aside style={{ padding: 8, borderInlineEnd: "1px solid var(--eq-line)", background: "var(--eq-surface)" }}>
<SideNav label="Store admin">
<SideNavSection>
<SideNavItem href="#orders" icon="receipt" badge={18}>
Orders
</SideNavItem>
<SideNavGroup label="Products" icon="package" defaultOpen current>
<SideNavItem href="#all-products" current>
All products
</SideNavItem>
<SideNavItem href="#inventory">Inventory</SideNavItem>
</SideNavGroup>
<SideNavItem href="#customers" icon="users">
Customers
</SideNavItem>
</SideNavSection>
</SideNav>
</aside>
<div style={{ padding: 16, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
<p style={{ margin: "0 0 4px", fontWeight: "var(--eq-weight-strong)", color: "var(--eq-ink)" }}>All products</p>
<p style={{ margin: 0 }}>214 products, 9 low on stock.</p>
</div>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:minmax(0, 200px) minmax(120px, 1fr);width:100%;max-width:520px;min-height:220px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-l);overflow:hidden">
<aside style="padding:8px;border-inline-end:1px solid var(--eq-line);background:var(--eq-surface)">
<nav aria-label="Store admin" class="eq-side-nav">
<div class="eq-side-nav-section">
<ul class="eq-side-nav-list">
<li class="eq-side-nav-item">
<a href="#orders" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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.5 20.5V5A1.5 1.5 0 0 1 7 3.5h10A1.5 1.5 0 0 1 18.5 5v15.5l-2.17-1.5-2.16 1.5-2.17-1.5-2.17 1.5-2.16-1.5z" />
<path d="M9 8h6M9 11.5h6M9 15h3.5" />
</svg>
</span>
<span class="eq-side-nav-label">Orders</span>
<span class="eq-side-nav-badge">18</span>
</a>
</li>
<li class="eq-side-nav-item">
<details class="eq-side-nav-group" open data-current="true">
<summary class="eq-side-nav-summary">
<span class="eq-side-nav-icon" aria-hidden="true">
<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 3l8.5 4.5v9L12 21l-8.5-4.5v-9z" />
<path d="M3.5 7.5 12 12l8.5-4.5M12 12v9" />
<path d="M7.75 5.25l8.5 4.5" />
</svg>
</span>
<span class="eq-side-nav-label">Products</span>
<svg class="eq-side-nav-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 4l4 4-4 4" />
</svg>
</summary>
<ul class="eq-side-nav-sublist">
<li class="eq-side-nav-item">
<a href="#all-products" class="eq-side-nav-link" aria-current="page">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">All products</span>
</a>
</li>
<li class="eq-side-nav-item">
<a href="#inventory" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<span class="eq-side-nav-dot"></span>
</span>
<span class="eq-side-nav-label">Inventory</span>
</a>
</li>
</ul>
</details>
</li>
<li class="eq-side-nav-item">
<a href="#customers" class="eq-side-nav-link">
<span class="eq-side-nav-icon" aria-hidden="true">
<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 8a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M3 20v-.5a5 5 0 0 1 5-5h2a5 5 0 0 1 5 5v.5" />
<path d="M15.5 5.2a3 3 0 0 1 0 5.6M17.5 14.6a5 5 0 0 1 3.5 4.9v.5" />
</svg>
</span>
<span class="eq-side-nav-label">Customers</span>
</a>
</li>
</ul>
</div>
</nav>
</aside>
<div style="padding:16px;font-size:var(--eq-text-s);color:var(--eq-ink-2)">
<p style="margin:0 0 4px;font-weight:var(--eq-weight-strong);color:var(--eq-ink)">All products</p>
<p style="margin:0">214 products, 9 low on stock.</p>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | SideNav: names the landmark, such as "Settings". |
rail | boolean | false | SideNav: shows icons only; each label floats beside its icon on hover and focus. |
...rest | ComponentProps<"nav"> | none | SideNav: every native nav attribute. |
SideNavSection label | string | none | A small heading over the list, also the list's accessible name. Hidden on the rail. |
SideNavItem href | string | none | Where the link goes. |
SideNavItem icon | IconName | ReactElement | none | A 16px icon. Nested items may leave it out; on the rail they show a dot. |
SideNavItem current | boolean | false | Marks the page in view with aria-current="page". |
SideNavItem badge | ReactNode | none | A count or short status after the label. A dot on the rail. |
SideNavItem ...rest | ComponentProps<"a"> | none | Every native anchor attribute, such as onClick for a client router. |
SideNavGroup label | ReactNode | none | The row that opens and closes the group. |
SideNavGroup icon | IconName | ReactElement | none | A 16px icon for the group row. |
SideNavGroup defaultOpen | boolean | false | Starts open. Open the group that holds the current page. |
SideNavGroup current | boolean | false | The group holds the current page; its row stays marked while it is closed. |
SideNavGroup ...rest | ComponentProps<"details"> | none | Every native details attribute, including name to keep one group open at a time. |
Anatomy
| root | .eq-side-nav | The nav. data-rail in rail mode. |
|---|---|---|
| section | .eq-side-nav-section | |
| section label | .eq-side-nav-section-label | Hidden from assistive tech, which hears it as the list's name instead. |
| list | .eq-side-nav-list | |
| item | .eq-side-nav-item | The li around a link or a group. |
| link | .eq-side-nav-link | |
| icon | .eq-side-nav-icon | |
| dot | .eq-side-nav-dot | Stands in for a missing icon on the rail. |
| label | .eq-side-nav-label | On the rail it floats beside the icon as a small name. |
| badge | .eq-side-nav-badge | A dot on the icon's corner on the rail. |
| group | .eq-side-nav-group | A details element. |
| summary | .eq-side-nav-summary | The group's row. |
| chevron | .eq-side-nav-chevron | Turns a quarter when the group opens. |
| sublist | .eq-side-nav-sublist | The group's links, hung from a guide line. |
Keyboard
| TabShift+Tab | Moves through the links and group rows in reading order. |
|---|---|
| Enter | Follows a link. |
| EnterSpace | On a group row, opens or closes the group. |
Notes
- It is a nav landmark of plain links and native disclosures, so it uses Tab and no arrow keys, as the APG advises for site navigation.
- Use it inside an aside or your own panel. Sidebar is itself a nav with its own links and toggle; nesting this inside it would give two landmarks for one list.
- Reach for Tree when the hierarchy is data people select and act on, with arrow-key movement; this is for links to pages.
- On the rail every label stays in the DOM as the link's accessible name and only shows beside the icon on hover or keyboard focus.
- A badge's text stays in the link's name on the rail, so "Members 12" is still read even though only a dot shows.
- Groups animate their height where ::details-content is supported and open at once elsewhere.
- 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/side-nav.tsx123 lines
import type { ComponentProps, ReactElement, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import "./side-nav.css";
type IconProp = IconName | ReactElement;
function renderIcon(icon: IconProp | undefined) {
if (icon == null) return <span className="eq-side-nav-dot" />;
return typeof icon === "string" ? <Icon name={icon} size="s" /> : icon;
}
export type SideNavProps = ComponentProps<"nav"> & {
/** Names the landmark, such as "Settings" or "Documentation". */
label: string;
/** Shows icons only. Each name appears beside its icon on hover and focus. */
rail?: boolean;
};
export function SideNav({ label, rail = false, className, children, ...rest }: SideNavProps) {
return (
<nav
aria-label={label}
{...rest}
className={["eq-side-nav", className].filter(Boolean).join(" ")}
data-rail={rail || undefined}
>
{children}
</nav>
);
}
export type SideNavSectionProps = ComponentProps<"div"> & {
/** A short heading over the list. Hidden on the rail, still read out as the list's name. */
label?: string;
};
export function SideNavSection({ label, className, children, ...rest }: SideNavSectionProps) {
return (
<div {...rest} className={["eq-side-nav-section", className].filter(Boolean).join(" ")}>
{label != null && (
<span className="eq-side-nav-section-label" aria-hidden="true">
{label}
</span>
)}
<ul className="eq-side-nav-list" aria-label={label}>
{children}
</ul>
</div>
);
}
export type SideNavItemProps = ComponentProps<"a"> & {
href: string;
/** An Icon name or your own 16px element. Nested items may leave it out. */
icon?: IconProp;
/** Marks the page the person is on with aria-current="page". */
current?: boolean;
/** A count or short status after the label. On the rail it becomes a dot. */
badge?: ReactNode;
};
export function SideNavItem({ href, icon, current = false, badge, className, children, ...rest }: SideNavItemProps) {
return (
<li className="eq-side-nav-item">
<a
{...rest}
href={href}
className={["eq-side-nav-link", className].filter(Boolean).join(" ")}
aria-current={current ? "page" : undefined}
>
<span className="eq-side-nav-icon" aria-hidden="true">
{renderIcon(icon)}
</span>
<span className="eq-side-nav-label">{children}</span>
{badge != null && <span className="eq-side-nav-badge">{badge}</span>}
</a>
</li>
);
}
export type SideNavGroupProps = Omit<ComponentProps<"details">, "children"> & {
label: ReactNode;
icon?: IconProp;
/** Starts open. Open the group that holds the current page. */
defaultOpen?: boolean;
/** The group holds the current page; its row stays marked while it is closed. */
current?: boolean;
/** SideNavItem elements, or further groups. */
children: ReactNode;
};
export function SideNavGroup({ label, icon, defaultOpen = false, current = false, className, children, ...rest }: SideNavGroupProps) {
return (
<li className="eq-side-nav-item">
<details
{...rest}
className={["eq-side-nav-group", className].filter(Boolean).join(" ")}
open={defaultOpen || undefined}
data-current={current || undefined}
>
<summary className="eq-side-nav-summary">
<span className="eq-side-nav-icon" aria-hidden="true">
{renderIcon(icon)}
</span>
<span className="eq-side-nav-label">{label}</span>
<svg
className="eq-side-nav-chevron"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 4l4 4-4 4" />
</svg>
</summary>
<ul className="eq-side-nav-sublist">{children}</ul>
</details>
</li>
);
}components/equip/side-nav.css309 lines
@layer equip {
.eq-side-nav {
--_row: var(--eq-h-s);
display: flex;
flex-direction: column;
gap: var(--eq-space-4);
min-width: 0;
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
interpolate-size: allow-keywords;
}
.eq-side-nav-section {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-side-nav-section-label {
padding-inline: var(--eq-space-2);
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
line-height: 20px;
}
.eq-side-nav-list,
.eq-side-nav-sublist {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-side-nav-item {
display: block;
min-width: 0;
}
/* Nested lists hang from a guide line under the parent's icon. */
.eq-side-nav-sublist {
margin-block-start: 2px;
margin-inline-start: calc(var(--eq-space-2) + 7.5px);
padding-inline-start: calc(var(--eq-space-2) - 0.5px);
border-inline-start: 1px solid var(--eq-line);
}
.eq-side-nav-link,
.eq-side-nav-summary {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--_row);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
color: inherit;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-side-nav-summary {
list-style: none;
user-select: none;
}
.eq-side-nav-summary::-webkit-details-marker {
display: none;
}
.eq-side-nav-icon {
flex: none;
display: grid;
place-items: center;
width: 16px;
height: 16px;
}
.eq-side-nav-icon > svg {
width: 16px;
height: 16px;
}
/* Stands in for a missing icon on the rail; in the full list the label is enough. */
.eq-side-nav-dot {
width: 5px;
height: 5px;
border-radius: var(--eq-radius-full);
background: currentColor;
}
.eq-side-nav:not([data-rail]) .eq-side-nav-icon:has(> .eq-side-nav-dot) {
display: none;
}
.eq-side-nav-label {
flex: 1;
min-width: 0;
padding-block: 5px;
overflow-wrap: anywhere;
}
.eq-side-nav-badge {
flex: none;
box-sizing: border-box;
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
padding-inline: var(--eq-space-1);
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: var(--eq-ink);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.eq-side-nav-chevron {
flex: none;
width: 14px;
height: 14px;
color: var(--eq-ink-3);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-side-nav-chevron:dir(rtl) {
scale: -1 1;
}
.eq-side-nav-group[open] > .eq-side-nav-summary .eq-side-nav-chevron {
rotate: 90deg;
}
.eq-side-nav-group[open] > .eq-side-nav-summary .eq-side-nav-chevron:dir(rtl) {
rotate: -90deg;
}
.eq-side-nav-link[aria-current="page"] {
background: var(--eq-tint-strong);
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
/* A closed group still says the current page is inside it. */
.eq-side-nav-group[data-current]:not([open]) > .eq-side-nav-summary {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
@media (hover: hover) {
.eq-side-nav-link:hover,
.eq-side-nav-summary:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
.eq-side-nav-link[aria-current="page"]:hover {
background: var(--eq-tint-strong);
}
}
.eq-side-nav-link:active,
.eq-side-nav-summary:active {
scale: var(--eq-press);
}
.eq-side-nav-link:focus-visible,
.eq-side-nav-summary:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
/* Groups open and close by animating their content's height where the engine allows it. */
@supports selector(::details-content) {
/* Clipped on the block axis only, so floating names on the rail can still reach out sideways. */
.eq-side-nav-group::details-content {
block-size: 0;
overflow-x: visible;
overflow-y: clip;
transition:
block-size var(--eq-dur-move) var(--eq-ease),
content-visibility var(--eq-dur-move) allow-discrete;
}
.eq-side-nav-group[open]::details-content {
block-size: auto;
}
}
/* ---- The rail ---- */
.eq-side-nav[data-rail] {
width: max-content;
gap: var(--eq-space-2);
}
.eq-side-nav[data-rail] .eq-side-nav-section + .eq-side-nav-section {
padding-top: var(--eq-space-2);
border-top: 1px solid var(--eq-line);
}
.eq-side-nav[data-rail] .eq-side-nav-section-label,
.eq-side-nav[data-rail] .eq-side-nav-chevron {
display: none;
}
.eq-side-nav[data-rail] .eq-side-nav-sublist {
margin-inline-start: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
.eq-side-nav[data-rail] :is(.eq-side-nav-link, .eq-side-nav-summary) {
justify-content: center;
width: calc(16px + 2 * var(--eq-space-2) + 4px);
min-height: calc(16px + 2 * var(--eq-space-2) + 4px);
padding: 0;
}
/* An open group on the rail sets its children on a faint tint, so they read as one set. */
.eq-side-nav[data-rail] .eq-side-nav-group[open] > .eq-side-nav-sublist {
margin-block-start: 2px;
border-radius: var(--eq-radius-s);
background: var(--eq-tint);
}
/* The label leaves the row and floats beside the icon as its name, on hover and focus.
It stays in the accessibility tree, so the link keeps its name. */
.eq-side-nav[data-rail] .eq-side-nav-label {
position: absolute;
inset-inline-start: calc(100% + var(--eq-space-2));
top: 50%;
z-index: 2;
flex: none;
width: max-content;
max-width: 240px;
padding: var(--eq-space-1) var(--eq-space-2);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
opacity: 0;
scale: 0.96;
translate: -4px -50%;
pointer-events: none;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
.eq-side-nav[data-rail] :is(.eq-side-nav-link, .eq-side-nav-summary):focus-visible .eq-side-nav-label {
opacity: 1;
scale: 1;
translate: 0 -50%;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@media (hover: hover) {
.eq-side-nav[data-rail] :is(.eq-side-nav-link, .eq-side-nav-summary):hover .eq-side-nav-label {
opacity: 1;
scale: 1;
translate: 0 -50%;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
}
.eq-side-nav[data-rail] .eq-side-nav-label:dir(rtl) {
translate: 4px -50%;
}
/* The count shrinks to a dot on the icon's corner; its text stays for screen readers. */
.eq-side-nav[data-rail] .eq-side-nav-badge {
position: absolute;
top: 5px;
inset-inline-end: 5px;
min-width: 0;
width: 8px;
height: 8px;
padding: 0;
overflow: hidden;
border-radius: var(--eq-radius-full);
background: var(--eq-ink);
box-shadow: 0 0 0 2px var(--eq-surface);
color: transparent;
}
}