Shell and nav
tier 1 — the frameThe layout chrome: shell, sidebar, nav, topbar, user widget. This must look identical in every app, and nobody — human or AI — should restyle it per project.
<AppShell> and there will not be one — copy the markup below. This site is
built from exactly these classes, so a regression in the frame shows up here first. Do not add
local overrides for the frame: report frame problems against the library.
Full shell
.layout is the flex row that fills the viewport; .content is
the column beside the sidebar; .page is the only scroll container. Shown
here at a fixed height — in an app the layout fills the window.
Approval queue
Items the AI proposed and is waiting on a human decision for.
Reservation: reroute to EU-West (confidence 0.87).
<div class="layout" style="height:460px">
<aside class="sidebar">
<div class="brand">
<div class="brand-logo" style="background:var(--brand)"></div>
<div class="brand-text">
<strong>Approval Console</strong>
<span class="brand-sub">Northwind Retail</span>
</div>
</div>
<nav class="nav">
<div class="nav-scroll">
<a class="nav-link active" href="#"><i class="ri-inbox-line"></i><span>Queue</span><span class="nav-count">3</span></a>
<a class="nav-link" href="#"><i class="ri-chat-3-line"></i><span>Orders</span></a>
<a class="nav-link" href="#"><i class="ri-check-double-line"></i><span>Decided</span></a>
<div class="nav-section">
<span class="nav-section-label">Administration</span>
<a class="nav-link" href="#"><i class="ri-robot-line"></i><span>Automation</span></a>
<a class="nav-link" href="#"><i class="ri-settings-3-line"></i><span>Settings</span></a>
</div>
<a class="nav-status-card nav-status-card--ok" href="#">
<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>
<a class="nav-status-card nav-status-card--fail" href="#">
<span class="nav-status-label">MySQL</span>
<span class="nav-status-row">
<span class="nav-status-dot"></span>
<span class="nav-status-state">Unreachable</span>
</span>
</a>
</div>
<div class="nav-tools">
<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>
<div class="content">
<header class="topbar">
<button class="topbar-btn topbar-btn--start" aria-label="Collapse sidebar" data-tip="Collapse the sidebar to an icon rail."><i class="ri-side-bar-line"></i></button>
<div class="topbar-spacer"></div>
<div class="topbar-section">
<span class="health-badge health-badge--healthy"><span class="health-dot"></span> Live</span>
<span class="health-badge health-badge--degraded"><span class="health-dot"></span> Degraded</span>
</div>
<button class="topbar-btn" aria-label="Help" data-tip="What is this console?"><i class="ri-question-line"></i></button>
<div class="user-widget">
<button class="user-trigger">
<span class="user-avatar"><i class="ri-user-line"></i></span>
<span class="user-info">
<span class="user-name">Alex Fischer</span>
<span class="user-email">alex.fischer@example.com</span>
</span>
</button>
<a class="user-signout" href="#" aria-label="Sign out" data-tip="Sign out of the console."><i class="ri-logout-box-r-line"></i></a>
</div>
</header>
<div class="page">
<a class="back-link" href="#"><i class="ri-arrow-left-line"></i> Back to the queue</a>
<h1>Approval queue</h1>
<p class="lede">Items the AI proposed and is waiting on a human decision for.</p>
<div class="card">
<div class="card-head">
<strong>ORD-4209</strong>
<span class="badge badge-warn">Pending</span>
</div>
<div class="card-body">
<p class="form-hint">Reservation: reroute to EU-West (confidence 0.87).</p>
</div>
</div>
</div>
</div>
</div>
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 class colours the item; aria-current is what is announced.
Matching drops the query string and the fragment and ignores a trailing slash, and a prefix
match must end on a path segment — so /queue does not light up on
/queue-archive. The link to the root needs
NavLinkMatch.All, or it is active everywhere.
The helpers are pure and do not subscribe to LocationChanged. A page
re-rendered by navigation picks up the new state for free; a sidebar that survives
navigation subscribes and calls StateHasChanged — this site's own sidebar is
the worked example.
Subscribe in the component that renders the links, not in the layout around it. When a parent re-renders, Blazor only hands new parameters to a child whose parameters actually differ, so a sidebar whose parameters are unchanged is skipped and goes on rendering the previous address. A subscription one level too high compiles and runs and does nothing: the active link then updates on the next unrelated click rather than on navigation.
@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>
Header search
.search goes at the start of the topbar, after the nav toggles and before
.topbar-spacer. The markup above is the whole of what an app writes: the
icon, the input and the clear button. Below 720px the box is hidden, so an app needs
another way in on a phone.
data-search hands the box to drSimpleUi.search, which builds the
result panel, ranks against the index the app registered, and implements the keyboard —
arrows, Home/End, Enter to open,
Escape to close and then to clear. The index is client-side:
register a fixed, known set once and results appear with no round trip. Searching a
database is the app's own job — render the panel below with these classes and leave
data-search off.
The clear button is shown by CSS on :placeholder-shown, so it needs no state
and works with scripting blocked. It does need the input to carry a placeholder.
The demo above is live and shares this site's own index — type in it.
<header class="topbar topbar--responsive">
<button class="topbar-btn topbar-btn--start" aria-label="Open navigation"><i class="ri-menu-line"></i></button>
<div class="search">
<i class="ri-search-line search-icon" aria-hidden="true"></i>
<input class="search-input" type="search" data-search autocomplete="off" spellcheck="false"
placeholder="Search…" aria-label="Search" />
<button class="search-clear" type="button" aria-label="Clear the search" tabindex="-1">
<i class="ri-close-line"></i>
</button>
</div>
<div class="topbar-spacer"></div>
<div class="topbar-section">
<span class="health-badge health-badge--healthy"><span class="health-dot"></span> Live</span>
</div>
</header>
Result panel
One .search-item per result: a .search-item-title, and a
.search-item-meta line carrying a .text-mono identifier, a
line of context and an optional .search-tag (--warn for
something that needs attention). .search-item--sel is where the keyboard is;
.search-status is the busy and empty line.
.dr-scroll belongs on the panel: it scrolls past eight rows, and the OS
default bar is the one thing on it that would not follow the theme.
An <a> rather than a button, so middle-click and “open in a new tab”
work and a router can intercept the click. role="option" inside
role="listbox" is what makes aria-selected legal, and the box
keeps focus while the highlight moves — which is what
aria-activedescendant is for.
<!-- What drSimpleUi.search builds, and what an app renders itself when the
results come from a database rather than a registered index.
The panel is appended to <body> with position: fixed, and its top, left and
width are set from the box it belongs to. A panel nested inside .search
would be trapped in .topbar's stacking context at z-index 60, under every
modal on the page. -->
<div class="search-panel dr-scroll" role="listbox" aria-label="Results">
<a class="search-item search-item--sel" role="option" aria-selected="true" tabindex="-1" href="/order/4182">
<span class="search-item-title">Mailbox quota exceeded on EXCH-02</span>
<span class="search-item-meta">
<span class="text-mono">TPC-4182</span>
<span>Kreisverwaltung Ahrweiler</span>
<span class="search-tag search-tag--warn">needs a human</span>
</span>
</a>
<a class="search-item" role="option" aria-selected="false" tabindex="-1" href="/order/3907">
<span class="search-item-title">VPN certificate renewal</span>
<span class="search-item-meta">
<span class="text-mono">TPC-3907</span>
<span>Stadtwerke Neuwied</span>
<span class="search-tag">closed</span>
</span>
</a>
<!-- While the results are being fetched, and when there are none. -->
<div class="search-status">No order matches “quota”.</div>
</div>
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 56px 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>Approval 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>
Narrow screens
Add .layout--responsive to .layout and the sidebar becomes the
rail below 900px, whether or not .collapsed is set. The user widget drops its
second line at 900px and its whole text block at 560px, leaving the avatar.
It is opt-in. An app gets the narrow-screen rail only by adding the modifier.
The modifier also outranks an app that sets .sidebar { width: 260px }
unconditionally, which a bare @media rule would not.
Below 900px the rail is forced, so a collapse toggle does nothing. Hide the toggle at that width, or leave it and accept the no-op.
A header that is not inside a .layout — in .bare-layout, in
.auth-layout, or on its own — opts in with .topbar--responsive on
the .topbar instead, which reaches the same user-widget rules. A topbar's own
narrow behaviour does not depend on which shell it is in.
<div class="layout layout--responsive">
<aside class="sidebar">…</aside>
<div class="content">…</div>
</div>
User menu
The panel is the library's one dropdown style, .menu — see
Menus for its items, labels and separators. There is no second panel
style for the header. Only the anchoring belongs to the frame:
.user-widget is position: relative, so the panel hangs off the
header's bottom edge with nothing measured.
Opening it, the outside click and Escape are your app's state — a
bool and an @onkeydown, with no JavaScript.
It is a disclosure, not an ARIA menu: the items are ordinary links and buttons that
tab normally. Do not declare role="menu" — it promises arrow-key navigation and a
roving tabindex that this markup does not implement.
Render .menu-scrim immediately before the panel: a transparent full-viewport
element that catches the click dismissing the menu. It carries no z-index — coming first in
the markup is what puts the panel above it. The demo omits it, because a fixed element
covering the viewport would swallow every click on this page.
<div style="padding-bottom:200px">
<header class="topbar topbar--responsive">
<button class="topbar-btn topbar-btn--start" type="button" aria-label="Collapse navigation"
data-tip="Collapse the sidebar to an icon rail.">
<i class="ri-side-bar-line"></i>
</button>
<div class="search" style="max-width:240px">
<i class="ri-search-line search-icon" aria-hidden="true"></i>
<input class="search-input" type="search" placeholder="Search orders…" aria-label="Search orders" />
</div>
<div class="topbar-spacer"></div>
<span class="health-badge health-badge--live"><span class="health-dot"></span> Live</span>
<button class="topbar-btn" type="button" aria-label="Light theme" data-tip="Switch theme.">
<i class="ri-sun-line"></i>
</button>
<div class="user-widget">
<button class="user-trigger" type="button" aria-expanded="true">
<span class="user-avatar">AF</span>
<span class="user-info">
<span class="user-name">Alex Fischer</span>
<span class="user-email">alex.fischer@example.com</span>
</span>
</button>
<div class="menu">
<span class="menu-label">Signed in as admin</span>
<a class="menu-item" href="#"><i class="ri-user-settings-line"></i> Profile</a>
<a class="menu-item" href="#"><i class="ri-settings-3-line"></i> Preferences</a>
<hr class="menu-sep" />
<button class="menu-item menu-item--danger" type="button">
<i class="ri-logout-box-r-line"></i> Sign out
</button>
</div>
</div>
</header>
</div>
Bare shell
.bare-layout — topbar and body, no sidebar. For sign-in, access-denied and
error pages, where there is no navigation to offer yet.
<div class="bare-layout" style="height:280px">
<header class="topbar">
<div class="bare-brand">
<div class="brand-logo" style="background:var(--brand)"></div>
<span>Acme Ops</span>
</div>
</header>
<div class="page">
<div class="card" style="max-width:440px">
<div class="empty-state">
<i class="ri-lock-line"></i>
<span class="empty-state-title">You do not have access</span>
<p>Your account is not a member of any role in this workspace. Ask an
administrator to grant you the <code>viewer</code> role.</p>
<button class="btn btn-sm"><i class="ri-mail-send-line"></i> Request access</button>
</div>
</div>
</div>
</div>
Reconnect banner
Four rows, shown together. Outside #components-reconnect-modal a
.reconnect-banner is visible on its own; inside it, one row shows at a time. The
banner keeps its dark palette in the light theme.
Blazor sets the state class on the modal — you write the rows and never the classes. Six states map onto these four:
| Blazor sets | The row that shows |
|---|---|
components-reconnect-show | .reconnect-attempting |
components-reconnect-retrying | .reconnect-attempting, with its counter revealed |
components-reconnect-paused | .reconnect-paused, or the attempting row if there is none |
components-reconnect-failed | .reconnect-failed |
components-reconnect-resume-failed | .reconnect-failed |
components-reconnect-rejected | .reconnect-rejected |
components-reconnect-hide | none — connected |
.reconnect-paused is optional; without it a paused circuit shows the attempting
row. .reconnect-attempting, .reconnect-failed and
.reconnect-rejected are not: omit one and that state renders as an empty bar.
.reconnect-attempt is the counter, hidden until there is a number in it. Give one
span the id components-reconnect-current-attempt and another
components-reconnect-max-retries and Blazor fills both in.
<div class="reconnect-banner reconnect-attempting">
<i class="ri-wifi-off-line"></i>
<span>
Connection lost. Reconnecting…
<span class="reconnect-attempt">attempt 3 of 8</span>
</span>
</div>
<div class="reconnect-banner reconnect-paused">
<i class="ri-pause-circle-line"></i>
<span>Paused. Your work is held on the server.</span>
</div>
<div class="reconnect-banner reconnect-failed">
<i class="ri-close-circle-line"></i><span>Could not reconnect.</span>
<button class="btn btn-sm btn-danger"><i class="ri-refresh-line"></i> Retry</button>
</div>
<div class="reconnect-banner reconnect-rejected">
<i class="ri-error-warning-line"></i><span>This session has expired on the server.</span>
<button class="btn btn-sm btn-danger"><i class="ri-refresh-line"></i> Reload</button>
</div>
Reconnect banner in the host page
Paste this into App.razor, inside <body> and before the
component that carries the render mode. Supply it, or Blazor injects an unstyled box of its
own. This is the block this site runs, and a test compares the two.
The reload buttons are plain onclick handlers: the circuit is gone in those
states, so a Blazor handler cannot run.
<div id="components-reconnect-modal">
<div class="reconnect-banner reconnect-attempting">
<i class="ri-wifi-off-line"></i>
<span>
Connection lost. Reconnecting…
<!-- Blazor fills these two by id, if they are there. -->
<span class="reconnect-attempt">
attempt <span id="components-reconnect-current-attempt">1</span>
of <span id="components-reconnect-max-retries">8</span>
</span>
</span>
</div>
<div class="reconnect-banner reconnect-paused">
<i class="ri-pause-circle-line"></i>
<span>Paused. Your work is held on the server.</span>
</div>
<div class="reconnect-banner reconnect-failed">
<i class="ri-close-circle-line"></i><span>Could not reconnect.</span>
<button class="btn btn-sm btn-danger" onclick="location.reload()">
<i class="ri-refresh-line"></i> Retry
</button>
</div>
<div class="reconnect-banner reconnect-rejected">
<i class="ri-error-warning-line"></i><span>This session has expired on the server.</span>
<button class="btn btn-sm btn-danger" onclick="location.reload()">
<i class="ri-refresh-line"></i> Reload
</button>
</div>
</div>
data-tip="what it does and its consequence" to
actionable controls, plus aria-label on icon-only buttons.
DR.Simple_UI.js renders one bubble for the whole page through event delegation, so
content rendered later needs no wiring. Use
data-tip-pos="left|right|top|bottom" to set a side.
Elements inside .sidebar are skipped; the collapsed rail uses a CSS flyout instead.