A vertical nav rail — destinations, not actions.
.iz-nav / .iz-nav-item is the canonical pattern for an
application shell's sidebar or compact icon rail — extracted into the design
system from Irozon Tools' own sidebar, the first product to need one. Every item
is a real <a> with native aria-current="page" for
the active route — never a button or a JS click handler standing in for a link.
An action that happens to live in the same rail (collapse/expand, sign out) is
not a nav item; use .iz-button
(icon-only) for those instead — see
Navigation vs. action below.
<nav class="iz-nav" aria-label="...">, optionally grouped into .iz-nav-group blocks with an .iz-nav-group__label..iz-nav-item on the <a>, containing a .iz-nav-item__icon and .iz-nav-item__label.data-collapsed="true" on the <nav> — every item becomes an icon-only square slot, no separate markup needed.<nav class="iz-nav" aria-label="Primary">
<a class="iz-nav-item" href="/" aria-current="page">
<span class="iz-nav-item__icon" aria-hidden="true"><!-- svg/glyph/img --></span>
<span class="iz-nav-item__label">Home</span>
</a>
<div class="iz-nav-group">
<p class="iz-nav-group__label">Tools</p>
<a class="iz-nav-item" href="/invoice" title="Invoices" aria-label="Invoices">
<span class="iz-nav-item__icon" aria-hidden="true"><!-- svg --></span>
<span class="iz-nav-item__label">Invoices</span>
</a>
</div>
</nav>
One markup, one attribute, two rails.
data-collapsed="true" is the only thing that changes between these
two — same HTML, same classes. Labels don't disappear from the DOM (they're
visually hidden, not display: none), and every icon slot is a fixed
square driven by flex + inline-size +
block-size + aspect-ratio: 1 together — deliberately
redundant, because a flex item's flex-basis silently overrides a
plain width/inline-size on the main axis. The first
version of this pattern (built directly in Irozon Tools, before it moved here)
set only inline-size in its collapsed override and left the
inherited flex: 0 0 20px in place — every logo rendered as a
20×32px pill instead of a 32×32px square. Setting all four properties together
is what makes that failure mode structurally impossible, not just fixed once.
Active is the straight outer-edge accent bar. Hover and focus are quieter, and never each other.
Three states, three distinct signals — never doubled up. The canonical active treatment is a straight vertical Green bar on the outer edge of the nav item, separate from the square icon slot; never wrap the icon in a rounded or bracket-style accent.
aria-current="page"), elevated background, bold label. In the collapsed rail the bar is still the signal — the icon slot itself never gets a green border/ring for "active."--color-border-strong slot border (collapsed) — never Green. Green on hover would read as "you're already here.":focus-visible only — the same 2px --color-focus outline .iz-button uses. Never triggered by a mouse click, never confused with "active route."Three kinds of content, one fixed square.
.iz-nav-item__icon accepts a Tabler outline inline SVG (2px stroke,
inherits currentColor the same way .iz-button's icons
do), a plain text/emoji glyph, or an <img> brand logo — the
slot's own size never changes, and content sizes down to fit via
object-fit: contain, never up.
<img> child automatically gets a fixed white backdrop (:has(img)) — brand artwork isn't theme-aware and would otherwise vanish in dark mode. This is deliberate for every logo, not a per-product override.object-fit: contain — a wide or tall mark occupies less of the square rather than stretching or cropping to fill it..iz-nav-item__external (a small ↗ mark) inside __label, plus target="_blank" rel="noopener noreferrer" and an accessible name that says "opens in a new tab" — the icon slot itself doesn't change for external links.Native semantics first.
<a href> (or a router's Link rendering one) — full keyboard support, correct browser context-menu behavior ("open in new tab"), no click-handler reimplementation.aria-current="page" — a screen reader announces "current page," not just a visual accent.aria-label and a title for a mouse tooltip; the component does not infer one from icon content..iz-nav-item__icon is always aria-hidden="true" — the accessible name comes from the item's own label/aria-label, never from icon/logo alt text.:focus-visible never reuses the active-route accent, and the active accent never appears on focus alone.prefers-reduced-motion: reduce — state still changes immediately.Nav, NavGroup and NavItem from @irozon/design-system-react.
NavItem renders a plain <a> by default; pass your
router's own Link component via as to get real client-side
navigation while keeping this component's markup, classes, and
aria-current handling.
import { Link, useRouterState } from "@tanstack/react-router";
import { Nav, NavGroup, NavItem } from "@irozon/design-system-react";
const pathname = useRouterState({ select: (s) => s.location.pathname });
<Nav aria-label="Primary" collapsed={collapsed}>
<NavGroup label="Tools">
<NavItem as={Link} to="/invoice" active={pathname === "/invoice"} icon={<InvoiceIcon />}>
Invoices
</NavItem>
</NavGroup>
</Nav>