Sidebar and nav
tier 1 — the frameEverything inside the sidebar: the links and what can hang off them, which one is current, the groups that collapse, and the icon rail the whole thing shrinks to.
Nav items
Every kind of row a sidebar nav can hold, in one nav — copy the rows you need and delete
the rest. Every link carries an icon, because the collapsed rail drops the
label and leaves a link without one as a blank 56px box (a .nav-group's
children excepted — the group's own icon stands for them); .nav-count and
.nav-link-ext both take margin-inline-start: auto, so a row
carries one or the other and never both.
<aside class="sidebar" style="height:560px; overflow:hidden">
<a class="brand" href="#">
<span class="brand-logo" style="background:var(--brand)"></span>
<span class="brand-text"><strong>Acme Ops</strong><span class="brand-sub">fulfilment</span></span>
</a>
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link" href="#"><i class="ri-dashboard-line"></i><span>Overview</span></a>
<a class="nav-link active" aria-current="page" href="#">
<i class="ri-inbox-line"></i><span>Orders</span><span class="nav-count">12</span>
</a>
<a class="nav-link" href="#"><i class="ri-truck-line"></i><span>Shipments</span></a>
<details class="nav-group" open>
<summary><i class="ri-building-line"></i><span>Warehouses</span></summary>
<a class="nav-link" href="#"><span>Rotterdam</span><span class="nav-count">4</span></a>
<a class="nav-link" href="#"><span>Duisburg</span></a>
<a class="nav-link" href="#"><span>Lyon</span></a>
</details>
<div class="nav-section">
<span class="nav-section-label">Administration</span>
<a class="nav-link" href="#"><i class="ri-key-2-line"></i><span>API keys</span></a>
<a class="nav-link" href="#"><i class="ri-team-line"></i><span>Members</span></a>
<details class="nav-group">
<summary><i class="ri-refresh-line"></i><span>Directory sync</span></summary>
<a class="nav-link" href="#"><span>Field mapping</span></a>
<a class="nav-link" href="#"><span>Change log</span></a>
</details>
</div>
<a class="nav-status-card nav-status-card--ok" href="#">
<span class="nav-status-label">Carrier API</span>
<span class="nav-status-row">
<span class="nav-status-dot"></span>
<span class="nav-status-state">Connected</span>
</span>
</a>
<a class="nav-status-card nav-status-card--fail" href="#">
<span class="nav-status-label">Label printer</span>
<span class="nav-status-row">
<span class="nav-status-dot"></span>
<span class="nav-status-state">Offline</span>
</span>
</a>
</div>
<div class="nav-tools">
<a class="nav-link nav-link-tool" href="#"><i class="ri-question-line"></i><span>Support</span></a>
<hr class="nav-tools-sep" />
<a class="nav-link nav-link-tool" href="#">
<i class="ri-book-2-line"></i><span>Documentation</span><i class="ri-external-link-line nav-link-ext"></i>
</a>
</div>
</nav>
</aside>
A logo image in the brand tile
.brand-logo sizes and clips whatever is in it, so an app's own logo goes in as a
plain <img> carrying no width, height or radius of its own — the tile is
30px and rounded, and the image fills it whatever its intrinsic size is. An image that is not
square is cropped rather than squashed, so supply a square asset. Leave
alt empty where the brand text beside it already names the app, or the name is
announced twice.
<aside class="sidebar" style="height:132px; overflow:hidden">
<a class="brand" href="#">
<span class="brand-logo">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Crect width='120' height='120' fill='%231E293B'/%3E%3Cpath d='M22 86 60 26l38 60z' fill='%2359C3FF'/%3E%3C/svg%3E"
alt="" width="120" height="120" />
</span>
<span class="brand-text"><strong>Northwind Retail</strong><span class="brand-sub">fulfilment</span></span>
</a>
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link" href="#"><i class="ri-dashboard-line"></i><span>Overview</span></a>
</div>
</nav>
</aside>
The active link
Which link is the current page is the one thing this markup cannot express, so the package
ships ActiveLink: Nav.CssClass(href) appends active
and Nav.AriaCurrent(href) returns "page" or null, which Blazor
omits. The link to the root needs NavLinkMatch.All or it is
active everywhere, and a sidebar that survives navigation subscribes to
LocationChanged in the component that renders the links, not
in the layout around it — Blazor skips a child whose parameters have not changed.
@inject NavigationManager Nav
<nav class="nav" style="max-width:260px">
<div class="nav-scroll">
<div class="nav-section">
<span class="nav-section-label">Frame</span>
<a class="@Nav.CssClass("/frame")" aria-current="@Nav.AriaCurrent("/frame")" href="/frame">
<i class="ri-side-bar-line"></i><span>Shell & nav</span>
</a>
<a class="@Nav.CssClass("/layouts")" aria-current="@Nav.AriaCurrent("/layouts")" href="/layouts">
<i class="ri-layout-3-line"></i><span>Layouts</span>
</a>
@* The root link needs NavLinkMatch.All. With the default Prefix it is
active on every page — the same trap the framework's NavLink has. *@
<a class="@Nav.CssClass("", match: NavLinkMatch.All)"
aria-current="@Nav.AriaCurrent("", NavLinkMatch.All)" href="">
<i class="ri-home-4-line"></i><span>Overview</span>
</a>
</div>
</div>
</nav>
Collapsible nav group
A <details>/<summary> pair, so the open state, the
keyboard operation and the announcement come from the platform and the group survives
scripting being blocked. Put open on the group containing the current page, or
a reader lands on a page whose own nav entry is hidden; the depth every row in the nav
reads is one knob, --nav-indent.
<aside class="sidebar" style="height:460px">
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link" href="#"><i class="ri-inbox-line"></i><span>Orders</span></a>
<details class="nav-group" open>
<summary><i class="ri-building-line"></i><span>Fulfilment</span></summary>
<a class="nav-link active" href="#"><span>Ready to pick</span><span class="nav-count">41</span></a>
<a class="nav-link" href="#"><span>On hold</span></a>
</details>
<div class="nav-section">
<span class="nav-section-label">Administration</span>
<a class="nav-link" href="#"><i class="ri-key-2-line"></i><span>API keys</span></a>
<details class="nav-group" open>
<summary><i class="ri-refresh-line"></i><span>Directory sync</span></summary>
<a class="nav-link" href="#"><span>Field mapping</span></a>
<a class="nav-link" href="#"><span>Change log</span></a>
</details>
<a class="nav-link" href="#"><i class="ri-file-list-line"></i><span>Audit log</span></a>
</div>
</div>
<div class="nav-tools">
<a class="nav-link nav-link-tool" href="#"><i class="ri-question-line"></i><span>Support</span></a>
<hr class="nav-tools-sep" />
<a class="nav-link nav-link-tool" href="#"><i class="ri-book-2-line"></i><span>Documentation</span><i class="ri-external-link-line nav-link-ext"></i></a>
</div>
</nav>
</aside>
Which group is open
Live — click a link and watch the groups. open is an
attribute and no selector can set one, so the group asks ActiveLink.IsActive
over its own children — the same call the links themselves use, so the highlighted link and
the open group cannot disagree.
@implements IDisposable
@inject NavigationManager Nav
<aside class="sidebar" style="height:260px">
<nav class="nav">
<div class="nav-scroll">
<details class="nav-group" open="@AnyActive(FramePages)">
<summary><i class="ri-side-bar-line"></i><span>Frame</span></summary>
@foreach (var (href, label) in FramePages)
{
<a class="@Nav.CssClass(href, match: NavLinkMatch.All)"
aria-current="@Nav.AriaCurrent(href, NavLinkMatch.All)"
href="@href"><span>@label</span></a>
}
</details>
<details class="nav-group" open="@AnyActive(DataPages)">
<summary><i class="ri-table-line"></i><span>Data</span></summary>
@foreach (var (href, label) in DataPages)
{
<a class="@Nav.CssClass(href, match: NavLinkMatch.All)"
aria-current="@Nav.AriaCurrent(href, NavLinkMatch.All)"
href="@href"><span>@label</span></a>
}
</details>
</div>
</nav>
</aside>
@code {
private static readonly (string Href, string Label)[] FramePages =
[("/frame", "Shell and nav"), ("/layouts", "Layouts")];
private static readonly (string Href, string Label)[] DataPages =
[("/table", "Tables"), ("/stat", "Stats")];
// CSS cannot express this. `:has(.active)` selects the group, but `open` is an
// ATTRIBUTE and no selector can set one — so which group is open is a value the
// app computes, from the same ActiveLink the links themselves use.
private bool AnyActive((string Href, string Label)[] pages) =>
pages.Any(p => Nav.IsActive(p.Href, NavLinkMatch.All));
// The subscription belongs to the component that RENDERS the group, not to the
// layout around it: Blazor skips re-rendering a child whose parameters have not
// changed, so a LocationChanged handler one level up leaves the group showing the
// previous page's state.
protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged;
private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => StateHasChanged();
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
}
Collapsed rail
Add .collapsed to .sidebar — no other markup changes. Labels and
status text drop out, the count pill moves onto the icon, and each item's
data-tip becomes a CSS flyout that escapes the rail; hover an icon to see it.
Content sits beside the rail.
<div class="layout" style="height:300px">
<aside class="sidebar collapsed">
<div class="brand">
<div class="brand-logo" style="background:var(--brand)"></div>
<div class="brand-text">
<strong>Dispatch Console</strong>
<span class="brand-sub">Northwind Retail</span>
</div>
</div>
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link active" href="#" data-tip="Queue — items waiting on a decision."><i class="ri-inbox-line"></i><span>Queue</span><span class="nav-count">3</span></a>
<a class="nav-link" href="#" data-tip="Orders — every open conversation."><i class="ri-chat-3-line"></i><span>Orders</span></a>
<a class="nav-link" href="#" data-tip="Decided — the audit trail."><i class="ri-check-double-line"></i><span>Decided</span></a>
<a class="nav-status-card nav-status-card--ok" href="#" data-tip="Workflow API: connected.">
<span class="nav-status-label">Workflow API</span>
<span class="nav-status-row">
<span class="nav-status-dot"></span>
<span class="nav-status-state">Connected</span>
</span>
</a>
</div>
</nav>
</aside>
<div class="content">
<header class="topbar">
<button class="topbar-btn topbar-btn--start" aria-label="Expand sidebar"><i class="ri-side-bar-line"></i></button>
<div class="topbar-spacer"></div>
</header>
<div class="page"><p class="lede">Content sits beside the rail.</p></div>
</div>
</div>