# React admin dashboard layout

_Box Kite 2.2.4 · a markdown copy of https://www.box-kite.dev/blocks/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](https://www.box-kite.dev/registry.md) has the one-time setup.

```shell
npx shadcn@latest add https://www.box-kite.dev/r/dashboard-shell.json
```

[Open in v0](https://v0.dev/chat/api/open?url=https%3A%2F%2Fwww.box-kite.dev%2Fr%2Fdashboard-shell.json)

[The item JSON](https://www.box-kite.dev/r/dashboard-shell.json)

2 files · installs @box-kite/react, lucide-react

## Light 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](https://www.box-kite.dev/blocks/react-admin-dashboard-layout/demo.md) to see it in yours.

## 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>
  );
}
```

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

