Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
DR.Simple_UI
Showing main. This can be ahead of the version your app has installed — check what a class says it needs before copying it. Releases (opens in a new tab)

Shell and nav

tier 1 — the frame

The 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.

The frame is markup, not components. There is no <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.

Live Degraded
Back to the queue

Approval queue

Items the AI proposed and is waiting on a human decision for.

ORD-4209 Pending

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>

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 &amp; 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>

.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.

Live
<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.

Live
<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.

Acme Ops
You do not have access

Your account is not a member of any role in this workspace. Ask an administrator to grant you the viewer role.

<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 setsThe 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-hidenone — 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.

Connection lost. Reconnecting… attempt 3 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
<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>
Hover hints. Add 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.