Skip to content
KONIGI Patterns

Dashboards / Organization and navigation / Sidebar and canvas

‹ 3 of 4 ›

Sidebar and canvas

People need to navigate between dashboards while keeping space for the data.

Updated September 10, 2026

Problem

People need to move among dashboards and know where they are without giving most of the screen to navigation.

Solution

Place navigation in a side rail and keep the main area for dashboard content. Clearly mark the current location.

Keep the rail focused on useful destinations. Use search or grouping when the list becomes too long, and preserve readable names rather than relying on unexplained icons.

Allow collapse when more content space is needed. On narrow screens, provide a clear way to reopen navigation and keep the current page identity visible.

Use when

The product has many destinations and people switch between them regularly.

Don’t use when

There are four destinations. A rail holding four links is a horizontal nav rotated ninety degrees and costing more width. Also avoid it on anything going on a wall, where nobody is navigating and the rail is pure loss.

Trade-offs

A permanent rail reduces space for data. Deep trees truncate labels and create competing scroll areas. A growing menu needs maintenance or it becomes another place people struggle to navigate.

Checklist

  • What is in the rail: global sections, current-section contents, or both?
  • Does it show current position clearly enough to orient someone who arrived from a link?
  • What happens at two hundred items, and is there search?
  • Do labels truncate, and are truncated labels still distinguishable?
  • Does the rail scroll independently, and does that fight the page scroll?
  • Can it collapse, and does anything break when it does?
  • What replaces it below the mobile breakpoint?
  • How much horizontal space does it take from the canvas, and is that trade stated?
  • Does it survive being on a wallboard, or should it be hidden there?
  • Who decides what gets an entry, and what stops the list growing?

Compare

Grafana runs a collapsible left nav for products and sections with dashboard search as the real navigation, an admission that at real scale a tree stops working and a search box is the honest answer. Netdata puts a node and section tree in the rail and uses it as the page’s table of contents as well as its navigation, so the rail scrolls the canvas rather than replacing it. Kibana splits the difference with a collapsible rail for apps and in-page controls for everything within an app, which keeps the rail short at the cost of two different navigation models to learn. Sentry keeps a thin fixed rail for top-level areas and pushes all narrowing into the content, which suits a product where the work is a queue rather than a library.

Overview then detail provides a hierarchy to navigate. Multi-page dashboard groups peer pages. Tabs as genres fits a smaller set of destinations.

Two-pane list and detail applies a similar split to records. Mobile adaptation changes the navigation for narrow screens.

Sidebar and canvas anatomy A wide screen with a persistent rail down the left, one item highlighted as the current position, and the rest of the width given to the canvas. Beside it, the same screen narrowed, where the rail has collapsed and taken the navigation with it. Anatomy Rail plus canvas Rail gone 1 2 3 4 1 THE RAIL Narrow, always there, and it does not scroll with the canvas. 2 CURRENT POSITION Answers "where am I" continuously and for free. A breadcrumb waits to be read. 3 THE CANVAS Everything else. On a dense page the rail is competing with what people came for. 4 WHEN IT NARROWS The rail goes first. If it held the only route anywhere, the narrow layout has none.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Persistent navigation across many dashboards without stealing the canvas. A sidebar earns its 240px by showing where you are in a set of fifty, and stops earning it the moment the set is six.

shadcn
npx shadcn@latest add scroll-area button
npm
lucide-react
Tokens
--card--foreground--muted--muted-foreground--border--chart-1
Checkout

Requests per second

312

p95 latency, ms

158

Error rate

0.7

Shell.tsxA rail that scrolls on its own, highlights the current item, collapses on request, and becomes a menu below the breakpoint so the narrow layout keeps its navigation.

import { useState, type ReactNode } from "react";
import { Menu, PanelLeftClose } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";

export type NavItem = { id: string; label: string };
export type NavSection = { label: string; items: NavItem[] };

/**
 * A rail and a canvas. The rail is narrow, always present, and scrolls on
 * its own rather than with the page. It highlights the current item, which
 * is the whole reason it earns its width: "where am I" answered continuously,
 * for someone who arrived from an alert link and has no idea what else exists.
 *
 * Two things are decided here rather than discovered later. Collapse reclaims
 * the width on a dense page. Below the breakpoint the rail hides, and the menu
 * button that replaces it opens the same list, so the narrow layout still has
 * a route everywhere.
 */
export function Shell({ workspace, sections, current, onNavigate, children, railWidth = 240 }: {
  workspace: string;
  sections: NavSection[];
  /** The id of the item the canvas is showing. Required: a rail without a
   *  current position is a list of links, and a breadcrumb does that. */
  current: string;
  onNavigate: (id: string) => void;
  children: ReactNode;
  railWidth?: number;
}) {
  const [collapsed, setCollapsed] = useState(false);
  const [open, setOpen] = useState(false); // the narrow layout's overlay

  const rail = (
    <nav aria-label="Dashboards" className="flex h-full flex-col">
      <div className="flex items-center justify-between px-3 py-2">
        <span className="truncate text-sm font-medium">{workspace}</span>
        <Button variant="ghost" size="sm" className="h-7 w-7 p-0 md:inline-flex hidden" aria-label="Collapse the rail" onClick={() => setCollapsed(true)}>
          <PanelLeftClose />
        </Button>
      </div>
      <ScrollArea className="flex-1">
        {sections.map((s) => (
          <div key={s.label} className="px-1.5 pb-2">
            <p className="px-1.5 pt-2 pb-1 text-[11px] uppercase tracking-wide text-muted-foreground">{s.label}</p>
            {s.items.map((it) => (
              <button
                key={it.id}
                type="button"
                aria-current={it.id === current ? "page" : undefined}
                onClick={() => { onNavigate(it.id); setOpen(false); }}
                title={it.label}
                className={cn(
                  "block w-full truncate rounded px-1.5 py-1 text-left text-xs",
                  it.id === current ? "bg-chart-1/20 text-foreground" : "text-muted-foreground hover:bg-muted",
                )}
              >
                {it.label}
              </button>
            ))}
          </div>
        ))}
      </ScrollArea>
    </nav>
  );

  return (
    <div className="flex h-[260px] overflow-hidden rounded-lg border bg-card">
      {/* Wide: the rail is a column. It never scrolls with the canvas. */}
      {!collapsed && (
        <aside className="hidden shrink-0 border-r md:block" style={{ width: railWidth }}>{rail}</aside>
      )}

      <div className="relative flex min-w-0 flex-1 flex-col">
        <div className="flex items-center gap-1 border-b px-2 py-1">
          <Button variant="ghost" size="sm" className="h-7 w-7 p-0 md:hidden" aria-label="Open navigation" onClick={() => setOpen(true)}>
            <Menu />
          </Button>
          {collapsed && (
            <Button variant="ghost" size="sm" className="hidden h-7 w-7 p-0 md:inline-flex" aria-label="Expand the rail" onClick={() => setCollapsed(false)}>
              <Menu />
            </Button>
          )}
          <span className="truncate text-xs text-muted-foreground">
            {sections.flatMap((s) => s.items).find((it) => it.id === current)?.label}
          </span>
        </div>
        <ScrollArea className="flex-1 p-3">{children}</ScrollArea>

        {/* Narrow: the same list, as an overlay the menu button opens. */}
        {open && (
          <aside className="absolute inset-y-0 left-0 z-10 w-[240px] border-r bg-card shadow-md md:hidden">
            <Button variant="ghost" size="sm" className="absolute right-1 top-1 h-7 w-7 p-0" aria-label="Close navigation" onClick={() => setOpen(false)}>
              <PanelLeftClose />
            </Button>
            {rail}
          </aside>
        )}
      </div>
    </div>
  );
}

demo.tsxHow it is called: two sections, checkout as the current page, three panels on the canvas.

import { useState } from "react";
import { Shell, type NavSection } from "./Shell";

/**
 * Two sections in the rail, checkout as the current position, and three
 * panels of requests per second on the canvas. Narrow the window and the
 * rail becomes a menu button that opens the same list.
 */
const SECTIONS: NavSection[] = [
  { label: "Services", items: [
    { id: "gateway", label: "API gateway" },
    { id: "checkout", label: "Checkout" },
    { id: "search", label: "Search" },
    { id: "cart", label: "Cart" },
    { id: "users", label: "Users" },
    { id: "feed", label: "Feed" },
  ] },
  { label: "Infrastructure", items: [
    { id: "k8s", label: "Kubernetes" },
    { id: "postgres", label: "Postgres" },
    { id: "redis", label: "Redis" },
    { id: "cdn", label: "CDN" },
  ] },
];

const PANELS = [
  { title: "Requests per second", values: [220, 236, 226, 268, 258, 292, 312] },
  { title: "p95 latency, ms", values: [180, 172, 190, 168, 175, 162, 158] },
  { title: "Error rate", values: [1.2, 1.1, 1.4, 0.9, 1.0, 0.8, 0.7] },
];

const Spark = ({ values }: { values: number[] }) => {
  const max = Math.max(...values), min = Math.min(...values);
  const pts = values.map((v, i) => `${(i / (values.length - 1)) * 120},${28 - ((v - min) / (max - min || 1)) * 24}`);
  return (
    <svg viewBox="0 0 120 32" className="mt-2 h-8 w-full" aria-hidden="true">
      <polyline points={pts.join(" ")} fill="none" className="stroke-chart-1" strokeWidth={1.5} />
    </svg>
  );
};

export default function Demo() {
  const [current, setCurrent] = useState("checkout");
  return (
    <Shell workspace="Storefront" sections={SECTIONS} current={current} onNavigate={setCurrent}>
      <div className="grid gap-3 sm:grid-cols-2">
        {PANELS.map((p, i) => (
          <div key={p.title} className={i === 2 ? "rounded border p-3 sm:col-span-2" : "rounded border p-3"}>
            <p className="text-[11px] text-muted-foreground">{p.title}</p>
            <p className="text-lg font-semibold tabular-nums">{p.values.at(-1)}</p>
            <Spark values={p.values} />
          </div>
        ))}
      </div>
    </Shell>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Home Assistant

Room-based card layouts combine household device states with controls.

Demo dashboard at phone width September 10, 2026 Home Assistant public demo (signed out, 390px viewport) medium · light · mobile
The desktop dashboard reflows into a narrow phone layout while keeping its room sections and controls. Cards remain in two columns within sections, limiting the page’s length. The section order differs from the desktop columns, so the responsive layout changes what people encounter first. Navigation moves behind a menu button, while the summary chips remain at the top.
Show 2 more examples Hide the rest

Home Assistant

Room-based card layouts combine household device states with controls.

Demo dashboard September 10, 2026 Home Assistant public demo (signed out) medium · light · desktop-web
Cards are grouped by rooms such as Living room, Kitchen, and Study. States are written in words, with icon color providing a second cue. Section headers add useful room summaries, such as temperature or meeting status. The page also contains controls, including lighting sliders and thermostat buttons, so people can act in the same place they check the state.

Netdata

A large collection of system charts with per-chart controls and anomaly-rate views.

Metrics / System September 10, 2026 Netdata Agent v2.10.0-686-nightly (public registry node, signed out) dense · dark · desktop-web
The navigation reports 720 charts organized by the metrics collected from the system. The main page follows the same nested structure, giving broad coverage but little indication of which charts matter most to a particular task. Search helps navigate that volume. The header also distinguishes live and stale nodes, making missing updates visible instead of presenting every node as current.