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
- 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.
npm install @stefgo/react-ui-componentsreact≥ 19.0.0react-dom≥ 19.0.0lucide-reacttailwindcss≥ 3.4
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.
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.
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) — thelocalStoragekey; the name belongs to the appdefaultTheme:dark(default) |light— what a first visit sees- A stored value that is neither of the two falls back to the default.
Four rules hold across the whole library. They are what makes it a system.
<Button icon={Save}>Save</Button> // yes
<Button icon={<Save size={16} />}>…</Button> // noOnly 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.
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 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.
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 doWhere 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.
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 itselfOverride 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.
| Token | Light | Dark |
|---|---|---|
--ruic-primary |
229 77 13 |
same |
--ruic-primary-hover |
255 95 31 |
same |
| 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) |
| 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 |
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.
| 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.
| 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 |
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" }}
/>variant:primary|secondary|danger|ghostsize:sm|md|lgisLoading: shows a spinner and disables the buttonicon:IconComponentclassNames:icon,spinner
variant:success|warning|error|info|neutralsize:sm|md|lg
title,action(rendered right),titleAs(heading level, ordivto stay out of the document outline)padding:none(default) |md.nonehands the content area to the child — aDataTable, aDataMultiView, aFileBrowser: anything that brings its own spacing and scroll container and has to be a direct child of the card to fill it.mdwraps the children in thep-6thatDataCardused to bring.classNames:header,headerTitle,headerAction,content(the padding wrapper, which only exists whilepaddingis notnone)
label,value,sub,icon:IconComponent,onClick- Renders a real
<button>whenonClickis given, so it is reachable by keyboard. classNames:label,value,iconWrapper,icon,sub- Label, value and
subline up across a row of cards, whatever wraps and whichever card has nosub. 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 givenrow-span-3too. Outside a grid a card lays itself out alone.
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|lgcolor:green|blue|red|orange|gray|indigo|errorvariant:solid|ghostdisabled:boolean | (() => boolean)tooltip:string | { enabled, disabled }
A portalled dropdown following the WAI-ARIA menu pattern.
isOpen,onClose,anchor:AnchorRect— useuseActionMenuto produce ittriggerRef: focus returns here on close
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.
label,hint,error,required,id,describedBylayout: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.
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, withhtmlFor) |div|p|spanrequired— the asterisk; decoration only
label,hint,error,icon:IconComponent,fullWidthclassNames:label,control,input,icon,error,hint
label,hint,error,options:{ value, label, disabled? }[],fullWidthvariant:field(default) |pill—pillis for a row of filters and has the shape of the search inDataMultiView. A select passed as that view'ssearchActionsis a pill without being told;variant="field"turns it back.classNames:label,control,select,error,hint
label,hint,error,rows(default 4),fullWidth- Resizes vertically only — horizontal resizing breaks the form it sits in.
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,unavailableclassNames: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.
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, becomesmin.
title,titleAs(defaulth3),children— what the section controls, held to a readable line length
label,hint,error,indeterminate- A restyled native
<input type="checkbox">, so keyboard and form behaviour are the browser's.indeterminateis the third state, for a "select all" box over a partial selection.
<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, plusvalue/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.
label,hint,error, plusvalue/defaultValue/onChangerole="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.
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 toaria-describedbyfooter— actions, pinned below the bodysize:sm|md|lg|xl|fullcloseOnOverlayClick(defaulttrue) — turn it off for a form with unsaved inputhideCloseButton,closeLabel
A Modal with the two buttons every confirmation needs.
onConfirm,confirmLabel,cancelLabelcancelLabel={null}— drops Cancel, for a notice with nothing to decidevariant:primary|dangerisConfirming— spinner on confirm, both buttons blocked
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()resolvestrueonce confirmed,falseon Cancel, Escape, × or the backdroponConfirm— 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; returningfalsekeeps it open without a messagealert()— one button (okLabel, defaultOK), resolves when closed
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|neutraltitle,children— the detail below it, or the whole message without oneicon— replaces the tone's icon;nullshows none- An error interrupts (
role="alert"); every other tone waits (role="status").
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 politelyduration: milliseconds,0keeps it until dismissedaction:{ label, onClick }— runs, then dismisses- The countdown pauses on hover and focus and resumes with the time that was left, not a fresh one.
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 anaria-label, because a tooltip that fails to open leaves it unnamed. - Opens immediately on focus, after
delayon hover, and closes on Escape.
The head of a page about one thing: its name, a few badges, its actions, and details below.
title,titleAs,leading,meta,actions,alertdetails/detailGroups, each detailvisibility: 'always' | 'expanded'value/defaultValue/onChange/persist— whether the details are openactionsBelow— 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.
title, plusvalue/defaultValue/onChange- Animates
grid-template-rowsfrom0frto1fr, so arbitrarily tall content is never cut off. Collapsed content isinert— out of the tab order and off the accessibility tree.
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). onStepSelectmakes 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.
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}
/>canContinueper step is the whole validation contract — the wizard never inspects the content, it only asks whether the step is complete.hideBackfor 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
useStateloses them on Back; hold the form data above the wizard and Back/Next come for free.
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,childrentrailing— after the name: a marker for unsaved changes, a count
Sidebar:groups—{ title?, items }, each item withicon: IconComponentbadgeLabelsays 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 badgeBottomNav:items— the same item shape, flat- Each surface picks its own icon size; there is no
sizeprop to get wrong.
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?,navnavGroups,currentPath,mobileMore:{ title?, icon? }username,onLogout,theme,onToggleTheme,isSidebarCollapsed,onToggleSidebar
Rendered automatically by every data view with a pagination prop; rarely needed
directly.
DataTable, DataList, DataTreeTable and DataMultiView share one pipeline.
<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.
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.
Switches between table, list and tree view.
columns+listGroups: every column once, for both views (see below)tableDef,listColumns,treeTableDef,getChildrensearch:Controllable<string>+searchable/searchFilterviewMode:Controllable<'table' | 'list' | 'tree'>+storageKeytreeExpanded,sort,paginationextraActions: the header's actions, handed whatselectionpicked
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: falseleaves the column out of that view. A sort'scolIndexcounts the table's columns, so it skips the ones withtable: false.list.labelreplaces the heading as the label;nullshows the value alone.list.groupnames the block of the row the field sits in. A field without a group, or with onelistGroupsdoes 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"). extraActionsgiven as a node works as before and does not see the pick.
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 blockactionsColumn(render, { header?, listClassName? })— one set of buttons for both views, in the blockACTIONS_GROUPPAGE_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
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.
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.
-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.
- <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.
- <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.
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.
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].
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.
Hand-built modals, confirmation dialogs, toasts, checkboxes, radio groups, switches and textareas. See the reference above.
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.tsEvery 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.
- TypeScript everywhere, 4-space indentation.
- Compose classes with
cn()fromsrc/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.
MIT © Stefan