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.
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
Dashboard
Key numbers
- Desktop
- Mobile
Recent customers
8 customers
Showing 1 to 5 of 8
With sub items
Retention
Collapsed
With your router
Permissions
ClassNames
Installation
npx rdloom add dashboard-shellCopies 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.jsonWorks, 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.
| Prop | Type | Default |
|---|---|---|
navigationrequiredThe 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 |
currentIdThe id of the page you are on. It is marked as the current page (aria-current) and its parent opens. | string | none |
brandYour logo or product name, at the top of the sidebar and the title of the phone menu. | node | none |
teamThe organisation or workspace shown at the top of the sidebar, with its logo and a second line such as the plan. Replaces brand there. | ShellTeam | none |
teamsOther workspaces to switch to. With two or more the team becomes a menu. | ShellTeam[] | none |
onTeamChangeCalled with the workspace the person chose. | (team: ShellTeam) => void | none |
userThe signed-in person: shown at the bottom of the sidebar with an account menu. | ShellUser | none |
headerContent for the left of the top bar, such as breadcrumbs or the page title. | node | none |
actionsControls for the right of the top bar, such as a theme switch or a Create button. | node | none |
onSearchShows a search button in the top bar and calls this when it is pressed, e.g. to open a command palette. | () => void | none |
searchLabelText and accessible name of the search button. | string | "Search" |
labelAccessible name of the navigation. | string | "Main navigation" |
skipLabelText of the skip link, the first thing a keyboard user can press. | string | "Skip to main content" |
onNavigateCalled with the item when one is chosen. Items without an href use this to do their work. | (item: NavItem) => void | none |
renderLinkDraw 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 }) => ReactNode | none |
sidebarAppearanceThe 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" |
sidebarFooterContent above the account area in the sidebar, such as an upgrade card. It is hidden while the sidebar is folded. | node | none |
isCollapsedControlled: whether the sidebar is folded down to icons. | boolean | none |
defaultCollapsedStart folded down. | boolean | false |
onCollapsedChangeCalled when the person folds or opens the sidebar. Save it if you want it remembered. | (collapsed: boolean) => void | none |
classNamesClass 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 |
childrenrequiredThe page. | node | none |
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