React admin dashboard layout
The frame an admin app hangs off: a sidebar, a header with search, a theme toggle and an account menu, and a row of stat tiles.
Install
One command copies the files into your project, as code you then own. It needs a
components.json, and the shadcn registry page has the one-time setup.Terminal
npx shadcn@latest add https://www.box-kite.dev/r/dashboard-shell.jsonLight and dark
The same component twice, each half in a theme of its own — nothing in it is written for either. Open it full screen to see it in yours.
light
Overview
Revenue
$48,210+12.4%
Invoices paid
182+4.1%
Overdue
$6,940-8.2%
Collection rate
86%
of this quarter
dark
Overview
Revenue
$48,210+12.4%
Invoices paid
182+4.1%
Overdue
$6,940-8.2%
Collection rate
86%
of this quarter
Source
The files the command writes, exactly as the preview above runs them. Every style is a prop, so there is no stylesheet to copy with them.
box-kite/dashboard-shell.tsx
JSX
'use client';
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
import Flex from '@box-kite/react/components/flex';
import Icon from '@box-kite/react/components/icon';
import Menu from '@box-kite/react/components/menu';
import { H1, Header, Li, Link, Main, Nav, Span, Ul } from '@box-kite/react/components/semantics';
import Textbox from '@box-kite/react/components/textbox';
import { LayoutDashboard, Moon, Receipt, Search, Settings, Sun, Users } from 'lucide-react';
import { type ReactElement, type ReactNode } from 'react';
/**
* The frame an admin app hangs off: a sidebar, a header with search, a theme toggle and an account
* menu, and the page itself. The sidebar sticks to the top of its column and folds away below the `md`
* breakpoint; nothing here is measured or positioned by script.
*
* `Box.Theme` is `use="local"` here, so the theme lands on this element and the block can sit inside a
* page that has its own. Move it to your root layout as `use="global"` for an app-wide switch.
*/
export interface NavItem {
id: string;
label: string;
href: string;
icon: ReactElement;
}
const dashboardNav: NavItem[] = [
{ id: 'overview', label: 'Overview', href: '#overview', icon: <LayoutDashboard /> },
{ id: 'invoices', label: 'Invoices', href: '#invoices', icon: <Receipt /> },
{ id: 'customers', label: 'Customers', href: '#customers', icon: <Users /> },
{ id: 'settings', label: 'Settings', href: '#settings', icon: <Settings /> },
];
function ThemeToggle() {
const [theme, setTheme] = Box.useTheme();
const next = theme === 'dark' ? 'light' : 'dark';
return (
<Button variant="ghost" p={2} borderRadius={2} onClick={() => setTheme(next)} props={{ 'aria-label': `Switch to the ${next} theme` }}>
<Icon size={4}>{theme === 'dark' ? <Sun /> : <Moon />}</Icon>
</Button>
);
}
interface SidebarProps {
current: string;
}
function Sidebar({ current }: SidebarProps) {
return (
<Nav
display="none"
md={{ display: 'block' }}
width={64}
flexShrink={0}
be={1}
borderColor="slate-200"
theme={{ dark: { borderColor: 'slate-800' } }}
props={{ 'aria-label': 'Sections' }}
>
<Box position="sticky" top={0} p={4}>
<Flex ai="center" gap={2} px={2} py={3} mb={2}>
<Box width={6} height={6} borderRadius={2} bgGradient={{ linear: 'br', colors: ['violet-500', 'sky-400'] }} />
<Span fontWeight={600}>Acme Ops</Span>
</Flex>
<Ul listStyle="none" m={0} p={0}>
{dashboardNav.map((item) => (
<Li key={item.id}>
<Link
props={{ href: item.href, 'aria-current': item.id === current ? 'page' : undefined }}
display="flex"
ai="center"
gap={3}
px={3}
py={2}
my={0.5}
borderRadius={2}
fontSize={14}
textDecoration="none"
color={item.id === current ? 'violet-700' : 'slate-600'}
bgColor={item.id === current ? 'violet-50' : 'transparent'}
hover={{ bgColor: item.id === current ? 'violet-50' : 'slate-100' }}
theme={{
dark: {
color: item.id === current ? 'violet-300' : 'slate-400',
bgColor: item.id === current ? 'violet-950' : 'transparent',
hover: { bgColor: item.id === current ? 'violet-950' : 'slate-900' },
},
}}
>
<Icon size={4}>{item.icon}</Icon>
{item.label}
</Link>
</Li>
))}
</Ul>
</Box>
</Nav>
);
}
export interface DashboardShellProps {
/** The heading above the page, and the `id` of the nav item that is marked as current. */
title: string;
current?: string;
/** The theme to open in. Without it the shell follows the reader's saved choice, then their system. */
theme?: string;
children: ReactNode;
}
export default function DashboardShell({ title, current = 'overview', theme, children }: DashboardShellProps) {
return (
<Box.Theme theme={theme} storageKey="dashboard-theme">
<Flex
minHeight="fit"
bgColor="slate-50"
color="slate-900"
theme={{ dark: { bgColor: 'slate-950', color: 'slate-100' } }}
overflow="hidden"
borderRadius={3}
>
<Sidebar current={current} />
<Flex d="column" flexGrow={1} minWidth={0}>
<Header
display="flex"
ai="center"
gap={3}
px={5}
py={3}
bb={1}
borderColor="slate-200"
theme={{ dark: { borderColor: 'slate-800' } }}
>
<H1 fontSize={16} fontWeight={600} flexGrow={1}>
{title}
</H1>
{/* Away below `md`, with the sidebar: a fixed-width field is what pushes the header past a
phone's width, and search deserves a screen of its own there rather than a sliver. */}
<Flex ai="center" gap={2} position="relative" display="none" md={{ display: 'flex' }}>
<Icon size={4} position="absolute" insetStart={3} color="slate-400" pointerEvents="none">
<Search />
</Icon>
<Textbox type="search" ps={9} py={2} width={56} placeholder="Search…" props={{ 'aria-label': 'Search' }} />
</Flex>
<ThemeToggle />
<Menu
trigger={(trigger) => (
<Button {...trigger} variant="ghost" p={2} borderRadius={2}>
AL
</Button>
)}
>
<Menu.Item>Profile</Menu.Item>
<Menu.Item>Billing</Menu.Item>
<Menu.Separator />
<Menu.Item>Sign out</Menu.Item>
</Menu>
</Header>
<Main p={5} flexGrow={1}>
{children}
</Main>
</Flex>
</Flex>
</Box.Theme>
);
}
box-kite/dashboard-stats.tsx
JSX
import Box from '@box-kite/react';
import { ProgressRing, Sparkline } from '@box-kite/react/components/chart';
import Flex from '@box-kite/react/components/flex';
import Grid from '@box-kite/react/components/grid';
import { Span } from '@box-kite/react/components/semantics';
import { type ReactNode } from 'react';
/**
* The row of tiles above a dashboard. The shapes are `components/chart` primitives — an SVG each, no
* chart library — and there is no `'use client'` here on purpose: nothing in this file holds state, so
* it renders in a Server Component, styles and all.
*/
export interface Stat {
label: string;
value: string;
delta: string;
up: boolean;
trend: number[];
}
const dashboardStats: Stat[] = [
{ label: 'Revenue', value: '$48,210', delta: '+12.4%', up: true, trend: [12, 18, 14, 22, 25, 21, 28, 31, 29, 36, 34, 42] },
{ label: 'Invoices paid', value: '182', delta: '+4.1%', up: true, trend: [30, 28, 33, 31, 36, 34, 38, 37, 41, 40, 44, 46] },
{ label: 'Overdue', value: '$6,940', delta: '-8.2%', up: false, trend: [22, 24, 21, 19, 20, 17, 16, 18, 15, 13, 12, 10] },
];
function Card({ children }: { children: ReactNode }) {
return (
<Flex
d="column"
gap={3}
p={4}
b={1}
borderRadius={3}
borderColor="slate-200"
bgColor="white"
theme={{ dark: { borderColor: 'slate-800', bgColor: 'slate-900' } }}
>
{children}
</Flex>
);
}
export default function DashboardStats() {
return (
<Grid gridTemplateColumns={1} gap={4} md={{ gridTemplateColumns: 4 }}>
{dashboardStats.map((stat) => (
<Card key={stat.label}>
<Span fontSize={13} color="slate-500" theme={{ dark: { color: 'slate-400' } }}>
{stat.label}
</Span>
{/* Stacked rather than side by side: a tile is a quarter of the row, and a long figure next
to its delta is the first thing to overflow — on one tile, which leaves the row ragged. */}
<Flex d="column" gap={1}>
<Span fontSize={24} fontWeight={600}>
{stat.value}
</Span>
<Span
fontSize={13}
color={stat.up ? 'emerald-600' : 'rose-600'}
theme={{ dark: { color: stat.up ? 'emerald-400' : 'rose-400' } }}
>
{stat.delta}
</Span>
</Flex>
{/* `width`/`height` on an SVG are the attributes, not the ÷4 layout props — a CSS length, or
a bare number meaning user units. `color` paints it: the stroke is `currentColor`. */}
<Sparkline data={stat.trend} height="2rem" color={stat.up ? 'emerald-500' : 'rose-500'} strokeWidth={2} />
</Card>
))}
<Card>
<Span fontSize={13} color="slate-500" theme={{ dark: { color: 'slate-400' } }}>
Collection rate
</Span>
<Flex ai="center" gap={3} flexWrap="wrap">
<ProgressRing value={0.86} width="3rem" height="3rem" color="violet-500" />
<Box>
<Span fontSize={24} fontWeight={600}>
86%
</Span>
<Box fontSize={13} color="slate-500" theme={{ dark: { color: 'slate-400' } }}>
of this quarter
</Box>
</Box>
</Flex>
</Card>
</Grid>
);
}