Skip to content

About

A React 19 + TypeScript component library for dashboards: a Tailwind-based design system with themeable tokens, built-in accessibility and dark mode.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@stefgo/react-ui-components

A React 19 component library for dashboards, built with TypeScript and Tailwind CSS.

It is a design system rather than a component collection: one token per role, one size scale, one way to pass an icon, one way to hold state, and one FormField behind every control. The differences between components are the ones that matter; the rest is decided once.

Storybook of the latest release

Features

  • One decision per concern — colour, radius, motion, layering and control sizes each come from a single scale, not from whatever the file needed.
  • Accessible by construction — dialogs trap focus and return it, menus follow the WAI-ARIA pattern, every control is labelled and describable, and animations honour prefers-reduced-motion.
  • Themeable without a build step — every colour is a CSS custom property with a default; override the ones you care about.
  • No stylesheet to import — the Tailwind preset emits the tokens itself.
  • Type-safe — TypeScript strict mode, exhaustive prop and slot types.
  • A workbench — Storybook with a light/dark side-by-side mode and axe running on every story.

Installation

npm install @stefgo/react-ui-components

Peer dependencies

  • react ≥ 19.0.0
  • react-dom ≥ 19.0.0
  • lucide-react
  • tailwindcss ≥ 3.4

Setup

Tailwind

Add the preset. It maps the tokens onto utilities, adds the library's dist files to content scanning, and emits the token declarations as base styles.

const path = require("path");

/** @type {import('tailwindcss').Config} */
module.exports = {
  presets: [require("@stefgo/react-ui-components/tailwind-preset")],
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
    path.join(
      path.dirname(require.resolve("@stefgo/react-ui-components")),
      "**/*.js",
    ),
  ],
};

Without the preset, Tailwind purges the library's classes and nothing is themed.

Stylesheet

There is none, and none is needed. The preset injects :root and .dark through Tailwind's addBase, so the tokens arrive through your own Tailwind build. There is no import step to forget.

src/index.css in this repository is generated from tokens.js and exists as a readable reference — it is not shipped.

Dark mode

The preset sets darkMode: "class". Put dark on <html> (or any ancestor) and every token switches. Override it in your own config if you need something else.

ThemeProvider does that, and remembers the choice:

<ThemeProvider storageKey="myapp.theme">
  <App />
</ThemeProvider>

const { theme, toggleTheme } = useTheme();
<ThemeToggle theme={theme} onToggle={toggleTheme} />
  • storageKey (required) — the localStorage key; the name belongs to the app
  • defaultTheme: dark (default) | light — what a first visit sees
  • A stored value that is neither of the two falls back to the default.

Conventions

Four rules hold across the whole library. They are what makes it a system.

Icons are components, never elements

<Button icon={Save}>Save</Button>          // yes
<Button icon={<Save size={16} />}>…</Button>  // no

Only the component form lets the surface decide the size and set aria-hidden itself. With elements every caller has to know both, and every caller answers differently.

The type is IconComponent (ComponentType<IconProps>), a deliberate subset of lucide-react's LucideProps — every lucide icon satisfies it, and so does a hand-written SVG component.

Sizes are sm | md | lg

Button, ActionButton and Badge take the same three steps. ICON_SIZE maps each step to the icon edge length that goes with it.

Navigation and display surfaces (Sidebar, BottomNav, StatCard) size their icons from their own layout instead. They are not sized controls, and a prop with one sensible value is worse than no prop.

className, not classNames.root

className goes on the root element. classNames addresses the named slots inside it — label, icon, header, and so on. There is no root slot, because that is what className already is.

State is value / defaultValue / onChange

Pass value and the state is yours: the component reads it and reports every change through onChange, but never writes it. Pass neither and the component keeps the state itself, seeded from defaultValue.

<Collapsible title="Details" defaultValue>…</Collapsible>          // it owns the state
<Collapsible title="Details" value={open} onChange={setOpen}>…</Collapsible>  // you do

Where a component has exactly one such state the three props sit flat on it. Where it has several they are grouped under the state's own name:

<DataMultiView
    search={{ value: query, onChange: setQuery }}
    viewMode={{ value: view, onChange: setView }}
    pagination={{ defaultValue: { pageSize: 25 } }}
/>

useControllableState implements this and is exported, so consumer components can follow the same convention.


Theming

Tokens

Every colour comes from a token in tokens.js, the single source of truth. It feeds the Tailwind preset (which renders) and scripts/generate-tokens-css.js (which documents). npm run tokens:check fails the build if the two disagree.

One token carries both themes. There are no -dark variants and no dark: classes at the call site:

:root { --ruic-text-secondary: #4b5563; }
.dark { --ruic-text-secondary: #d1d5db; }
<p className="text-text-secondary">…</p>   // resolves per theme by itself

Override by redefining the variables you care about. Only what actually differs needs a .dark entry:

:root {
  /* Raw RGB triple — Tailwind needs it for opacity utilities like bg-primary/20 */
  --ruic-primary: 12 34 56;
  --ruic-bg-card: #ffffff;
}
.dark {
  --ruic-bg-card: #0b0b0b;
}

Many tokens are aliases: --ruic-input-bg defaults to var(--ruic-bg-app), so moving the app background moves every input with it. Override the specific token to break that link.

Brand

Token Light Dark
--ruic-primary 229 77 13 same
--ruic-primary-hover 255 95 31 same

Semantic state

Token Light Dark
--ruic-error #dc2626 #f87171
--ruic-error-hover #b91c1c same
--ruic-error-bg #fef2f2 rgba(127, 29, 29, 0.3)
--ruic-success #16a34a #4ade80
--ruic-success-hover #15803d same
--ruic-success-bg #f0fdf4 rgba(20, 83, 45, 0.3)
--ruic-warning #ea580c #fb923c
--ruic-warning-hover #c2410c same
--ruic-warning-bg #ffedd5 rgba(124, 45, 18, 0.3)
--ruic-info #2563eb #60a5fa
--ruic-info-hover #1d4ed8 #93c5fd
--ruic-info-bg #eff6ff rgba(30, 58, 138, 0.3)
--ruic-accent #4f46e5 #818cf8
--ruic-accent-hover #4338ca same
--ruic-accent-bg #eef2ff rgba(49, 46, 129, 0.3)

Surfaces and typography

Token Light Dark
--ruic-bg-app #f9fafb #111111
--ruic-bg-card #ffffff #141414
--ruic-bg-card-header #f3f4f6 #181818
--ruic-overlay rgba(0, 0, 0, 0.6) same
--ruic-text-primary #111827 #f9fafb
--ruic-text-secondary #4b5563 #d1d5db
--ruic-text-muted #808080 same
--ruic-border #e2e8f0 #2a2a2a
--ruic-hover #f3f4f6 #252525

Component tokens

Buttons, badges, inputs, sidebar, tables, StatCard and DashboardHeader each have their own group, most of them aliases onto the surfaces above. src/index.css is generated and therefore always accurate — read it for the full list.

Shape and motion

Scale Values Applies to
rounded-* sm md lg xl full containers lg, controls and overlays md, pills full
duration-* fast 150 · base 200 · slow 300 every transition

These override Tailwind's own steps on purpose, so rounded-lg means the same thing everywhere.

Layering

Class Value Used by
z-sticky 10 sticky table headers
z-header 30 DashboardHeader
z-bottomnav 40 BottomNav
z-dropdown 50 ActionMenu, UserMenu, Tooltip
z-overlay 60 MobileMoreSheet
z-modal 70 Modal, ConfirmDialog, ConfirmProvider, toasts

Slot overrides

Every component takes classNames for its named inner elements. tailwind-merge runs internally, so your classes win over the defaults.

<Input
  className="max-w-xs"
  classNames={{ label: "normal-case", input: "font-mono" }}
/>

Component reference

Foundational

Button

  • variant: primary | secondary | danger | ghost
  • size: sm | md | lg
  • isLoading: shows a spinner and disables the button
  • icon: IconComponent
  • classNames: icon, spinner

Badge

  • variant: success | warning | error | info | neutral
  • size: sm | md | lg

Card

  • title, action (rendered right), titleAs (heading level, or div to stay out of the document outline)
  • padding: none (default) | md. none hands the content area to the child — a DataTable, a DataMultiView, a FileBrowser: anything that brings its own spacing and scroll container and has to be a direct child of the card to fill it. md wraps the children in the p-6 that DataCard used to bring.
  • classNames: header, headerTitle, headerAction, content (the padding wrapper, which only exists while padding is not none)

StatCard

  • label, value, sub, icon: IconComponent, onClick
  • Renders a real <button> when onClick is given, so it is reachable by keyboard.
  • classNames: label, value, iconWrapper, icon, sub
  • Label, value and sub line up across a row of cards, whatever wraps and whichever card has no sub. That needs the cards to be direct children of a grid (grid grid-cols-…); each card takes three of its rows (CSS subgrid). Anything else in the same grid fills one of the three unless it is given row-span-3 too. Outside a grid a card lays itself out alone.

ActionButton

An icon-only button. tooltip does two jobs: it becomes the accessible name and the visible Tooltip. Pass aria-label to name it differently.

  • icon: IconComponent, size: sm | md | lg
  • color: green | blue | red | orange | gray | indigo | error
  • variant: solid | ghost
  • disabled: boolean | (() => boolean)
  • tooltip: string | { enabled, disabled }

ActionMenu

A portalled dropdown following the WAI-ARIA menu pattern.

  • isOpen, onClose, anchor: AnchorRect — use useActionMenu to produce it
  • triggerRef: focus returns here on close

Forms

All of them are built on FormField, which owns the label, the hint, the error message and the ARIA wiring that ties them together. They therefore behave and align identically.

FormField

  • label, hint, error, required, id, describedBy
  • layout: stacked (label above) | inline (label beside, for checkbox/radio/switch)
  • children: a render prop receiving { id, describedBy, invalid, … }

Use it directly to wrap a control the library does not have:

<FormField label="Cron expression" hint="Five fields, UTC." error={error}>
  {(ids) => (
    <input id={ids.id} aria-invalid={ids.invalid} aria-describedby={ids.describedBy} />
  )}
</FormField>

The rule it encodes: an error replaces the hint on screen and in aria-describedby, so nothing is announced that is not visible.

FieldLabel

The caption FormField puts above a control, for the places that have no such control: a read-only value, a list with its own buttons.

  • as: label (default, with htmlFor) | div | p | span
  • required — the asterisk; decoration only

Input

  • label, hint, error, icon: IconComponent, fullWidth
  • classNames: label, control, input, icon, error, hint

Select

  • label, hint, error, options: { value, label, disabled? }[], fullWidth
  • variant: field (default) | pill — pill is for a row of filters and has the shape of the search in DataMultiView. A select passed as that view's searchActions is a pill without being told; variant="field" turns it back.
  • classNames: label, control, select, error, hint

Textarea

  • label, hint, error, rows (default 4), fullWidth
  • Resizes vertically only — horizontal resizing breaks the form it sits in.

CopyField and useCopyToClipboard

A value to take away — a token, a key, a fingerprint — beside the button that copies it.

<CopyField value={token} aria-label="Registration token" />
  • value, aria-label (required — the field has no visible label of its own)
  • labels: copy, copied, unavailable
  • classNames: input, button, notice
  • A click into the field selects all of it.
  • Without a clipboard the button selects the value and says so. The clipboard API exists in a secure context only, and a dashboard is often reached over plain HTTP in a LAN; a copy button that silently does nothing there is the defect this component exists to end.

useCopyToClipboard() is the same behaviour for a text that is not a field — a log, a snippet in a <pre>:

const { copied, unavailable, copy } = useCopyToClipboard();

if (!(await copy(text))) window.getSelection()?.selectAllChildren(box.current);

copy never throws: it resolves false and sets unavailable, and selecting the text is left to the caller, who knows where it is.

NumberField

A whole number, kept as a string and clamped as it is typed.

  • label, value: string, onChange(value: string), min (default 0)
  • hint, error, placeholder, disabled
  • Anything below min, and an emptied field, becomes min.

SectionHeader

  • title, titleAs (default h3), children — what the section controls, held to a readable line length

Checkbox

  • label, hint, error, indeterminate
  • A restyled native <input type="checkbox">, so keyboard and form behaviour are the browser's. indeterminate is the third state, for a "select all" box over a partial selection.

Radio and RadioGroup

<RadioGroup label="Schedule" value={schedule} onChange={setSchedule}>
  <Radio value="daily" label="Daily" />
  <Radio value="weekly" label="Weekly" />
</RadioGroup>
  • RadioGroup: label, hint, error, required, disabled, name, orientation, plus value / defaultValue / onChange
  • A real <fieldset> with a <legend>. Without it a screen reader announces "Daily, radio button" and never says what is being chosen.
  • One tab stop for the group, arrow keys within it — native behaviour, kept.

Switch

  • label, hint, error, plus value / defaultValue / onChange
  • role="switch", not a checkbox: a switch is the action, a checkbox states an intention that a later submit carries out. Screen readers announce them differently.

Overlays and feedback

Modal

Focus is trapped inside, Escape closes it, the page behind does not scroll, and focus returns to whatever opened it.

  • isOpen, onClose, title (required — an unnamed dialog is unusable)
  • description — wired to aria-describedby
  • footer — actions, pinned below the body
  • size: sm | md | lg | xl | full
  • closeOnOverlayClick (default true) — turn it off for a form with unsaved input
  • hideCloseButton, closeLabel

ConfirmDialog

A Modal with the two buttons every confirmation needs.

  • onConfirm, confirmLabel, cancelLabel
  • cancelLabel={null} — drops Cancel, for a notice with nothing to decide
  • variant: primary | danger
  • isConfirming — spinner on confirm, both buttons blocked

ConfirmProvider / useConfirm

Asks and tells from event handlers, without a pending, an isConfirming and a <ConfirmDialog> in every component that needs one. One provider renders a single dialog; requests that arrive while it is open wait their turn.

<ConfirmProvider>
  <App />
</ConfirmProvider>

const { confirm, alert } = useConfirm();

if (await confirm({ title: "Pull & recreate nginx:latest?", confirmLabel: "Pull & recreate" })) pull();

// The dialog stays open, busy, until the action is done.
confirm({ title: "Delete pbs-node-01?", variant: "danger", onConfirm: () => deleteClient(id) });

await alert({ title: "Cannot delete the last user", description: "Create a second user first." });
  • confirm() resolves true once confirmed, false on Cancel, Escape, × or the backdrop
  • onConfirm — runs with the dialog open and blocked. Throwing keeps it open and shows the error's message inside it, next to the button that retries; returning false keeps it open without a message
  • alert() — one button (okLabel, default OK), resolves when closed

Alert

A message that stays in the page: a read that failed, a save the server refused, the result of a test.

<Alert title="Could not load the users">{message}</Alert>
<Alert tone="success" title={`Delivered (HTTP ${status})`} />
  • tone: error (default) | success | warning | info | neutral
  • title, children — the detail below it, or the whole message without one
  • icon — replaces the tone's icon; null shows none
  • An error interrupts (role="alert"); every other tone waits (role="status").

Toast

Needs a provider, like confirmations: toasts are raised from event handlers and request callbacks, i.e. from outside the tree that shows them.

<ToastProvider placement="bottom-right" duration={5000} limit={4}>
  <App />
</ToastProvider>

const { show, dismiss, dismissAll } = useToast();
show({ title: "Backup started", description: "pbs-node-01", variant: "success" });
  • variant: info | success | warning | error — errors interrupt (role="alert"); everything else waits politely
  • duration: milliseconds, 0 keeps it until dismissed
  • action: { label, onClick } — runs, then dismisses
  • The countdown pauses on hover and focus and resumes with the time that was left, not a fresh one.

Tooltip

Replaces the native title, which cannot be styled, does not exist on touch devices and is announced inconsistently.

  • content, placement: top | bottom | left | right, delay, disabled
  • Takes exactly one element as its child and describes it via aria-describedby — it never renames it. A control with no visible label still needs an aria-label, because a tooltip that fails to open leaves it unnamed.
  • Opens immediately on focus, after delay on hover, and closes on Escape.

Layout and navigation

EntityHeader

The head of a page about one thing: its name, a few badges, its actions, and details below.

  • title, titleAs, leading, meta, actions, alert
  • details / detailGroups, each detail visibility: 'always' | 'expanded'
  • value / defaultValue / onChange / persist — whether the details are open
  • actionsBelow — on a narrow screen the actions take the last line; for a header with a row of buttons rather than one menu trigger
  • On a narrow screen the badges take a line of their own below the title and the actions, so a long title keeps the width of the row.

Collapsible

  • title, plus value / defaultValue / onChange
  • Animates grid-template-rows from 0fr to 1fr, so arbitrarily tall content is never cut off. Collapsed content is inert — out of the tab order and off the accessibility tree.

Stepper

The progress strip of a multi-step flow — an <ol>, because the position ("3 of 5") is what a step indicator is for.

  • steps: { id, label, description? }, current, orientation
  • The current step carries aria-current="step"; a completed one says so in its accessible name (completedLabel).
  • onStepSelect makes the completed steps buttons, so the user can jump back. Without it the strip is inert — a display, not a control. Upcoming steps never become controls: skipping past the current step would skip what it validates.
  • Owns no state and never advances by itself.

Wizard

Stepper plus the current step's content plus the navigation between them. Works inline in a Card and inside a Modal (classNames={{ body: 'flex p-0' }}, no modal footer — the wizard brings its own).

<Wizard
  steps={[{ id: 'mode', label: 'Connection', canContinue: !!mode, content: <ModeStep /> }, …]}
  value={index} onChange={setIndex}          // controlled: required for a branching flow
  onCancel={close} onFinish={create} finishLabel="Create client" isFinishing={creating}
/>
  • canContinue per step is the whole validation contract — the wizard never inspects the content, it only asks whether the step is complete.
  • hideBack for a step that has already had an effect and cannot be undone.
  • Only the current step is rendered, so the step contents' state belongs to the caller. A step holding its inputs in its own useState loses them on Back; hold the form data above the wizard and Back/Next come for free.

SideTab

One tab of a column of them — the sections of a settings page. useTabs owns the behaviour and leaves the look to the caller; this is the look three apps drew identically.

const tabs = useTabs({ tabs: ['general', 'retention'], orientation: 'vertical' });

<TabList tabs={tabs} aria-label="Settings sections" className="flex flex-col">
  <SideTab tabs={tabs} value="general" icon={Globe}>General</SideTab>
  <SideTab tabs={tabs} value="retention" icon={Clock} trailing={unsavedMarker}>Retention</SideTab>
</TabList>
  • tabs, value, icon, children
  • trailing — after the name: a marker for unsaved changes, a count

Sidebar and BottomNav

  • Sidebar: groups — { title?, items }, each item with icon: IconComponent
  • badgeLabel says a badge in words ("3 of 5 online" for "3 / 5"): it is the badge's tooltip and part of the entry's accessible name, also where the collapsed sidebar shows no badge
  • BottomNav: items — the same item shape, flat
  • Each surface picks its own icon size; there is no size prop to get wrong.

Dashboard

The top-level shell: header, sidebar, bottom nav and the mobile overflow sheet.

It renders navigation, not routing. It highlights the entry matching currentPath and leaves what is on screen to your router.

<Dashboard pages={pages} navGroups={navGroups} currentPath={location.pathname} /* … */>
  <Routes>
    <Route path="/clients" element={<Clients />} />
    <Route path="*" element={<NotFound />} />
  </Routes>
</Dashboard>
  • pages: DashboardPage[] — id, path, active?, nav
  • navGroups, currentPath, mobileMore: { title?, icon? }
  • username, onLogout, theme, onToggleTheme, isSidebarCollapsed, onToggleSidebar

PaginationControls

Rendered automatically by every data view with a pagination prop; rarely needed directly.


Data views

DataTable, DataList, DataTreeTable and DataMultiView share one pipeline.

Sorting and expansion

<DataTable sort={{ defaultValue: [{ colIndex: 0, direction: 'asc' }], storageKey: 'clients' }} />
<DataTreeTable expanded={{ all: true }} />

Both follow the state convention. storageKey persists the choice while uncontrolled — it is the default of that variant, not a second mode; a controlled caller decides for itself.

Keyboard and screen readers

Sorting and expansion are not mouse-only:

Action Mouse Keyboard
Sort by a column click the header Enter / Space on the header button
Add a column to the sort Shift+click Shift+Enter
Expand or collapse a tree row click the chevron Enter on the chevron button
Expand or collapse everything click the header chevron Enter on it
onRowClick click the row Enter / Space on the focused row

A sortable <th> carries aria-sort, the chevrons carry aria-expanded, and a row with onRowClick is focusable. Clicks and keystrokes that start on a control inside a row — a DataAction menu, a link, a checkbox in a cell — belong to that control and no longer fire onRowClick as well.

Where only some rows lead somewhere — the leaves of a tree, say — isRowClickable names them. A row it turns down gets no pointer and no hover, and stays out of the tab order.

New classNames slot on both tables: sortButton, for the header's button.

DataMultiView<T>

Switches between table, list and tree view.

  • columns + listGroups: every column once, for both views (see below)
  • tableDef, listColumns, treeTableDef, getChildren
  • search: Controllable<string> + searchable / searchFilter
  • viewMode: Controllable<'table' | 'list' | 'tree'> + storageKey
  • treeExpanded, sort, pagination
  • extraActions: the header's actions, handed what selection picked

columns takes a DataColumnDef<T>[] and replaces tableDef and listColumns, which ask for the same column twice. The heading is also the list label, and one render serves both views:

const columns: DataColumnDef<Client>[] = [
    { header: 'Host', accessorKey: 'hostname', sortable: true, list: { label: null } },
    { header: 'Status', sortable: true, sortValue: (c) => c.status,
      render: (c) => <Badge variant={tone[c.status]}>{c.status}</Badge> },
    { header: 'ID', accessorKey: 'id', table: false },
    { header: 'Actions', render: (c) => <DataAction rowId={c.id} … />,
      list: { label: null, group: 'actions' } },
];

<DataMultiView
    columns={columns}
    listGroups={[{ id: 'content', grow: true }, { id: 'actions', className: 'md:text-right' }]}
    …
/>
  • table: false / list: false leaves the column out of that view. A sort's colIndex counts the table's columns, so it skips the ones with table: false.
  • list.label replaces the heading as the label; null shows the value alone.
  • list.group names the block of the row the field sits in. A field without a group, or with one listGroups does not define, goes into the first.
  • render(item, view) receives 'table' or 'list' for the cell that has to differ between the two.

tableDef and listColumns keep working. Passed next to columns they are ignored, with a warning.

One place for actions. A list's buttons sit in the header, next to the view toggle. Given as a function, extraActions is handed the picked keys, so a button acts on all rows while nothing is picked and on the picked ones once something is:

<DataMultiView
    selection={{ value: picked, onChange: setPicked, rowLabel: (c) => `Select ${c.hostname}` }}
    extraActions={(selected) => (
        <>
            <Button size="sm" icon={RefreshCw} onClick={() => (selected.size > 0 ? check(selected) : checkShown())}>
                {selected.size > 0 ? 'Check selected' : 'Check all'}
            </Button>
            <Button size="sm" disabled={selected.size === 0} onClick={() => restart(selected)}>Restart</Button>
        </>
    )}
    …
/>
  • What "all" is -- the rows the search and the filters leave, or more than that -- is the button's to know. Its label says which reach it has.
  • A button that only makes sense for picked rows stays in place and is disabled while nothing is picked, so the first pick does not move the others.
  • The pick is counted in brackets behind the title (selection.label, or "3 selected").
  • extraActions given as a node works as before and does not see the pick.

The layout every list shares

The two blocks of a list row, the actions column and the paging are the same in every list of an app, so they are spelled once:

const columns: DataColumnDef<Client>[] = [
    { header: 'Host', accessorKey: 'hostname', sortable: true },
    actionsColumn((c) => <DataAction rowId={c.id} … />),
];

<DataMultiView
    columns={columns}
    listGroups={listGroups()}
    pagination={listPagination(PAGE_SIZE.page)}
    …
/>
  • listGroups(contentClassName?) — the content block and the actions block
  • actionsColumn(render, { header?, listClassName? }) — one set of buttons for both views, in the block ACTIONS_GROUP
  • PAGE_SIZE.page (20) for a list that is a page of its own, PAGE_SIZE.embedded (10) for one below a header or inside a tab; listPagination(size) hides the bar while one page holds everything
  • For a tree: treeListGroups(), treeActionsColumn(render) and {...TREE_ONLY} spread onto a view that offers no other mode — the tree table where it fits, the list by itself where it does not

Pagination

Every data view can page itself. Pass the complete data set — the view filters, sorts and then takes the current page, which is the only order that sorts the whole table rather than the rows that happen to be on screen.

<DataTable data={rows} itemDef={cols} keyField="id" pagination />

Defaults: page 1, 10 rows per page, options [10, 20, 50].

Configured:

pagination={{ defaultValue: { pageSize: 25 }, pageSizeOptions: [25, 50, 100] }}

State held outside (URL sync, reset from elsewhere):

const page = usePagination({ pageSize: 25 });
<DataTable data={rows} pagination={{ ...page.props }} />

Server-side paging — data is already the current page, so the view neither sorts nor slices, and the total has to be supplied (-1 if unknown):

pagination={{
    mode: 'server',
    value: { page, pageSize },
    onChange: fetchPage,
    totalItems: total,
}}
Prop Default Meaning
mode 'client' 'server' when data is already one page
value / onChange — hold the state outside the view
defaultValue { page: 1, pageSize: 10 } starting values while the view holds it
pageSizeOptions [10, 20, 50] choices in the dropdown
totalItems — required for mode: 'server'; -1 = unknown
hideOnSinglePage false hide the bar while everything fits on one page
autoResetPage true back to page 1 when data or filterKey changes

Filtering. Pass filter (a predicate) with filterKey (a stable value identifying it, typically the query). The filter runs before the count, so the numbers in the bar always describe the rows on screen. DataMultiView wires both up from searchFilter.

Trees. DataTreeTable pages its root nodes: totalItems counts roots, and the children of the roots on a page are shown with them. Counting rendered rows would make a page's length depend on what is expanded.


Migrating from 2.x to 3.0

Six groups of changes. Each is mechanical, and TypeScript points at every call site — with one exception, called out in group 6, where a default changed.

1. Colour tokens

-dark tokens are gone. One token carries both values.

- <div className="bg-card dark:bg-card-dark text-text-primary dark:text-text-primary-dark">
+ <div className="bg-card text-text-primary">

If you overrode tokens, move the dark values into a .dark block:

  :root {
    --ruic-bg-card: #ffffff;
-   --ruic-bg-card-dark: #0b0b0b;
  }
+ .dark { --ruic-bg-card: #0b0b0b; }

Also gone: --ruic-info-light, whose value is now the dark value of --ruic-info-hover. New: --ruic-badge-neutral-bg and --ruic-badge-neutral-text.

The preset now sets darkMode: "class" itself — remove yours unless it differs.

2. Icons, sizes and class names

- <Button icon={<Save size={16} />}>Save</Button>
+ <Button icon={Save}>Save</Button>

- <ActionButton icon={Play} size={20} />
+ <ActionButton icon={Play} size="lg" />

- <Card classNames={{ root: "rounded-none" }} />
+ <Card className="rounded-none" />

- <DataTable containerClassName="flex-1" />
+ <DataTable className="flex-1" />

Affects icon on Button, Input, Sidebar, BottomNav, StatCard, Dashboard.mobileMore and MobileMoreSheet.

Badge.variant="gray" is now "neutral". BadgeClassNames, ActionMenuClassNames and DataActionClassNames.menu are gone — root was their only live slot. InputClassNames.inputWrapper and SelectClassNames.selectWrapper are both control now.

3. State props

- <Collapsible initiallyExpanded expanded={open} onExpandedChange={setOpen} />
+ <Collapsible defaultValue value={open} onChange={setOpen} />

- <DataTable defaultSort={{ colIndex: 0, direction: 'asc' }} sortStorageKey="clients" />
+ <DataTable sort={{ defaultValue: [{ colIndex: 0, direction: 'asc' }], storageKey: 'clients' }} />

- <DataTreeTable defaultExpanded />
+ <DataTreeTable expanded={{ all: true }} />

- <DataMultiView viewModeStorageKey="clients" defaultSearchValue="" onSearchChange={setQuery} />
+ <DataMultiView viewMode={{ storageKey: 'clients' }} search={{ onChange: setQuery }} />

useSortColumns and useTreeExpansion take their options in the same shape.

4. Dashboard renders navigation, not routing

DashboardPage.content is gone: move the content into your own routes and pass them as children. mobileMoreMenu and mobileMenuOverlay are gone too — use mobileMore and nav.placement: 'mobile-more'.

When no page matches currentPath, nothing is highlighted. 2.x silently fell back to the first page, which meant an unknown URL rendered page one. Give your router an explicit catch-all route.

usePagination no longer returns a slice of the data. Handing a view one page and letting it sort is what produced sorts covering only the current page; the view now filters, sorts and slices itself, in that order.

5. Menus

ActionMenu takes an anchor rectangle instead of a coordinate pair, and useActionMenu supplies it:

- <ActionMenu position={{ x: menuState.x, y: menuState.y, top: menuState.top }} />
+ <ActionMenu anchor={menuState?.anchor ?? null} />

ActionButton no longer sets a native title; the visible hint comes from a real Tooltip and the accessible name from aria-label. Nothing to change unless you were styling [title].

6. DataCard is gone — Card has padding

DataCard was Card plus a p-6 wrapper, and its own noPadding prop turned that single difference back off. It is now the padding prop on Card.

padding defaults to none, which is what Card has always done, so every existing <Card> call site keeps rendering exactly as it did. Only the DataCard call sites move.

- <DataCard title="Users">{form}</DataCard>
+ <Card title="Users" padding="md">{form}</Card>

- <DataCard title="Users" noPadding><DataTable … /></DataCard>
+ <Card title="Users"><DataTable … /></Card>

- <DataCard classNames={{ card: { header: 'py-2' }, content: 'space-y-4' }} />
+ <Card padding="md" classNames={{ header: 'py-2', content: 'space-y-4' }} />

DataCardClassNames is gone with it: its card key was a second nesting level around CardClassNames, and content moved onto CardClassNames unchanged.

The one change TypeScript will not point at: a DataCard that did not say noPadding needs padding="md" on the Card replacing it, or its content silently loses the p-6. Grep for <DataCard before you drop the import — that is the whole of the migration. Where a child element was there only to carry padding, padding="md" plus classNames.content replaces it.

Two shape notes for padding="none": there is no wrapper element at all, so classNames.content does nothing, and a child using flex-1 or h-full stays a direct child of the card, as it must be to fill it.

What you can now delete

Hand-built modals, confirmation dialogs, toasts, checkboxes, radio groups, switches and textareas. See the reference above.


Development

npm run storybook       # the workbench; the a11y panel must be clean per story
npm run build-storybook # static docs
npm run lint            # tsc --noEmit + eslint
npm test                # vitest (jsdom)
npm run tokens:build    # regenerate src/index.css from tokens.js
npm run tokens:check    # fails if it is stale
npm run build           # tsup: CJS, ESM, .d.ts

Every stable release publishes its Storybook to GitHub Pages (.github/workflows/storybook.yml, called by release.yml). A beta from dev does not; to republish by hand, run the workflow with an optional tag.

Storybook has a side-by-side theme mode that renders a story in light and dark next to each other. That is where colour changes are judged.

Code style

  • TypeScript everywhere, 4-space indentation.
  • Compose classes with cn() from src/utils.ts — never string concatenation.
  • Follow the four conventions above. A new component that invents a fifth way to pass an icon or hold state is the thing this library exists to prevent.

License

MIT © Stefan

About

A React 19 + TypeScript component library for dashboards: a Tailwind-based design system with themeable tokens, built-in accessibility and dark mode.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages