npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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:5178

Components

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: Modal and Drawer move focus into the panel, trap Tab inside it, and return focus to whatever opened them on close.
  • Keyboard parity, not just mouse. Arrow keys in Tabs, Menu, SegmentedControl and RadioGroup; ←/→ walk a LineChart'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 with showTable. Alert and Badge tones 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-motion respected 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 prepublishOnly

Roadmap

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. DateRangePicker covers 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 — Table is plain markup.
  • An icon set. Icons are ReactNode props 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.