iboon-ui
v0.13.1
Published
The iBoon design system: tokens, a component stylesheet and typed React components, shared across iBoon products.
Maintainers
Readme
iboon-ui
The iBoon design system: design tokens, a component stylesheet, and typed React components. Shared across the iBoon product family so that a button, a table and a dialog mean the same thing in every app.
npm install iboon-uiTwo halves, usable separately
The stylesheet is plain CSS with class names — .btn, .card,
.table-wrap, .badge-brand. It works from any framework, or none, in an app
that imports no JavaScript from this package at all.
The components are typed React wrappers over exactly those classes, plus the behaviour markup cannot express: a focus trap, a scroll lock, a toast queue, a menu that stays on screen.
Mixing them is expected rather than tolerated. <Button variant="primary"> and
className="btn btn-primary" render the same element — which is what lets an
app be migrated a screen at a time instead of all at once.
Setup
// once, at your entry point
import 'iboon-ui/styles.css';If your app already ships a CSS reset (Tailwind's preflight, for instance),
take the tokens and components without this package claiming ownership of
button and body:
import 'iboon-ui/tokens.css';
import 'iboon-ui/components.css';The system uses Inter for UI and Newsreader for headings. They are not
loaded here — a library that injects an @import for a font decides a network
request on your behalf and cannot be overridden. Link them yourself:
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,[email protected],500;6..72,600;6..72,700&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>Theme and density
Two independent axes, both set as attributes on <html>:
<html data-theme="dark" data-density="compact">| attribute | values | default |
| -------------- | --------------------- | ------------ |
| data-theme | light | dark | light |
| data-density | comfortable | compact | comfortable |
Dark is a real design, not an inversion: --brand and --danger become light
colours, so anything filled with them takes its foreground from --on-brand /
--on-danger rather than from a literal white.
Density exists because the apps genuinely differ. A screen of forms can afford air; a screen that is a fourteen-column grid of tasks cannot, and the same padding there produces a horizontal scrollbar. Only spacing and type-size tokens move — no colour, no radius, no component rule is restated.
// Your app picks once, at startup
document.documentElement.dataset.density = 'compact';Components
import {
Button, IconButton, Badge, Avatar, AvatarStack, Spinner, Divider,
Card, Panel, Grid, PageHead, BackLink, Toolbar, EmptyState,
Field, Input, Select, Textarea, Checkbox, SearchBox, FormActions, FormError,
TableWrap, Table, EmptyRow, List, ListItem,
StatRow, StatBox, Progress, Tabs,
Modal, ConfirmDialog,
ToastProvider, useToast,
ContextMenu,
} from 'iboon-ui';A page
<PageHead
title="Sprint 14"
subtitle="Nine days left. Four tasks unassigned."
scope="Identity · IDN"
actions={<Button variant="primary" onClick={create}>New task</Button>}
/>
<Card padded={false}>
<TableWrap>
<Table>
<thead>
<tr><th>Task</th><th>Assignee</th><th className="num">Points</th></tr>
</thead>
<tbody>
{tasks.length === 0 && <EmptyRow colSpan={3}>No tasks in this sprint</EmptyRow>}
{tasks.map((t) => (
<tr key={t.id}>
<td className="tcell-name">{t.title}</td>
<td><Avatar name={t.assignee} size="sm" /></td>
<td className="num">{t.points}</td>
</tr>
))}
</tbody>
</Table>
</TableWrap>
</Card>A form
Field generates the id and wires htmlFor, aria-describedby and
aria-invalid to the control inside it. Label-to-control association is the
thing hand-written forms get wrong most often — the label looks right, clicking
it does nothing, and a screen reader announces an unlabelled textbox.
<Field label="Sprint name" required error={errors.name}>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Goal" hint="What this sprint is for, in one sentence.">
<Textarea value={goal} onChange={(e) => setGoal(e.target.value)} />
</Field>
<FormActions>
<Button onClick={cancel}>Cancel</Button>
<Button variant="primary" loading={saving} onClick={save}>Save</Button>
</FormActions>A dialog
Portalled, focus-trapped, scroll-locking, Escape-closing, focus-restoring. Nested dialogs are handled: Escape closes only the topmost, and the scroll lock is released by the last one out.
<Modal open={open} onClose={close} title="Move to sprint" size="sm"
footer={<>
<Button onClick={close}>Cancel</Button>
<Button variant="primary" onClick={move}>Move</Button>
</>}
>
<p className="page-sub">Three tasks will move to Sprint 15.</p>
</Modal>ConfirmDialog focuses Cancel, not the confirm button — deliberately. The
dialog exists because the action is hard to undo, and the key someone reaches
for to dismiss a surprise is Enter.
<ConfirmDialog
open={open} onClose={close} onConfirm={remove}
title="Delete this sprint?" confirmLabel="Delete" danger
>
Its 14 tasks return to the backlog. This cannot be undone.
</ConfirmDialog>Toasts
<ToastProvider>{app}</ToastProvider>
const { show } = useToast();
show('Sprint saved');
show('Could not reach the server', { tone: 'danger' });
show('Upload in progress', { duration: 0 }); // stays until dismissedErrors get twice the default duration, because an error that vanishes in four
seconds is an error nobody read. useToast throws when there is no provider
rather than no-op'ing: a silently dropped confirmation looks exactly like the
action having failed.
Buttons
| variant | use |
| --- | --- |
| primary | the one action the screen is for |
| secondary | everything else (default) |
| ghost | tertiary, in a dense row |
| danger | destructive — outlined, not filled, so it is reachable without being the loudest thing on screen |
loading disables as well as spins. The reason to show a spinner is that the
action is already running, and a second click would run it twice.
IconButton requires a label. An icon button with no accessible name is
announced as "button", which is the same as not being there — so the type
system asks for the name rather than a linter noticing later.
Colour vocabulary
Four tones, and every state in these apps maps onto one:
- brand (pine) — settled, good, positive
- accent (ochre) — attention, not alarm
- danger — wrong
- neutral — nothing to say
A fifth would have to mean something none of these do.
Icons
This package draws four icons itself (close, search, back, check) and takes
everything else as a prop. It deliberately has no icon-library dependency —
consumers are on incompatible majors of lucide-react, and a peer range
spanning both would either break an install or quietly resolve to two copies.
import { Trash2 } from 'lucide-react';
<Button variant="danger" icon={<Trash2 />}>Delete</Button>License
MIT
