grain-design
v0.1.0
Published
A lightweight, dependency-free React component kit for dashboards and internal tools — 34 components plus accessible SVG charts, in plain CSS.
Maintainers
Readme
grain-design
A lightweight React component kit for dashboards and internal tools — built to feel like the calm, clean, "everything in its place" software people already trust (Zoho, Stripe, Linear), without borrowing anyone's actual look.
No Tailwind, no CSS-in-JS runtime, no charting library, no dependencies besides React. Plain CSS + CSS variables — 34 components and 4 chart types, 18 KB JS and 9 KB CSS gzipped for the whole kit, tree-shakeable per component.
Why it's built this way — the research
You asked for this to be grounded in real research, not vibes. Here's what shaped it.
Color. Blue dominates trust-critical software (Stripe, Salesforce, LinkedIn, every banking
app) for a real reason, not just habit: it's the one hue with the widest range of usable, legible
shades, and it doesn't fight with the semantic colors (red/amber/green) you need for alerts and
status. But generic blue (Tailwind's default blue-600, Bootstrap's default) reads as
templated because thousands of products use the exact same hex. So grain-design uses a deeper,
slightly muted "harbor blue" (#2A4B8D) instead of a bright SaaS blue — it reads as more
authoritative and less like a free UI kit. Everywhere else, color is rationed: neutrals do almost
all the work, and saturated color is reserved for the primary action and for data (charts, deltas,
status), never for chrome. That restraint is itself what research on dashboard cognition points
to — busy dashboards fail when color is decorative instead of informational.
What "confident" actually means in a UI. It isn't a color choice, it's consistency: the same action always looks the same way, spacing follows one scale everywhere, and elevation is used sparingly and means something (a shadow signals "this is floating above the page," like a menu or modal — so it's not wasted on every card). That's why this kit uses borders, not shadows, for base cards — the generic "SaaS card kit" look (every card identically rounded, same soft grey shadow underneath, regardless of hierarchy) is one of the clearest tells of a templated interface, and it also makes real hierarchy harder to read. Shadows here are reserved for things that are actually elevated: dropdowns, modals, popovers.
2010s → 2024 → 2026, and where this sits. Flat design and card grids in the 2010s solved
skeuomorphism's clutter but ossified into identical rounded rectangles everywhere. 2020–2023 saw
that "SaaS card kit" become the default look of every dashboard template. 2024–2026 moved toward
bento grids (asymmetric tile sizes that let important numbers be bigger than supporting ones —
this is why Grid/GridItem supports variable spans) and a much more restrained second wave of
glassmorphism (subtle translucency for depth, not the heavy frosted-blur of 2020). The direction
past 2026 is context-aware layouts that reprioritize based on what a user is doing — out of scope
for a component kit, but it's why every component here takes plain, composable props instead of
baked-in layout assumptions, so you can rearrange without fighting the library.
Charts, and why the colors were computed rather than picked. Dashboard color has one job
that chrome color doesn't: telling series apart for everyone, including the ~8% of men with a
color-vision deficiency. So the eight-slot categorical palette here isn't a designer's swatch
row — it was run through the measurable checks and tuned until it passed against this kit's own
surfaces (#ffffff light, #131a2a dark): every slot inside the mode's lightness band and above
the chroma floor, worst adjacent pair separated by ΔE 9.1 light / 8.4 dark under simulated
protanopia (target ≥ 8) and 19.6 / 19.3 under normal vision (floor ≥ 15). Slots are assigned in a
fixed order and never cycled — the order is itself the safety mechanism, and a ninth series
would be indistinguishable from an earlier one, so past eight the honest move is "Other" or small
multiples, not a generated hue. Three light-mode slots sit under 3:1 against white, which is
exactly why every chart also ships a legend, optional direct labels and a data table: identity
never rests on hue alone. Dark mode is a selected set of steps for the dark surface, not an
automatic flip.
The rest follows the same logic. Marks are thin and capped (bars ≤ 24 px, 2 px lines, 4 px rounded
data-end square at the baseline); white does the separating — a 2 px surface gap between stacked
segments and adjacent bars, a 2 px surface ring on dots — instead of borders, which would add ink
that isn't data. Gridlines are hairline and recessive. Text never wears the series color; a
colored mark sits beside ink-colored text. And every chart is interactive by default: a
crosshair that snaps to the nearest x on lines, per-mark tooltips on bars and donuts, the same
readout on keyboard focus, and a <table> of the same numbers rendered for screen readers
(showTable reveals it on screen). No dual-axis charts — two scales on one plot is the single
most common way a dashboard lies to its reader.
What this kit deliberately skips. Default glassmorphism, gradients-as-decoration, ALL-CAPS eyebrow labels, monospace data labels, arrows on every button, rainbow sequential ramps, pie charts with fifteen slices, a number printed on every data point — the current tells of "AI-generated UI." None of it serves a dashboard's actual job, which is to be read quickly and trusted at a glance.
Install
npm install grain-design// once, at your app root
import 'grain-design/styles.css';Dark mode: set data-theme="dark" on <html> or any wrapper — every token flips automatically.
<html data-theme={isDark ? 'dark' : undefined}>Quick example
import {
AppShell, Sidebar, Topbar, Container, Section, Grid, GridItem,
StatCard, Card, LineChart, Button, ToastProvider,
} from 'grain-design';
function Dashboard() {
return (
<ToastProvider>
<AppShell
sidebar={
<Sidebar brand="Acme">
<Sidebar.Section title="Overview">
<Sidebar.Item label="Home" href="/" />
<Sidebar.Item label="Analytics" href="/analytics" active />
<Sidebar.Item label="Accounts" href="/accounts" badge={148} />
</Sidebar.Section>
</Sidebar>
}
topbar={<Topbar left="Analytics" right={<Button size="sm">Export</Button>} />}
>
<Container size="xl">
<Section title="Overview" description="Last 30 days">
<Grid gap="md">
<GridItem span={4}>
<StatCard
label="Revenue"
value="₹4,82,300"
delta={{ value: '12.4%', direction: 'up', label: 'vs prev. 30d' }}
trend={[402, 418, 441, 431, 455, 468, 461, 482]}
/>
</GridItem>
<GridItem span={4}>
<StatCard label="Churn" value="1.2%" goodDirection="down"
delta={{ value: '0.3pp', direction: 'down', label: 'vs prev. 30d' }} />
</GridItem>
<GridItem span={12}>
<Card padding="lg">
<LineChart
title="Revenue by stream"
description="Monthly, in ₹ lakh"
categories={['Apr', 'May', 'Jun', 'Jul']}
series={[
{ key: 'recurring', label: 'Recurring', data: [312, 328, 341, 352] },
{ key: 'services', label: 'Services', data: [86, 92, 88, 101] },
]}
/>
</Card>
</GridItem>
</Grid>
</Section>
</Container>
</AppShell>
</ToastProvider>
);
}Run the playground
A full demo dashboard — every component, both themes, real interactions — lives in playground/:
npm install
npm run playground # http://localhost:5178Components
Every component forwards className and native HTML props, so you can extend anything without
forking it. 34 components, grouped by the job they do.
Layout & shell
| Component | What it's for |
|---|---|
| AppShell | Page frame: fixed sidebar + sticky topbar + scrolling content well. sidebarCollapsed collapses to icons. |
| Sidebar (.Section, .Item) | Nav rail. Items take icon, label, active, badge, collapsed, disabled. Hides below 900px — pair with a Drawer for mobile nav. |
| Topbar | left / center / right slots — breadcrumb, search, actions. |
| useSidebarCollapse(initial?) | { collapsed, setCollapsed, toggle } for the collapse button. |
| Container | size: sm \| md \| lg \| xl \| full. |
| Section | title, description, actions — the heading row every dashboard page needs, with consistent vertical rhythm. |
| Grid / GridItem | Bento layout. Grid.columns (12), Grid.gap; GridItem.span, .spanSm, .rowSpan. |
| Card | Compound: .Header, .Title, .Description, .Body, .Footer. variant: default \| elevated \| interactive \| ghost; padding: none \| sm \| md \| lg. |
| Divider | orientation, optional label, spacing. |
Navigation
| Component | Notes |
|---|---|
| Tabs | Compound (.List, .Trigger, .Panel), controlled or uncontrolled, arrow-key navigation. |
| Breadcrumb | items: { label, href?, onClick? }[], maxItems collapses the middle to …. |
| Pagination | page / pageCount / onPageChange, plus optional total, pageSize, onPageSizeChange for the "1–20 of 348 · Rows 20" row. Windows page numbers with gaps. |
| Accordion | items: { id, title, meta?, content, disabled? }[], single for one-open-at-a-time, controlled via value/onChange. |
Inputs
| Component | Notes |
|---|---|
| Button | variant: primary \| secondary \| outline \| ghost \| danger \| success \| link; size: sm \| md \| lg \| icon; loading, leftIcon, rightIcon, fullWidth. |
| Input / Textarea / Select | Share label, hint, error, required, and fieldStyle/fieldClassName (fields are full-width blocks — that's how you size one down for a filter row). Input adds leftIcon, rightIcon, inputSize. Select takes options and placeholder. |
| Checkbox | label, hint, error, and indeterminate for the "some rows selected" header state. |
| Radio / RadioGroup | Group owns value/onChange and the shared name; orientation. |
| Switch | label, hint, size, labelPosition (start for settings rows). Renders role="switch". |
| SegmentedControl | The time-range / view-mode switcher. A real radiogroup, so arrow keys change the selection. |
| DateRangePicker | Presets as rows (today, last 7/30/90 days, month-to-date) with a bold check on the selection, custom range behind a hairline in the footer. ISO YYYY-MM-DD strings in and out — no Date objects across the API. Exports defaultDateRangePresets and toISODate. |
Data display
| Component | Notes |
|---|---|
| StatCard | label, value, delta ({ value, direction, label }), goodDirection (up \| down \| none — churn goes down to be green), trend: number[] for an inline sparkline, chart for any custom visual, icon, footnote, loading. |
| Table | columns ({ key, header, render?, align?, width?, sortable? }), data, getRowKey. Controlled sorting (sortKey, sortDirection, onSortChange), row selection (selectable, selectedKeys, onSelectionChange — header checkbox shows the mixed state), stickyHeader, onRowClick, isRowActive, loading skeleton rows, dense, emptyMessage. Sorting and paging stay yours; the component never reorders your data behind your back. |
| DataList | Label/value pairs for record panels. layout: rows \| columns; columns: 1–3; per-item wide. |
| Timeline | Activity feed / audit trail. Items take title, description, timestamp, icon, tone. |
| Badge | variant: solid \| soft \| outline; tone: neutral \| primary \| accent \| success \| warning \| danger; size, dot. |
| Avatar | src (falls back to initials from name), size xs–xl, shape, status. |
| ProgressBar | value/max, variant, size, label, showValue. |
Charts
Zero dependencies — inline SVG, sized in real pixels via ResizeObserver (no stretched viewBox,
so strokes and text never distort). All four read the palette from CSS variables, so they flip
with the theme. Shared props: title, description, height, valueFormat, showLegend,
showTable, toolbar, footer, loading (dims the previous render instead of flashing a
skeleton), className, ariaLabel.
| Component | Props worth knowing |
|---|---|
| LineChart | categories, series, area (defaults on for a single series), curve (linear \| smooth), emphasis (one series in color, the rest gray — the most underused chart form), endLabels, beginAtZero, showGrid, yTickCount. Crosshair snaps to the nearest x; ←/→ do the same from the keyboard. null in data breaks the line instead of pretending it's zero. |
| BarChart | categories, series, stacked, horizontal (use it for long category names), showValues (drawn only where the text actually fits), emphasis, maxBarThickness. Hit targets span the whole band, so thin bars are still easy to hover. |
| DonutChart | data: { label, value, color? }[], thickness, centerLabel, centerValue, showLegendValues (value + share per row). Segments are separated by a true 2 px gap, not a stroke. |
| Sparkline | data: number[], width, height, color, area, showEnd, curve. Standalone — no frame, no axes. |
| ChartFrame / ChartLegend / ChartTooltip | The chrome, exported so a custom chart matches the rest. |
| formatCompact, niceScale, slotColor, useChartWidth | The math, if you're rolling your own. |
<BarChart
title="New business by channel"
categories={['Q1', 'Q2', 'Q3', 'Q4']}
series={[
{ key: 'direct', label: 'Direct', data: [42, 51, 47, 58] },
{ key: 'partner', label: 'Partner', data: [28, 31, 36, 33] },
]}
stacked
/>Series colors come from --gr-chart-1 … --gr-chart-8, assigned in order. Override a single
series with color only when you already own that entity's color (a brand, a status). Don't add a
ninth hue.
Overlays
All portal into document.body, trap focus where they should, close on Escape, and return focus
to whatever opened them.
| Component | Notes |
|---|---|
| Modal | open/onClose, title, description, size (sm \| md \| lg \| full), footer, closeOnOverlayClick, closeOnEscape, showCloseButton. Locks body scroll (safely, with several overlays open). |
| ConfirmDialog | The destructive-action dialog: onConfirm, confirmLabel, cancelLabel, tone, loading. |
| Drawer | Side sheet for record details and filter panels. side: right \| left \| bottom; size. |
| Menu | Compound (.Item, .Label, .Separator). Roving arrow-key focus, Home/End, shortcut and tone="danger" on items, keepOpen for toggles. |
| Popover | Anchored dismissible panel — the base for filter popovers. Controlled or uncontrolled. |
| Tooltip | Wraps one focusable child. Shows on hover and keyboard focus, wires aria-describedby, flips when it would overflow. |
| ToastProvider + useToast() | toast({ title, description, tone, duration, action }). duration: 0 requires a manual dismiss; hover or focus pauses the timer; position and max on the provider. |
| Portal, useAnchoredPosition | The primitives, if you need to build your own floating thing. |
Feedback
| Component | Notes |
|---|---|
| Alert | Inline banner. tone: info \| success \| warning \| danger \| neutral, each with a default icon so meaning never rests on color; title, action, onDismiss. |
| EmptyState | icon, title, description, action — for the "no data yet" moments a dashboard hits constantly. |
| Skeleton | variant (text \| circle \| rect), width, height, lines. |
| Spinner | size, label (announced to screen readers), inherit. |
Theming
Everything reads from CSS variables in src/styles/tokens.css. Override any of them — in your
own stylesheet, or scoped to a wrapper — to reskin without touching component code:
:root {
--gr-color-primary: #7c3aed; /* swap the brand color */
--gr-radius-md: 4px; /* sharper corners everywhere */
}Accessibility baseline
- Focus is always visible, and overlays manage it properly:
ModalandDrawermove focus into the panel, trapTabinside it, and return focus to whatever opened them on close. - Keyboard parity, not just mouse. Arrow keys in
Tabs,Menu,SegmentedControlandRadioGroup;←/→walk aLineChart's crosshair; bars, donut segments and chart hit areas are focusable and announce their values. - Meaning never rests on color. Charts ship a legend (two or more series), optional direct
labels, and a
<table>of the same numbers — rendered for screen readers always, shown on screen withshowTable.AlertandBadgetones pair color with an icon or text. - Correct roles and state throughout:
aria-invalid,aria-busy,aria-sort,aria-current,aria-expanded,role="progressbar",role="switch",aria-modal, mixed-state checkboxes. prefers-reduced-motionrespected globally — spinners and shimmers slow down rather than vanish, so loading still reads as "in progress".- It survives narrow screens. The sidebar drops below 900px, topbar slots never overlap (the centre slot hides below 760px), breadcrumbs collapse to the current page on phones, section headers wrap their actions, and every chart resizes with its container.
Build & publish
npm run typecheck # tsc over src/
npm run build # tsup → dist/ (ESM + CJS + .d.ts + one CSS file)
npm run playground # the demo dashboard at http://localhost:5178
npm publish # runs typecheck + build first, via prepublishOnlyRoadmap
Shipped since the first pass: Modal/ConfirmDialog, Drawer, Menu, Popover, Tooltip,
Toast, AppShell/Sidebar/Topbar, and the chart set — all still dependency-free.
Still deliberately out:
- A calendar-grid date picker.
DateRangePickercovers the dashboard case (presets + a custom range) without the month-grid, locale and keyboard-navigation surface a real calendar needs. - Virtualised tables. Past a few thousand rows you want windowing, which means a dependency or
a much larger
Table. Paginate instead, or drop in your own virtualiser —Tableis plain markup. - An icon set. Icons are
ReactNodeprops everywhere, so bring Lucide, Phosphor, or your own. - Heatmap and scatter charts. The sequential ramp (
--gr-chart-seq-1…-7) is already in the tokens for whoever adds the heatmap; scatter needs the stricter all-pairs color gate (a three-series cap), so it deserves its own pass rather than a rushed one.
