Blocks, installed by the shadcn CLI
NEW
Finished sections you install into your own repository and then own — a data grid, a settings form and a dashboard shell.
A shadcn registry is JSON over HTTP, and the CLI that reads it does not care which library the code imports. These three items install the
@box-kite/react package and drop wired code beside it — so the parts you will change are yours from the first commit, and the parts you will not stay a dependency you can upgrade.Two ways in
Point the CLI straight at an item, from any project that has a
components.json:Terminal
npx shadcn@latest add https://www.box-kite.dev/r/data-grid.jsonOr register the namespace once, and then name the block rather than its address. Add this to
components.json:JAVASCRIPT
{
"registries": {
"@box-kite": "https://www.box-kite.dev/r/{name}.json"
}
}After that,
npx shadcn@latest add @box-kite/data-grid — and npx shadcn@latest search @box-kite lists what is here.The CLI expects a
components.json carrying a tailwind key, and empty strings satisfy it — these blocks write no CSS file and import no stylesheet, because every style in them is a prop.Invoices data grid
A virtualized data grid wired to sample invoices: search, column filters, grouping, totals, an editable amount that is validated, range selection with copy and paste, and CSV/XLSX export.
Terminal
npx shadcn@latest add https://www.box-kite.dev/r/data-grid.jsonInvoices
Search the table, filter a column, group by Team from a column menu, edit an amount, select a block of cells and copy it, or export what is on screen.
Invoices
Reference
Customer
Team
Status
Amount
Issued
Select...
Select...
INV-2480
Ardent Supply
Northern
paid
$400
2026-01-01
INV-2481
Beacon Freight
Southern
pending
$537
2026-02-08
INV-2482
Corvus Analytics
Eastern
overdue
$674
2026-03-15
INV-2483
Delta Hardware
Western
paid
$811
2026-04-22
INV-2484
Evergreen Mills
Northern
pending
$948
2026-05-02
INV-2485
Fairline Media
Southern
overdue
$1,085
2026-06-09
INV-2486
Granite Foods
Eastern
paid
$1,222
2026-07-16
INV-2487
Harbour Logistics
Western
pending
$1,359
2026-08-23
INV-2488
Ardent Supply
Northern
overdue
$1,496
2026-09-03
INV-2489
Beacon Freight
Southern
paid
$1,633
2026-10-10
INV-2490
Corvus Analytics
Eastern
pending
$1,770
2026-11-17
INV-2491
Delta Hardware
Western
overdue
$1,907
2026-12-24
INV-2492
Evergreen Mills
Northern
paid
$2,044
2026-01-04
INV-2493
Fairline Media
Southern
pending
$2,181
2026-02-11
INV-2494
Granite Foods
Eastern
overdue
$2,318
2026-03-18
INV-2495
Harbour Logistics
Western
paid
$2,455
2026-04-25
INV-2496
Ardent Supply
Northern
pending
$2,592
2026-05-05
INV-2497
Beacon Freight
Southern
overdue
$2,729
2026-06-12
INV-2498
Corvus Analytics
Eastern
paid
$2,866
2026-07-19
INV-2499
Delta Hardware
Western
pending
$3,003
2026-08-26
INV-2500
Evergreen Mills
Northern
overdue
$3,140
2026-09-06
INV-2501
Fairline Media
Southern
paid
$3,277
2026-10-13
INV-2502
Granite Foods
Eastern
pending
$3,414
2026-11-20
INV-2503
Harbour Logistics
Western
overdue
$3,551
2026-12-27
INV-2504
Ardent Supply
Northern
paid
$3,688
2026-01-07
INV-2505
Beacon Freight
Southern
pending
$3,825
2026-02-14
INV-2506
Corvus Analytics
Eastern
overdue
$3,962
2026-03-21
INV-2507
Delta Hardware
Western
paid
$4,099
2026-04-01
INV-2508
Evergreen Mills
Northern
pending
$4,236
2026-05-08
INV-2509
Fairline Media
Southern
overdue
$4,373
2026-06-15
All invoices
200
$1,001,500
Rows: 200
Selected: 0
invoices-grid.tsx
JSX
'use client';
import Box from '@box-kite/react';
import DataGrid, { type CellModel, type GridDefinition } from '@box-kite/react/components/dataGrid';
import Flex from '@box-kite/react/components/flex';
import { H2, P } from '@box-kite/react/components/semantics';
import { type Invoice, invoices } from './invoice-rows';
/**
* An invoices table with what a real one needs: search, per-column filters, grouping from the column
* menu, totals, an editable amount that is validated, a block of cells to copy, and CSV/XLSX export.
*
* None of it is configuration to keep in sync — the grid is one `def` object. Swap `invoices` for your
* own rows and edit the columns.
*/
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const STATUS_STYLES = {
paid: { bgColor: 'emerald-100', color: 'emerald-700', theme: { dark: { bgColor: 'emerald-950', color: 'emerald-300' } } },
pending: { bgColor: 'amber-100', color: 'amber-700', theme: { dark: { bgColor: 'amber-950', color: 'amber-300' } } },
overdue: { bgColor: 'rose-100', color: 'rose-700', theme: { dark: { bgColor: 'rose-950', color: 'rose-300' } } },
} as const;
// A cell renderer is a component defined outside the render: a new identity on every render remounts
// the whole column on every scroll.
function StatusCell({ cell }: { cell: CellModel<Invoice> }) {
const status = cell.value as Invoice['status'];
return (
<Flex ai="center" px={3} height="fit">
<Box px={2} py={0.5} borderRadius={4} fontSize={12} fontWeight={500} textTransform="capitalize" {...STATUS_STYLES[status]}>
{status}
</Box>
</Flex>
);
}
// `fontVariantNumeric` has no prop of its own, so it goes through `css` — still one shared class, not
// a style attribute.
function AmountCell({ cell }: { cell: CellModel<Invoice> }) {
return (
<Flex ai="center" jc="end" px={3} height="fit" css={{ fontVariantNumeric: 'tabular-nums' }}>
{money.format(cell.value as number)}
</Flex>
);
}
const definition: GridDefinition<Invoice> = {
rowKey: 'id',
title: 'Invoices',
topBar: true,
bottomBar: true,
globalFilter: true,
rowSelection: { pinned: true },
rangeSelection: true,
footer: { label: 'All invoices' },
export: { fileName: 'invoices' },
visibleRowsCount: 12,
rowHeight: 44,
columns: [
{ key: 'reference', header: 'Reference', width: 120, filterable: true },
{ key: 'customer', header: 'Customer', width: 170, filterable: true },
{ key: 'team', header: 'Team', width: 130, filterable: { type: 'multiselect' } },
{ key: 'status', header: 'Status', width: 120, filterable: { type: 'multiselect' }, Cell: StatusCell, aggregate: 'count' },
{
key: 'amount',
header: 'Amount',
width: 130,
align: 'end',
editable: true,
filterable: { type: 'number' },
aggregate: 'sum',
Cell: AmountCell,
AggregateCell: ({ cell }) => (
<Flex ai="center" jc="end" px={3} height="fit" fontWeight={600} css={{ fontVariantNumeric: 'tabular-nums' }}>
{cell.value === null ? '' : money.format(Number(cell.value))}
</Flex>
),
// An export runs no React, so the column says what it writes rather than exporting the renderer.
exportValue: (row) => row.amount,
exportFormat: '#,##0',
},
{ key: 'issued', header: 'Issued', width: 120 },
],
// One function both judges an edit and is told about it: a string is a refusal, and the message is
// what the cell shows.
onCellEdit: ({ value }) => {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) return 'An invoice is worth more than nothing.';
if (amount > 100_000) return 'Anything over $100,000 needs an approval.';
},
};
export default function InvoicesGrid() {
return (
<Flex d="column" gap={4}>
<Box>
<H2 fontSize={20} fontWeight={600}>
Invoices
</H2>
<P mt={1} fontSize={14} color="slate-600" theme={{ dark: { color: 'slate-400' } }}>
Search the table, filter a column, group by Team from a column menu, edit an amount, select a block of cells and copy it, or
export what is on screen.
</P>
</Box>
<DataGrid data={invoices} def={definition} />
</Flex>
);
}
invoice-rows.ts
JSX
/** The rows the block is wired to. Replace this file with your own fetch — the grid reads plain objects. */
export interface Invoice {
id: number;
reference: string;
customer: string;
team: string;
status: 'paid' | 'pending' | 'overdue';
amount: number;
issued: string;
}
const TEAMS = ['Northern', 'Southern', 'Eastern', 'Western'];
const STATUSES = ['paid', 'pending', 'overdue'] as const;
const CUSTOMERS = [
'Ardent Supply',
'Beacon Freight',
'Corvus Analytics',
'Delta Hardware',
'Evergreen Mills',
'Fairline Media',
'Granite Foods',
'Harbour Logistics',
];
/**
* Two hundred invoices generated from the index, so the block has something to sort, filter, group and
* total without shipping a data file — and every reload shows the same table.
*/
export const invoices: Invoice[] = Array.from({ length: 200 }, (_, index) => ({
id: index + 1,
reference: `INV-${(2480 + index).toString()}`,
customer: CUSTOMERS[index % CUSTOMERS.length],
team: TEAMS[index % TEAMS.length],
status: STATUSES[index % STATUSES.length],
amount: 400 + ((index * 137) % 9600),
issued: new Date(Date.UTC(2026, index % 12, ((index * 7) % 27) + 1)).toISOString().slice(0, 10),
}));
Settings form
A settings panel of real form controls — text fields, a searchable select, a radio group, a slider and a switch — that reads its own fields on submit, so none of it needs state.
Terminal
npx shadcn@latest add https://www.box-kite.dev/r/settings-form.jsonsettings-form.tsx
JSX
'use client';
import Button from '@box-kite/react/components/button';
import Dropdown from '@box-kite/react/components/dropdown';
import Flex from '@box-kite/react/components/flex';
import Form from '@box-kite/react/components/form';
import RadioGroup from '@box-kite/react/components/radioGroup';
import { H2, Label, P, Span } from '@box-kite/react/components/semantics';
import Slider from '@box-kite/react/components/slider';
import Switch from '@box-kite/react/components/switch';
import Textarea from '@box-kite/react/components/textarea';
import Textbox from '@box-kite/react/components/textbox';
import { toast } from '@box-kite/react/components/toaster';
import { type ReactNode } from 'react';
/**
* A settings panel: text fields, a searchable select, a radio group, a slider and a switch, each a real
* form control. `Form` reads its own fields when it submits, so none of this needs state — `values` is
* built from the elements carrying a `name`.
*
* `<Toaster />` has to be mounted once near the root of the app for the confirmation to show.
*/
export interface Settings {
name: string;
email: string;
bio: string;
timezone: string;
theme: string;
density: string;
notify: boolean;
}
const TIMEZONES = ['Europe/Chisinau', 'Europe/London', 'America/New_York', 'Asia/Tokyo'];
/**
* A text field and its name. The control sits *inside* the `<label>`, so the two are associated with no
* `htmlFor`/`id` pair to keep in step. `Dropdown`, `RadioGroup` and `Switch` draw their own label from a
* `label` prop, so they are written bare below.
*/
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<Label display="flex" d="column" gap={2} width="fit">
<Span fontSize={14} fontWeight={500}>
{label}
</Span>
{children}
{hint && (
<Span fontSize={13} color="slate-500" theme={{ dark: { color: 'slate-400' } }}>
{hint}
</Span>
)}
</Label>
);
}
/**
* A `Slider` is the exception: its `label` is the thumb's accessible name and draws nothing, and a
* `<label>` cannot wrap it because there is no form control inside to attach to. So the caption is an
* element with an id and `labelledBy` points at it — one name, read and seen.
*/
const DENSITY_LABEL = 'settings-density-label';
export default function SettingsForm() {
return (
<Form<Settings>
p={6}
b={1}
borderRadius={3}
borderColor="slate-200"
bgColor="white"
theme={{ dark: { borderColor: 'slate-800', bgColor: 'slate-900' } }}
onSubmit={(values) => toast.success('Settings saved', { description: `${values.name || 'Your profile'} is up to date.` })}
>
<H2 fontSize={20} fontWeight={600}>
Settings
</H2>
<P mt={1} mb={5} fontSize={14} color="slate-600" theme={{ dark: { color: 'slate-400' } }}>
Every control here is a native element with its name attached, so the keyboard, the focus ring and the screen reader are the
platform's.
</P>
<Flex d="column" gap={5}>
<Flex d="column" gap={5} md={{ d: 'row' }}>
<Field label="Display name">
<Textbox name="name" defaultValue="Ada Lovelace" width="fit" />
</Field>
<Field label="Email">
<Textbox name="email" type="email" defaultValue="ada@example.com" width="fit" />
</Field>
</Flex>
<Field label="About" hint="Shown on your public profile.">
<Textarea name="bio" rows={3} placeholder="A sentence or two." width="fit" />
</Field>
<Dropdown<string> name="timezone" label="Time zone" defaultValue={TIMEZONES[0]} isSearchable searchPlaceholder="Search zones…">
{TIMEZONES.map((zone) => (
<Dropdown.Item key={zone} value={zone}>
{zone}
</Dropdown.Item>
))}
</Dropdown>
<RadioGroup label="Theme" name="theme" defaultValue="system" orientation="horizontal">
<RadioGroup.Item value="system" label="Follow the system" />
<RadioGroup.Item value="light" label="Light" />
<RadioGroup.Item value="dark" label="Dark" />
</RadioGroup>
<Flex d="column" gap={2}>
<Span id={DENSITY_LABEL} fontSize={14} fontWeight={500}>
Row density
</Span>
<Slider name="density" labelledBy={DENSITY_LABEL} defaultValue={44} min={32} max={64} step={4} format={(value) => `${value}px`} />
<Span fontSize={13} color="slate-500" theme={{ dark: { color: 'slate-400' } }}>
How tall a row is in tables across the app.
</Span>
</Flex>
<Switch name="notify" label="Email me when something needs a decision" defaultChecked />
</Flex>
<Flex mt={6} gap={3} jc="end">
<Button variant="secondary" type="reset">
Reset
</Button>
<Button type="submit">Save changes</Button>
</Flex>
</Form>
);
}
Dashboard shell
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 drawn with the chart primitives.
Terminal
npx shadcn@latest add https://www.box-kite.dev/r/dashboard-shell.jsonOverview
Revenue
$48,210+12.4%
Invoices paid
182+4.1%
Overdue
$6,940-8.2%
Collection rate
86%
of this quarter
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;
children: ReactNode;
}
export default function DashboardShell({ title, current = 'overview', children }: DashboardShellProps) {
return (
<Box.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>
);
}
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>
);
}
Where the JSON comes from
The three blocks are ordinary sources in the library's own repository, under
registry/blocks/. They are type-checked by the same tsc run as the library, rendered on this page from those same files, and inlined into the item JSON at build time. A block that stops compiling cannot be published, and what you install is what is running above.The catalog is at https://www.box-kite.dev/registry.json, and each item at
/r/<name>.json.The components themselves are a package, not a registry item: install @box-kite/react and import
Button, Dialog or DataGrid directly. A block is for the composition above them — the part that is always half yours.