Skip to content

Dashboard Shell

The frame of a dashboard or admin app: a sidebar of navigation that folds down to icons, a top bar with search and actions, an account menu, and the page. On a phone, or in any narrow space, the sidebar becomes a menu that slides in.

Blockv0.1.0experimentalWCAG 2.2 AAView spec

A ready-made piece, built from the library's own parts.

A block puts several components together into something you would otherwise assemble by hand. It never fetches data: you give it the data, or answer its callbacks. It is copied into your project like any component, with the parts it uses, so you can change anything.

Basic

Full screen (opens in a new tab)
Skip to main content

Dashboard

How the business is doing this month.

Key numbers

Total revenue: $1,250.00. Up 12.5%. Trending up this month. Visitors for the last 6 months
New customers: 1,234. Down 20%. Down 20% this period. Acquisition needs attention
Active accounts: 45,678. Up 12.5%. Strong user retention. Engagement exceeds targets
Growth rate: 4.5%. Up 4.5%. Steady performance increase. Meets growth projections
Total visitorsTotal for last 3 monthsArea chart of Desktop, Mobile across 90 points. Desktop is highest at 562 (Jun 20) and lowest at 252 (Jul 12).
  • Desktop
  • Mobile

Recent customers

Status
Plan
Sort by

8 customers

  • Ada Lovelaceada@example.com · Analytical Coactive
    Plan
    Free
    Revenue
    $0
    Joined
    Jan 4, 2026
  • Grace Hoppergrace@example.com · Compiler Labsactive
    Plan
    Pro
    Revenue
    $29
    Joined
    Feb 7, 2026
  • Katherine Johnsonkatherine@example.com · Orbit Systemstrial
    Plan
    Team
    Revenue
    $99
    Joined
    Mar 10, 2026
  • Alan Turingalan@example.com · Enigma Worksoverdue
    Plan
    Enterprise
    Revenue
    $499
    Joined
    Apr 13, 2026
  • Margaret Hamiltonmargaret@example.com · Analytical Coactive
    Plan
    Free
    Revenue
    $0
    Joined
    May 16, 2026

Showing 1 to 5 of 8

With sub items

Full screen (opens in a new tab)
Skip to main content

Retention

The sidebar marks the page you are on, and opens the list that holds it.

Collapsed

Full screen (opens in a new tab)
Skip to main content

Home

The sidebar is folded. Open it with the button in the top bar.

With your router

Full screen (opens in a new tab)
Skip to main content

People

The address is /people.

Permissions

Full screen (opens in a new tab)
Skip to main content

Current page: overview

ClassNames

Full screen (opens in a new tab)
Skip to main content
Overview

Current page: overview

Installation

npx rdloom add dashboard-shell

Copies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.

Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/dashboard-shell.json

Works, but without upgrade tracking.

Usage

import {
  BreadcrumbItem,
  Breadcrumbs,
  Button,
  Chart,
  ChartIcon,
  CreditCardIcon,
  CustomerTable,
  DashboardPage,
  DashboardShell,
  EmptyState,
  GlobeIcon,
  HomeIcon,
  InboxIcon,
  SegmentedControl,
  SegmentedControlItem,
  SettingsIcon,
  SparkleIcon,
  UsersIcon,
  findNavItem,
  trailOf,
  type Customer,
  type NavGroup,
} from "@rdloom/react";

    <DashboardPage
      title="Dashboard"
      description="How the business is doing this month."
      actions={
        <>
          <Button variant="secondary">Export</Button>
          <Button>New report</Button>
        </>
      }
      stats={[
        { label: "Total revenue", value: "$1,250.00", trend: { change: 12.5 }, summary: "Trending up this month", description: "Visitors for the last 6 months" },
        { label: "New customers", value: 1234, trend: { change: -20 }, summary: "Down 20% this period", description: "Acquisition needs attention" },
        { label: "Active accounts", value: 45678, trend: { change: 12.5 }, summary: "Strong user retention", description: "Engagement exceeds targets" },
        { label: "Growth rate", value: "4.5%", trend: { change: 4.5 }, summary: "Steady performance increase", description: "Meets growth projections" },
      ]}
    >
      <div className="rounded-2xl border border-[var(--rd-color-border-default)] bg-[var(--rd-color-surface-default)] p-5 [box-shadow:var(--rd-elevation-raised)]">
        <Chart
          type="area"
          title="Total visitors"
          description={`Total for ${RANGES[range].toLowerCase()}`}
          height={260}
          actions={
            <SegmentedControl label="Time range" selectedKey={range} onChange={(key) => setRange(String(key) as keyof typeof RANGES)} size="sm">
              {ORDER.map((key) => (
                <SegmentedControlItem key={key} id={key}>
                  {RANGES[key]}
                </SegmentedControlItem>
              ))}
            </SegmentedControl>
          }
          data={{ labels: data.map((d) => d.label), series: [{ name: "Desktop", values: data.map((d) => d.desktop) }, { name: "Mobile", values: data.map((d) => d.mobile) }] }}
        />
      </div>
      <div className="flex flex-col gap-3">
        <h2 className="text-lg font-semibold tracking-[-0.01em]">Recent customers</h2>
        <CustomerTable customers={customers} insights="none" pageSize={5} locale="en-US" />
      </div>
    </DashboardPage>
  );
}

// The shell fills its parent. Items without an href call onNavigate; with an href they are real links.
export default function DashboardShellBasicExample() {
  const [current, setCurrent] = useState("overview");
  const [team, setTeam] = useState(teams[0]);
  const page = findNavItem(navigation, current)!;
  const trail = trailOf(navigation, current);

  return (
    <div className="h-full w-full">
      <DashboardShell
        navigation={navigation}
        currentId={current}
        onNavigate={(item) => setCurrent(item.id)}
        brand={team.name}
        team={team}
        teams={teams}
        onTeamChange={(t) => setTeam(teams.find((x) => x.id === t.id) ?? teams[0])}
        user={{ name: "Ada Lovelace", email: "ada@example.com", menu: [{ id: "profile", label: "Your profile", onSelect: () => {} }, { id: "out", label: "Sign out", onSelect: () => {}, variant: "danger" }] }}
        onSearch={() => {}}
        header={
          <Breadcrumbs label="You are here">
            <BreadcrumbItem>{navigation.find((g) => g.items.some((i) => i.id === current))?.label ?? "Platform"}</BreadcrumbItem>
            <BreadcrumbItem>{trail[trail.length - 1] ?? page.label}</BreadcrumbItem>
          </Breadcrumbs>
        }
      >
        {current === "overview" ? (
          <Overview />
        ) : (
          <DashboardPage title={page.label}>
            <EmptyState title={`${page.label} is yours to build`} description="Put any page here. The sidebar, the top bar, the breadcrumb and the way they fold on a phone are already done." />
          </DashboardPage>
        )}
      </DashboardShell>
    </div>

API Reference

Defined by the spec. Components also accept the props of the React Aria component they wrap.

PropTypeDefault
navigationrequired

The navigation as groups of items. An item may hold one level of sub-items. An item may carry a permission ("allow", "disabled", "hidden" or { state, reason }): hidden items, and groups or parents left with nothing, are not drawn or counted; disabled items stay as focusable, non-navigating entries (aria-disabled) with the reason in a tooltip.

NavGroup[]none
currentId

The id of the page you are on. It is marked as the current page (aria-current) and its parent opens.

stringnone
brand

Your logo or product name, at the top of the sidebar and the title of the phone menu.

nodenone
team

The organisation or workspace shown at the top of the sidebar, with its logo and a second line such as the plan. Replaces brand there.

ShellTeamnone
teams

Other workspaces to switch to. With two or more the team becomes a menu.

ShellTeam[]none
onTeamChange

Called with the workspace the person chose.

(team: ShellTeam) => voidnone
user

The signed-in person: shown at the bottom of the sidebar with an account menu.

ShellUsernone
header

Content for the left of the top bar, such as breadcrumbs or the page title.

nodenone
actions

Controls for the right of the top bar, such as a theme switch or a Create button.

nodenone
onSearch

Shows a search button in the top bar and calls this when it is pressed, e.g. to open a command palette.

() => voidnone
searchLabel

Text and accessible name of the search button.

string"Search"
label

Accessible name of the navigation.

string"Main navigation"
skipLabel

Text of the skip link, the first thing a keyboard user can press.

string"Skip to main content"
onNavigate

Called with the item when one is chosen. Items without an href use this to do their work.

(item: NavItem) => voidnone
renderLink

Draw items as your router's link. Return the link with the given className and children; call onClick when chosen. Without it, items with an href are plain links.

(props: { item: NavItem; className: string; children: ReactNode; isCurrent: boolean; onClick: () => void }) => ReactNodenone
sidebarAppearance

The look of the sidebar and the page: bordered (a line between them), subtle (a tinted sidebar), floating (the sidebar is a raised card) or inset (a tinted background with the page as a rounded card).

"bordered" | "subtle" | "floating" | "inset""bordered"
sidebarFooter

Content above the account area in the sidebar, such as an upgrade card. It is hidden while the sidebar is folded.

nodenone
isCollapsed

Controlled: whether the sidebar is folded down to icons.

booleannone
defaultCollapsed

Start folded down.

booleanfalse
onCollapsedChange

Called when the person folds or opens the sidebar. Save it if you want it remembered.

(collapsed: boolean) => voidnone
classNames

Class names for the parts of the shell, by slot name, added after the built-in ones so you can restyle one part without editing the file. Slots: root, skip-link, sidebar, collapse, nav, group, item, sub-list, account, header, search, actions, main, menu-sheet, team-switcher.

Partial<Record<"root" | "skip-link" | "sidebar" | "collapse" | "nav" | "group" | "item" | "sub-list" | "account" | "header" | "search" | "actions" | "main" | "menu-sheet" | "team-switcher", string>>none
childrenrequired

The page.

nodenone

Accessibility

Role navigation, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.

Keyboard

  • Tab: the skip link first, then the sidebar (collapse button, items, account menu), the top bar, then the page
  • Enter or Space: follows a link or presses an item; on an item with sub-items, opens and closes its list
  • In the account menu: arrow keys move, Enter chooses, Escape closes
  • In the phone menu: Escape closes it, and focus stays inside until it is closed, then returns to the menu button

Screen readers announce

  • "Skip to main content, link" is the first thing reached with Tab
  • "Main navigation, navigation", then each item; the current one reads "Customers, current page"
  • "Reports, button, collapsed" for an item with sub-items, "expanded" once opened
  • "Account menu, Ada Lovelace, menu button"

What your code must do

  • A skip link is the first focusable thing and moves focus to the page (a main landmark that can take focus)
  • The sidebar is a named navigation landmark; there is one main landmark and one header landmark
  • The current page is marked with aria-current="page" and also shown by a tinted background, an accent-colored icon and a heavier label, never by color alone
  • An item with sub-items is a button with aria-expanded and aria-controls; the sub-list is hidden when closed
  • A badge is read after the label ("Inbox, 3") and drawn as a pill; in the folded sidebar it becomes a dot but is still read
  • The folded sidebar keeps every item's name for assistive technology and shows it in a tooltip on hover and focus; the collapse button (in the top bar) says what it will do (Collapse sidebar, Expand sidebar) and has aria-expanded
  • On a phone, or when the shell is in a space narrower than 768 px, the sidebar is replaced by a button that opens the same navigation in a sheet; only one of the two is in the accessibility tree at a time
  • Sidebar width changes stop for visitors who prefer reduced motion
  • The account button is named with the person's name; the menu is a real menu
  • A workspace switcher is a menu button named with the current workspace ("Switch workspace, current: Acme Inc"); with only one workspace it is plain text

Block contract

Data
The navigation as plain data: groups of items, each with an id, label, optional href, icon, badge and sub-items.
Data states
None: it shows what it is given
Permissions
per navigation item
Events
onNavigate, onTeamChange, onSearch, onCollapsedChange
You can replace
renderLink (your router's link); brand or team; header and actions slots; the Sidebar it is built on

Guidelines

Use it when

  • An admin, billing, analytics or internal tool with several sections
  • You want the navigation, top bar and page frame without building each

Avoid it when

  • A marketing site or a one-page app: a top navigation bar is enough
  • Navigation that changes with the page content: build it from Menu and Tabs

Don't

  • Putting the shell inside another shell
  • Hiding the only way to a section behind the folded sidebar's tooltip: give every item an icon people can tell apart
  • More than about 7 top-level items in one group without headings
  • UI permission is not security: hiding or disabling an item only changes what people see, so the server must check access again for every request

Design tokens

The semantic tokens this component uses. Change them once and every component follows; see Design tokens.

  • --rd-color-surface-default
  • --rd-color-surface-subtle
  • --rd-color-surface-selected
  • --rd-color-surface-raised
  • --rd-color-border-default
  • --rd-color-text-default
  • --rd-color-text-muted
  • --rd-color-action-primary
  • --rd-color-focus-ring
  • --rd-radius-control
  • --rd-elevation-floating
  • --rd-size-control-sm