Brand book · 06
Component registry
Install the Dwellir parts in any React app with the shadcn CLI: the setup, every item in the registry, and the install command for each.
Set up an app
Three steps, then one command per part. This guide is REGISTRY.md from the site's repository, rendered when the site builds.
The design system's parts are a shadcn registry. Any Dwellir app with Tailwind v4 and shadcn can install them. The reference page is /design-system.
-
Set up shadcn in the app with Radix UI and Tailwind v4.1 or later, and the
@/import alias. Run:npx shadcn@4.22.1 init --base radix --preset nova -yConfirm
components.jsoncontains"style": "radix-nova". The defaultbase-novastyle transforms Radix composition props and breaks these parts. The registry format does not enforce the consumer style. Tailwind 4.0 fails the build: the theme uses variants and utilities that 4.1 added, such asnoscript:andwrap-anywhere. Add these imports to the app's global stylesheet:@import "tailwindcss"; @import "tw-animate-css"; @import "./styles/dwellir/parts.css";The theme declares the
tw-animate-cssnpm dependency. Its CSS import is required for accordion and sheet animations. The last path assumes the global stylesheet andstyles/share a directory. Adjust it to match the installed file location shown by shadcn. Before installing, check whetherstyles/dwellir/parts.cssalready exists at the target path. If it is not the Dwellir-generated file, move it first:-ooverwrites the whole file. -
Add the registry to the app's
components.json:{ "registries": { "@dwellir": "https://www.dwellir.com/r/{name}.json" } } -
Install the theme first, then the parts you need:
npx shadcn@latest add @dwellir/dwellir-theme npx shadcn@latest add @dwellir/button @dwellir/card @dwellir/section
Every part depends on @dwellir/dwellir-theme and @dwellir/utils, so the CLI
installs them too. The theme writes:
- the Dwellir Extended colours as full values, for
:root(light) and.dark, includingdestructive-foregroundfor stock shadcn parts and the status scale (see below); - the type, radius, width and shadow tokens (
text-heading,rounded-card,max-w-page,shadow-card, ...); - the
grain,grain-sky,glass,rim,rim-hoverandaurora-curtainutilities, and the sheet styles forSection; - shared part utilities, including
ui-button,card-title, andui-table-cell. These base styles come fromstyles/design-system-parts.csson this site.
@dwellir/utils replaces lib/utils.ts: its cn knows the theme's own names, so
cn('text-heading', 'text-foreground') keeps both classes. It needs
tailwind-merge 3, and the item installs it.
Move your helpers out of
lib/utils.tsfirst. The CLI overwrites the whole file and reports only "Updated 1 file". Every other export in it is lost, and the files that import them stop compiling.
Tested with shadcn@4.22.1 and Tailwind 4.3.3. The CLI writes the theme
variables to the global stylesheet. It overwrites the separate theme CSS file
when you re-add the theme with -o.
Color and field rules
Identity colors and registry colors use full OKLCH values. Legacy website RGB channel tokens remain for older components. Surface colors retain their existing order and appearance.
Palette colors use OKLCH. Subtle fills mix Oklab channels without transparency.
This reduces chroma and moves lightness toward the surrounding surface.
Cartesian mixing keeps a neutral surface from rotating a tint toward red.
design-system-tones.css recomputes these values on parts and supported backgrounds.
Cards, alt sheets, sky sheets, and popovers select their own surface.
Use the semantic utilities instead of opacity suffixes for subtle colors.
Transparency remains for glass, scrims, shadows, grain, aurora artwork and the dark border.
A glass card uses border-foreground/12 on purpose: its edge composites over
the aurora with its translucent fill.
The Button warning variant uses warning-ink for its fill and focus ring.
Its hover fill is the solid warning-ink-90 step.
Fields have calm edges. The placeholder, the label and the focus ring identify a field, so its edge only frames it. Four theme tokens set the look:
| Token | Light | Dark | Use |
|---|---|---|---|
--input | oklch(0.87 0.005 248) | oklch(0.4 0 0) | Field edge, 1.2–1.8:1 |
--input-hover | oklch(0.74 0.006 248) | oklch(0.52 0 0) | Field edge on hover, 1.9–3.1:1 |
--input-strong | oklch(0.58 0 0) | oklch(0.68 0 0) | Edge of a control with no other cue, 3:1 or more |
--field | oklch(1 0 0) | oklch(0.25 0 0) | Field fill |
Inputs, selects, input groups, textareas, copy fields, option pickers and the
calendar's month field use --input, --input-hover and --field. Keyboard
focus adds the 2px ring with a band of the ground on each side (7.3:1 or more
on every solid ground; see "Hairlines, inset rows, focus ring and modal fill").
The strong-edge rule: a control whose edge is its only cue takes
--input-strong, so it keeps 3:1 (WCAG 1.4.11). Checkbox and RadioGroupItem
draw it as their edge, and an off Switch fills its track with it. Their hover
darkens to muted-foreground. Give any new control of this kind the same edge.
Outline buttons and toggles draw their edge with foreground-15 and hover with
foreground-35. Their label names them, so the edge only groups them.
A large composer (InputGroup size="lg") draws its focus ring on its edge,
not 2px outside it, so the field shows one 2px frame. surface="glass"
frosts the group for a composer over the aurora: a sheer pane (40% card fill,
so the aurora shows through), the glass blur, a border-foreground/12 edge
and the card shadow. Focus brightens the edge to foreground/50 (3:1 or more
against the fill) instead of drawing the focus ring. Reduced transparency
makes the pane solid, and lite and still motion quality drop the blur and
fill it to 92%. With these, an app needs no composer classes
of its own: the dashboard's COMPOSER_SURFACE and the field, checkbox and
outline rules in its dwellir-overrides.css can go.
Set the tokens on your theme root to change them. Controls inherit them, including RGB overrides. The browser contrast contract checks both website and exported registry colors.
76 items
Every item in the registry, grouped as the design system shows them. The copy button copies the item's install command, npx shadcn@latest add @dwellir/<name>. The name links to the item's JSON.
Foundations
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/dwellir-themetheme | Dwellir Extended colours (dark and light) as full values, the status scale, the type, radius and shadow tokens, and the stacking (z-index) tokens, and the grain, glass, rim and aurora-curtain utilities.Needs @dwellir/field-style, tw-animate-css | |
| @dwellir/utilslib | clsx + tailwind-merge, taught the theme names (text-heading, rounded-card, ...).Needs clsx@^2, tailwind-merge@^3 | |
| @dwellir/brandcomponent | The Dwellir logo: DwellirLogo draws the wordmark or the shield as a decorative image, and the item installs both SVGs in public/images. One file serves both themes.Needs @dwellir/dwellir-theme | |
| @dwellir/field-stylecomponent | Browser colour decoder and shared aurora field types. | |
| @dwellir/input-modalitylib | Marks <html data-input> as keyboard or pointer, so menu items ring only for keyboard readers. | |
| @dwellir/portal-containerlib | Renders the tooltips, menus and selects opened in a landmark into that landmark, so their content stays inside the page's landmarks. | |
| @dwellir/touch-focuslib | An onOpenAutoFocus for modals: under a coarse pointer the modal's content takes the focus, not its first field, so the phone keyboard stays shut. |
Surfaces
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/cardui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slot, class-variance-authority@^0.7 |
| Item | Install | About |
|---|---|---|
| @dwellir/buttonui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/spinner, @radix-ui/react-slot, class-variance-authority@^0.7 | |
| @dwellir/badgeui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slot, class-variance-authority@^0.7 | |
| @dwellir/toggleui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-toggle, class-variance-authority@^0.7 |
| Item | Install | About |
|---|---|---|
| @dwellir/inputui | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/input-groupui | A field with addons: icons, text, keys or buttons round one input or text area, at inline-start, inline-end, block-start or block-end. size default is the Input's 40px pill; lg is a 56px field with 18px text and rounded-3xl corners, for a search composer.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, class-variance-authority@^0.7 | |
| @dwellir/textareaui | A multi-line field with the Input's fill, edge, focus ring and invalid state. sizing content grows with the text; fixed keeps its rows and resizes vertically.Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/labelui | Needs @dwellir/dwellir-theme, @dwellir/utils | |
| @dwellir/fieldui | A form field's frame: Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet, FieldLegend, FieldSeparator, FieldContent and FieldTitle. Field takes orientation vertical, horizontal or responsive; FieldLegend takes variant legend or label. FieldError takes a message, or react-hook-form errors as errors={[fieldState.error]}.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/label, @dwellir/separator, class-variance-authority@^0.7 | |
| @dwellir/formui | shadcn's react-hook-form bindings (Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage, useFormField), drawn with the Field parts. For zod schemas, also install @hookform/resolvers and zod.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/field, @radix-ui/react-slot@^1, react-hook-form@^7 | |
| @dwellir/checkboxui | A square on/off box, such as one optional purpose in a list. The edge has the input's 3:1 contrast; checked, it fills with the primary colour; checked="indeterminate" shows a minus for a mixed state. md is 16px, lg 20px for a settings row.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, @radix-ui/react-checkbox, class-variance-authority@^0.7 | |
| @dwellir/radio-groupui | One choice from a list, each with its own label. The Checkbox's look in a circle: md is 16px, lg 20px. The group stacks (vertical) or wraps in a row (horizontal). For two to five short options as pills or tiles, use choice-group.Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-radio-group@^1, class-variance-authority@^0.7 | |
| @dwellir/switchui | An on/off setting that applies at once. Off, the track takes the input's 3:1 edge colour; on, it fills with the primary colour. size default (36 by 20px) or sm (28 by 16px).Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-switch@^1, class-variance-authority@^0.7 | |
| @dwellir/selectui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/input-modality, @dwellir/portal-container, @dwellir/rune-icons, @radix-ui/react-select, class-variance-authority@^0.7 | |
| @dwellir/choice-groupui | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/toggle-groupui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/choice-group, @dwellir/toggle, @radix-ui/react-toggle-group, class-variance-authority@^0.7 | |
| @dwellir/sliderui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slider | |
| @dwellir/kbdui | A keyboard key on the muted fill, such as Enter or the keys of ⌘K in a KbdGroup. size default or lg.Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 |
Copy fields
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/copy-fieldui | Copyable inline chips, fields and scrollable read-outs with masked values and clipboard feedback.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/rune-icons, class-variance-authority@^0.7 |
Action lists
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/action-listui | Stacked action rows for links, clipboard values and pending or completed actions.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/copy-field, @dwellir/spinner, @dwellir/rune-icons, class-variance-authority@^0.7 |
Disclosure
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/accordionui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, @radix-ui/react-accordion | |
| @dwellir/collapsibleui | Content that opens and closes under a trigger. plain has no look: the trigger is a Button through asChild, as a show-more control. card is a bordered card whose header row opens it, for reference entries and questions; with forceMount, closed text stays in the server HTML and shows when script is off.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, @radix-ui/react-collapsible, class-variance-authority@^0.7 | |
| @dwellir/tabsui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-tabs, class-variance-authority@^0.7 |
Navigation
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/site-headercomponent | The site header: a full-width bar that contracts into a floating frosted pill over the first 160px of scroll, with slots for the brand, centre links and actions, a menu button and a phone menu sheet. SiteHeaderMenu lives in its own file, so an app can load it on demand.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/sheet, @dwellir/rune-icons, @radix-ui/react-slot@^1, class-variance-authority@^0.7 | |
| @dwellir/breadcrumbui | The trail to the current page. sm is small text over a page; title takes the size and face of the heading around it, on one line, as in an app header.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, @radix-ui/react-slot, class-variance-authority@^0.7 | |
| @dwellir/paginationui | Real links to the pages of a list. Each page has a Button's look (ghost, or outline for pills with a rim); the current page takes the secondary fill and has no href. Previous and Next keep their label on a phone.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/rune-icons |
App shell
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/sidebarui | shadcn's Sidebar family for an app shell: SidebarProvider, Sidebar (variant sidebar, floating or inset; collapsible offcanvas, icon or none; side left or right; position fixed or contained), its header, content, groups, menu rows with badges, actions, sub-menus and skeletons, SidebarTrigger, SidebarRail, SidebarInset and useSidebar. Below the md breakpoint it opens as a Sheet.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/input, @dwellir/separator, @dwellir/sheet, @dwellir/skeleton, @dwellir/tooltip, @dwellir/rune-icons, @radix-ui/react-slot@^1, class-variance-authority@^0.7 |
Overlays
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/dialogui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/portal-container, @dwellir/touch-focus, @dwellir/rune-icons, @dwellir/modal-surface, @radix-ui/react-dialog, class-variance-authority@^0.7 | |
| @dwellir/sheetui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/portal-container, @dwellir/touch-focus, @dwellir/rune-icons, @dwellir/modal-surface, @radix-ui/react-dialog, class-variance-authority@^0.7 | |
| @dwellir/modal-surfacelib | Sets the surface of every Dialog, AlertDialog and Sheet that names none. Mount it once at an app's root with surface="plain". | |
| @dwellir/popoverui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/portal-container, @dwellir/touch-focus, @radix-ui/react-popover, class-variance-authority@^0.7 | |
| @dwellir/dropdown-menuui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/input-modality, @dwellir/portal-container, @dwellir/rune-icons, @radix-ui/react-dropdown-menu, class-variance-authority@^0.7 | |
| @dwellir/tooltipui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/portal-container, @radix-ui/react-tooltip, class-variance-authority@^0.7 |
Command and dates
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/commandui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/dialog, @dwellir/input-modality, @dwellir/rune-icons, class-variance-authority@^0.7, cmdk@^1 | |
| @dwellir/calendarui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/rune-icons, class-variance-authority@^0.7, react-day-picker@^9.11.2 | |
| @dwellir/date-pickerui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/calendar, @dwellir/popover, @dwellir/rune-icons, class-variance-authority@^0.7, react-day-picker@^9.11.2 |
Option picker
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/option-pickerui | Searchable single and multiple selection with removable chips and optional icon slots.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/popover, @dwellir/input-modality, @dwellir/rune-icons, class-variance-authority@^0.7 |
| Item | Install | About |
|---|---|---|
| @dwellir/tableui | A scrollable table with opt-in container-based stacked rows, a sticky first column, surface variants, and full-width loading and empty states.Needs @dwellir/dwellir-theme, @dwellir/utils | |
| @dwellir/code-blockui | A block of code in the mono face on the ground colour, with a copy button (or, for a one-line command, the whole block as the copy control) and an optional title row. Long lines scroll sideways or wrap. Shiki output takes the colours of the site theme.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, class-variance-authority@^0.7 | |
| @dwellir/uptime-barui | A strip of uptime periods with a downtime slice per tier (the theme's status scale) and an optional lag cap, read with the pointer or the arrow keys, with one tooltip of per-period detail.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/tooltip, class-variance-authority@^0.7 | |
| @dwellir/skeletonui | Loading placeholders in the shape of the content on its way. SkeletonRows holds a list or table that has no layout yet: a named role="status" around sized rows, with the row count and row height as props.Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/separatorui | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/scroll-areaui | Content that scrolls inside a fixed height, with a thin bar in the line colour. type auto shows the bar while the content overflows; overscroll contain keeps the wheel in a panel at its end.Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-scroll-area, class-variance-authority@^0.7 | |
| @dwellir/emptyui | What a reader sees where data will be: a title, a line of text and the action that fills it. card stands in for content, dashed outlines where it will appear; xs with align start is a note in a list of results.Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slot, class-variance-authority@^0.7 | |
| @dwellir/spinnerui | A spinning ring for work in progress, with role status. size sm, md or lg; tone current, muted or accent. Button's pending prop shows it.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, class-variance-authority@^0.7 | |
| @dwellir/loading-splashcomponent | The loading family's splash: a ground layer for an app boot or a whole region with nothing to skeleton yet. A brand mark slot defaults to the Dwellir shield at /img/dwellir-mark.svg; a required label names its role="status"; a delay (300 ms by default) keeps fast loads from flashing it. fill is screen (fixed) or region (fills a positioned ancestor). One quiet motion: the mark pulses slowly, or the spinner part turns; reduced motion stands still.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/spinner, class-variance-authority@^0.7 |
App data
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/chartui | shadcn's Recharts 3 chart wrapper: ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent and ChartStyle. A ChartConfig colour such as "chart-1" picks one of the theme's five series colours; axis text, grid, cursor and tooltip take theme tokens. The series helpers assign distinct colours per set of series, keep a missing bucket a gap unless the caller passes missing="zero" for a measured range, step date ticks so labels never collide, and carry the quiet time-series preset (horizontal guides, no per-bucket dots).Needs @dwellir/dwellir-theme, @dwellir/utils, recharts@^3, class-variance-authority@^0.7 | |
| @dwellir/chart-legendui | A chart legend laid out beside the chart: series markers, muted labels, muted secondary detail, optional right-aligned values, and an icon slot. Rows align on phones and wrap on wide screens (layout="rows" or "wrap" pins a shape); hover and keyboard focus highlight alike; long lists collapse after maxVisible rows into "+N more".Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/chart, class-variance-authority@^0.7 | |
| @dwellir/data-tableui | A TanStack Table v8 data table with sorting, selection, pagination, expansion, and opt-in stacked rows with configurable container breakpoints. Responsive states sit outside the scroller.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/table, @dwellir/button, @dwellir/checkbox, @dwellir/dropdown-menu, @dwellir/empty, @dwellir/pagination, @dwellir/skeleton, @dwellir/rune-icons, @tanstack/react-table@^8 |
Stats and page header
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/statui | A metric read-out: label, figure in tabular numbers (formatter, optional count-up), unit, delta, hint, loading and unavailable states, as a tile, a row, or in a responsive StatGroup.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/skeleton, @dwellir/tooltip, @dwellir/rune-icons, class-variance-authority@^0.7 | |
| @dwellir/page-headerui | An app page header: muted icon slot, title and description, actions, and a tabs or toolbar row; sticky with a frosted glass state once the page scrolls under it.Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 |
Feedback
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/alertui | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/alert-dialogui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/modal-surface, @radix-ui/react-alert-dialog@^1, class-variance-authority@^0.7 | |
| @dwellir/avatarui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-avatar@^1, class-variance-authority@^0.7 | |
| @dwellir/progressui | Needs @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-progress@^1, class-variance-authority@^0.7 | |
| @dwellir/sonnerui | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/rune-icons, sonner@^2 |
| Item | Install | About |
|---|---|---|
| @dwellir/sectioncomponent | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/aurora-curtain, class-variance-authority@^0.7 | |
| @dwellir/section-headingcomponent | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/containercomponent | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/prosecomponent | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/icon-chipcomponent | Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 | |
| @dwellir/aurora-curtaincomponent | Needs @dwellir/dwellir-theme, @dwellir/utils |
Aurora canvas
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/aurora-canvascomponent | The moving aurora curtain of the site hero and footer, drawn on one canvas in a worker at 24 fps. AuroraCanvas shows a CSS still of the curtain before JavaScript runs and where no canvas can draw. Works without the solar wind.Needs @dwellir/dwellir-theme, @dwellir/field-style | |
| @dwellir/solar-windcomponent | The animated aurora: flux lines behind the page that light the edges of hovered cards and section titles, and feed the aurora-canvas curtain. Mount SolarWind once in the root layout. For marketing pages, not dense app screens.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/field-style, @dwellir/aurora-canvas |
Page hero
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/page-herocomponent | Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/container, @dwellir/section, @dwellir/section-heading, class-variance-authority@^0.7 |
Rune icons
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/rune-iconscomponent | Dwellir's icon set: 175 icons cut as runes, with straight strokes, square ends and mitred corners on a 24px grid. Each draws in currentColor. They replace Tabler, lucide and react-icons. | |
| @dwellir/rune-icons-codemodfile | A Node script that moves a codebase from Tabler, lucide and react-icons (tb, lu) to the rune icons, with the name map it reads. |
Chain icon
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/chain-iconui | Theme-aware chain artwork with initials, a dark-ground backplate and optional tile frame.Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7 |
Network badge
See them on the design system| Item | Install | About |
|---|---|---|
| @dwellir/network-badgeui | Neutral network, node-type and ecosystem labels. Network types appear in words.Needs @dwellir/dwellir-theme, @dwellir/utils, @dwellir/badge |
Reference
The rest of the registry's guide: upgrades, the logo, the status scale, the aurora, and the rules of each family of parts.
Upgrade
Never edit the theme's variables in place. A reinstall replaces cssVars by
key, including edits in :root and .dark. Before upgrading, move custom
values into a separate dwellir-overrides.css file. Import it after the theme
file, among the CSS imports. Use the doubled selectors below: shadcn writes
theme variables after imports, so the overrides need higher specificity.
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/dwellir/parts.css";
@import "./dwellir-overrides.css";
/* dwellir-overrides.css */
:root:root {
--field: oklch(0.98 0 0);
--radius-control: 17px;
}
.dark.dark {
--field: oklch(0.22 0 0);
}
Adjust the imports to match your files. Keep overrides outside the global
stylesheet that shadcn edits. Then re-add the items with -o:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/utils @dwellir/button
For the first upgrade to file-based CSS, remove the old Dwellir rules from the
global stylesheet. Remove its Dwellir @utility blocks, theme-specific
@layer rules, and other theme CSS that came from the old dwellir-theme
css field. Keep your own rules and the theme variables in :root, .dark,
and @theme. Compare with the previous /r/dwellir-theme.json if needed.
Then import the installed styles/dwellir/parts.css once, after Tailwind.
The CLI prints the installed path. Later upgrades replace this file with -o.
After updating to the OKLCH theme, re-install aurora-canvas, and solar-wind if the app uses it.
The theme also installs the shared field-style colour parser.
Check for duplicate utility definitions across your global stylesheet and the installed file. Pass the paths that shadcn printed:
python3 - src/index.css src/styles/dwellir/parts.css <<'PY'
import collections, pathlib, re, sys
marker = "/* Dwellir registry theme CSS. Generated; do not edit. */"
if not pathlib.Path(sys.argv[-1]).read_text().startswith(marker):
raise SystemExit("The last file is not the Dwellir-generated stylesheet")
names = collections.Counter()
for file in sys.argv[1:]:
css = pathlib.Path(file).read_text()
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
names.update(re.findall(r"@utility\s+([\w*-]+)\s*\{", css))
duplicates = {name: count for name, count in names.items() if count > 1}
print(duplicates or "No duplicate utilities")
raise SystemExit(bool(duplicates))
PY
Review the old theme's other merged rules too. This check only finds duplicate
@utility definitions; it does not identify stale rules without a duplicate.
The radius scale
One token sets every control's corner: --radius-control. The theme ships it
as a pill (999px), this site's look. Button, Input, Textarea, the Select
trigger, InputGroup, Toggle, ToggleGroup, ChoiceGroup, the Tabs track and Kbd
read it, so an app sets it once in its theme and every control follows:
@theme inline {
--radius-control: 0.5rem; /* 8px */
}
Derived tokens keep the shapes a pill cannot take. A multiline field caps it (--radius-control-max-2xl at 16px, the
large composer --radius-control-max-3xl at 24px, choice tiles
--radius-control-max-xl at 12px), and a key cap caps it at 4px
(--radius-control-max-sm). Override --radius-control alone; the derived
corners follow it.
--radius-inset (rounded-inset) is the corner of a card inside a card's
content, such as Card inset: the card corner less its 1px edge and a 16px
gap, never under 12px (--radius-xl), or under the card corner where that is
smaller. The 16px gap is the padding of a size="xs" card; md and sm cards
pad 24px and 20px. At a card corner of 28px or less the floor sets the corner:
12px at the theme's 18px card. It follows --radius-card: 15px at a 32px card.
The scale by role: items inside a panel take rounded-lg (8px), floating
layers (menus, popovers, select panels) rounded-2xl (16px), dialogs
rounded-3xl (24px), cards rounded-card (18px) and page sheets
rounded-sheet (32px). The Button shape prop is a legacy pin: pill and
rounded keep their corners whatever the token says. New code passes no
shape and sets the token instead.
The logo
npx shadcn@latest add @dwellir/brand
The item installs public/images/dwellir-wordmark.svg (7.5 KB) and
public/images/dwellir-shield.svg (3.9 KB), and DwellirLogo, which draws
either one as a decorative image at low fetch priority:
<a href="https://www.dwellir.com" aria-label="Dwellir website">
<DwellirLogo className="h-8 w-auto" />
</a>
<DwellirLogo mark="shield" className="h-8 w-auto" />
One file serves both themes. On dark grounds the green rim carries the shape (5.2:1 or more); on white the dark fill does (8.0:1). The link or heading around the logo names it.
The status scale and the uptime bar
The theme carries a status scale for uptime and incidents. Each tier has a fill, for bars and tints, and an ink, for text:
| Tier | Fill | Ink | Means |
|---|---|---|---|
| Operational | status-operational | status-operational-ink | Clean: at or above the clean cut |
| Minor | status-minor | status-minor-ink | Some downtime, at or above the minor cut |
| Major | status-major | status-major-ink | At or above the major cut |
| Down | status-down | status-down-ink | Below the major cut |
| No data | status-no-data | status-no-data-ink | No reading for the period |
Fills reach 3:1 and inks 4.5:1 on the ground and the card, in both themes and
on the alt sheet. Inks also reach 4.5:1 on the sky, but the light minor and
no-data fills reach only 2.88:1 there. Do not put status fills on a sky
surface. On this site they map onto the --au-status-* identity
tokens (styles/aurora-tokens.css). tests/paletteContrast.test.cjs and
tests/designSystemRegistry.test.cjs check the ratios. The registry test also
checks that the registry values match the site tokens.
Use the tokens as utilities: bg-status-minor, text-status-down-ink. Badge
has a status tone per tier (operational, minor, major, down). Use the
neutral badge for no data. As with every badge, the label says the state;
there is no status dot.
@dwellir/uptime-bar
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/uptime-bar @dwellir/badge
UptimeBar draws a strip of periods, oldest first. Each period is a green
bar. A period below the clean cut gets a slice at its foot, as tall as its
downtime (at least 5%), in its tier's fill. A period without data is grey.
The bars are decorative. The strip is one slider: the pointer, the arrow keys,
Home and End read a period. Right and Up step to a later period, Left and Down
to an earlier one. The readout under the bars shows its label and uptime, and
screen readers hear the same text, followed by the period's details. Without periods, the strip is an image named
"<aria-label>: no data", and the readout says "no data". A series that shrinks
while a period is read moves the reading to its last period.
A period may carry details, lines such as "Downtime: 4 hours" or "Lagging
behind chain head: 28m (up to 2048 blocks)". If any period has details, one
tooltip follows the active period (pointer, focus or arrow keys). It shows the
label, "Uptime: 99.00 %" (or "No data") and each line, and stays inside the
viewport. Escape hides it until the reading moves. The slider reads the same
text: the label and uptime, then each line. The tooltip loads on the first
reading, so a strip without details ships none of its code.
marker: 'lag' draws an amber cap (the minor fill, edged in the page ground)
on top of the bar, above any downtime slice. It is decorative, so also say the
fact in details. Production's rule is a lag of 5 minutes or more.
Each period is a bare <i data-slot="uptime-bar-period">. The theme's
ui-uptime-bar utility draws it by its data attributes, so a 90-period strip
adds no classes to the HTML. A period carries data-tier unless it is
operational, data-active while it is read and data-current when it is the
last, and data-marker when it has a marker. The slice is the period's
::after; the cap is its ::before.
| Prop | Type | Default | |
|---|---|---|---|
periods | { label: string; uptime: number | null; details?: string[]; marker?: 'lag' }[] | required | Uptime in % (0–100); null is no data. Give each period its own label. details fill the tooltip and the slider text. marker caps the bar. |
aria-label | string | required | Names the strip. |
size | 'default' | 'fill' | 'default' | default: 32 px bars. fill: fills a positioned parent. |
thresholds | Partial<{ clean; minor; major }> | { clean: 99.99, minor: 95, major: 80 } | The lowest uptime of each tier. |
start, end | ReactNode | first and last label | The readout's ends while no period is read. |
readout | boolean | true | Shows the line under the bars. A strip with details may set readout={false}: the tooltip says the same. |
The file also exports uptimeTier(uptime, thresholds?), which returns
'operational' | 'minor' | 'major' | 'down' | 'no-data', and
UPTIME_THRESHOLDS. Use the tier to pick a badge or a figure's ink, so a
number takes the colour of its bar.
The default cuts are the website's. The status page's slice colours already
used 95% and 80%. Its clean cut was any downtime; the shared cut is 99.99% (a
day may lose 8.6 s), because a fleet-wide day always loses a few seconds and
an any-downtime rule slices every bar. Pass thresholds={{ clean: 100 }} to
slice any downtime.
Adopt it in the status page
In apps/frontend/components/endpoint-uptime-bar.tsx, replace the bucket grid
in EndpointDetail with the part:
import { UptimeBar, uptimeTier, type UptimeTier } from '@/components/ui/uptime-bar';
<UptimeBar
aria-label={`${endpoint.displayName} uptime`}
periods={displayBuckets.map((bucket) => ({
label: formatTimestamp(range, bucket.timestamp),
uptime: Number.isFinite(bucket.uptime) ? Math.min(100, Math.max(0, bucket.uptime)) : null,
details: [
downtimeLine(bucket, range), // "Downtime: 4 hours", when the bucket lost time
describeBucketLag(bucket, range), // "Lagging behind chain head: 28m (up to 2048 blocks)"
].filter((line): line is string => Boolean(line)),
marker: shouldHighlightLag(bucket, range) ? 'lag' : undefined,
}))}
className="mt-3"
/>
Then delete UptimeBucketBar, MissingBucketBar, getBarBackground and
MIN_DOWNTIME_VISUAL_PERCENT. Keep formatDowntimeDuration (as downtimeLine) and lag.ts. Replace getUptimeClasses with the tier's ink:
const UPTIME_INK: Record<UptimeTier, string> = {
operational: 'text-status-operational-ink',
minor: 'text-status-minor-ink',
major: 'text-status-major-ink',
down: 'text-status-down-ink',
'no-data': 'text-status-no-data-ink',
};
export const uptimeInk = (uptime: number) => UPTIME_INK[uptimeTier(uptime)];
What changes for its readers:
- One tooltip follows the active period, not one per bar. Pass the downtime
length and the lag line as
details, andmarker: 'lag'for a lag of 5 minutes or more. Setreadout={false}if the tooltip is enough. - Aggregate figures from 99% to 99.99% turn from green to the minor ink, as their bars do.
- Major slices turn from
warning-inkto rose (status-major), as on the website.
Adopt it in the API platform dashboard
Install the same items. Map each endpoint's or key's daily series onto
periods, as above, and keep the default thresholds. Show a state with a
status badge; no data takes the neutral badge:
const tier = uptimeTier(value);
<Badge variant={tier === 'no-data' ? 'neutral' : tier}>{label}</Badge>;
Colour a figure with the tier's ink, as uptimeInk above does. Remove the
dashboard's own tier colours.
Stats and the page header
npx shadcn@latest add @dwellir/stat @dwellir/page-header
Stat is one metric read-out: a label, a figure in tabular numbers and
quiet meta. It carries no surface; put it on a Card.
Long values wrap at digit-group separators, with fallback wrapping for ungrouped values.
| Prop | |
|---|---|
label | What the figure measures. A string also names the hint button. |
value | A number to format, or a ready-made string such as "99.98". Null reads as a dash, and as "Not available" to screen readers. |
format | Formats a numeric value, also mid-count. Pass the app's own number format. |
unit, delta, description | A quiet suffix, a StatDelta (arrow plus change), and a line under the figure. Children go under the meta: a progress bar, an uptime bar. |
hint | A short explanation behind an info button next to the label. |
tone | neutral (default), good, warning, critical, muted, and the five status tiers (operational, minor, major, down, no-data). Colour marks a state: set a tone only when the value warrants it. The tiers paint with the status scale's own inks, so pass uptimeTier(value) and the figure matches its uptime bar. |
layout | stacked (a tile), inline (label left, figure right), or auto (inline in a narrow container, stacked from the md container width, inside a StatGroup). |
size, align | The figure's size (sm, md, lg) and, for tiles, its alignment. |
loading | A skeleton in the figure's place, at the figure's height. |
animate, duration | Counts a numeric value up as it changes (seconds), from the figure on screen. The first paint shows the final figure and does not count. Reduced motion skips the count. Screen readers hear the final figure once. Off by default. |
StatGroup lays several stats out by its own width, so it answers to the
card it sits in, not the viewport: a compact list in a narrow container, a
row of columns (2 to 6, or auto) in a wide one. layout="list" keeps
one column of inline rows, as in a usage summary card.
Marketing pages use the brand page-hero. Apps use page-header.
PageHeader is an app page's title row: leading (a sidebar trigger or a
back button), a muted icon slot, the title (an h1 by default, as="h2"
where the page already has one; hideTitle keeps it for screen readers
only) and a quiet description, meta readings beside them (hidden on
phones), and actions. Children form a tabs or toolbar row under the title.
size="md" is a top bar, size="lg" a page lead. A bar that spans the page
takes inset="page". With sticky, the header stays at the top of its
scroll container: clear at rest, frosted glass with a hairline once the
page scrolls under it (a sentinel element watches the nearest scroller, so
an app's main column or a panel frosts it too). bordered keeps the
hairline at rest too. The title truncates on a phone and the actions can
shrink, so a wide control narrows instead of covering the title.
In the API platform dashboard, this pair replaces the home-grown figures
(account-stats-*, stat-number, animated-stat-value, usage-text-row,
threshold-text-row) and each page's hand-built header (the root panel's
header row). Map the dashboard's ready flag onto value/loading, its
warning and error thresholds onto tone, and its route icon onto the icon
slot. On the status page, an endpoint's uptime figure is a Stat with a
string value and the tier's tone, beside its UptimeBar.
The site header
npx shadcn@latest add @dwellir/site-header
SiteHeader is the bar on every Dwellir page. At the top of the page it is
an open, full-width bar, 76px tall. Over the first 160px of scroll it
contracts into a frosted pill: 56px tall, 12px from the top, as wide as the
narrow Container's content box. The nav-shell and nav-bar utilities move
only translate, scale and opacity, so scrolling runs no layout and React
does not render per frame. The bar keeps the full bar's box and never moves.
The frosted pane (the bar's ::before) fades in and shrinks to the pill. The
brand and the actions close in on it, and the centre links shift with the
pill's centre. Where the browser has scroll-driven animations, CSS follows
the scroll. Elsewhere a scroll listener writes --nav-morph (0 to 1) on the
header. The shell keeps its height, so the page never shifts. The brand link
spans its column, so the empty bar beside the logo goes home. The website
renders its own header through this part.
A dropdown inside the centre links moves with them, so it takes them as its
containing block, not the nav. Copy nav-panel from styles/shell.css: it
starts at calc(50% + var(--navbar-height) / 2), the bar's lower edge. It
sets --nav-x: 0px and --nav-y: calc(var(--spacing) * 7 - var(--navbar-height) / 2),
and runs the bar's nav-move animation, so it follows the pill's lower
edge. Measure its offset from the centre links, not from the bar.
| Prop | ||
|---|---|---|
brand | required | The logo link. Wrap it in SiteHeaderBrand, which adds the focus ring to the link. |
children | Links centred in the bar from the collapse width, such as the website's menus. Hide them below it (hidden sm:flex). | |
actions | The bar's end: theme switch, account links, SiteHeaderMenuButton. | |
collapse | 'lg' | sm, md or lg: the width under which SiteHeaderMenuButton shows. Hide wide-only actions at the same width (max-sm:hidden). |
position | 'sticky' | static keeps it in the flow, as on /design-system. |
morph | Holds the bar at one value from 0 to 1, for demos. |
The theme switch goes in SiteHeaderThemeSlot, a fixed 40px slot, so the bar
does not shift when the switch renders after hydration.
SiteHeaderThemeButton takes mode and onToggle from the app's colour-mode
hook (next-themes, nextjs-color-mode), and an icon as its child if the app has
its own.
SiteHeaderMenu (site-header-menu.tsx) is the phone menu: a sheet from the
right with the rows, then the theme switch and account actions in its foot.
It carries the dialog code, so keep it out of the first load. The app owns the
open state: SiteHeaderMenuButton opens it, and the app mounts the menu on the
first press (next/dynamic, as the website does), or imports it directly when
the bundle cost does not matter. Rows take siteHeaderMenuRowVariants().
const [open, setOpen] = useState(false);
const menuButton = useRef<HTMLButtonElement>(null);
<SiteHeader
collapse="sm"
brand={
<SiteHeaderBrand>
<a href="https://www.dwellir.com" aria-label="Dwellir website">
<img src="/images/dwellir-wordmark.svg" alt="" width={190} height={40} className="h-7.5 w-auto lg:h-8.5" />
</a>
</SiteHeaderBrand>
}
actions={
<>
<SiteHeaderThemeSlot className="max-sm:hidden">
{mounted ? <SiteHeaderThemeButton mode={mode} onToggle={toggle} /> : null}
</SiteHeaderThemeSlot>
<Button>Sign up</Button>
<SiteHeaderMenuButton ref={menuButton} aria-expanded={open} onClick={() => setOpen(true)} />
<SiteHeaderMenu open={open} onOpenChange={setOpen} trigger={menuButton} description="Account links">
…
</SiteHeaderMenu>
</>
}
/>
Set --navbar-height on :root to change the 76px slot (and use it for
scroll-padding-top), and --z-navbar to lift the header above the app's
layers; it defaults to 40. The theme sets these defaults, and the glass fill
and blur, on :where(:root), so any :root rule of the app wins, before or
after the theme. Without transparency
(prefers-reduced-transparency), the theme makes the pill solid and drops the
blur.
Charts that stay honest, and skeleton rows
npx shadcn@latest add -o @dwellir/chart @dwellir/chart-legend @dwellir/skeleton @dwellir/loading-splash
The chart item also ships the series helpers in components/ui/chart-series.ts
(re-exported from @/components/ui/chart):
assignSeriesColors(names)andchartConfigForSeries(series)give each series a distinct theme colour while the five-colour palette lasts. The assignment depends only on the set of names, and the tokens already differ in light and dark. Past five series, group the rest into one series.missingonChartContainersays what a bucket with no value means. The default,"gap", leaves it missing, so unknown data breaks the line. Passmissing="zero"only for a range you measured, so an idle day reads 0. On the status page a missing bucket drawn as zero would show a false outage or a false calm, so it keeps the default.fillMissingSeriesValues(data, keys, missing)does the same work on rows a caller prepares itself.chartDateAxisProps(pointCount, chartWidth)steps date labels to the chart's width. Measure the width withuseChartWidth(from@/components/ui/chart-series): put its ref on theChartContainerand pass the width in. The first and last label always show and the rest spread evenly, at a 72 px budget each. With no width the axis assumes a phone plot and keeps at most four labels.QUIET_GRID_PROPS,QUIET_SERIES_PROPSandTIME_SERIES_MARGINare the quiet time-series preset: horizontal guides, dots on isolated or active points, and room at the axis ends for the first and last labels.
@dwellir/chart-legend is a legend the caller lays out beside the chart. An
entry is { key, label, detail?, value?, icon?, color? }: the label rests
muted, detail carries secondary metadata such as a network, value is a
right-aligned count, and icon is a slot for a chain mark. Rows align on
phones and wrap from sm up (layout pins rows or wrap). onHighlight
fires on hover and on keyboard focus alike, onSelect makes each row a
button, and maxVisible folds the rest into "+N more".
SkeletonRows (in @dwellir/skeleton) holds a list or table that has no
layout yet while it loads. It takes a label for its named role="status",
plus the rows count and a rowHeight (sm, default, lg), so the
placeholder keeps the shape of the content it stands in for.
The loading family is three parts: Spinner for work in progress,
SkeletonRows for content with a known shape, and @dwellir/loading-splash
for an app boot or a whole region with nothing to skeleton yet.
LoadingSplash is a ground layer (fill="screen" for the viewport,
fill="region" to fill a positioned ancestor) with a brand mark slot. The
default mark is the Dwellir shield at /img/dwellir-mark.svg, which each app
ships in its own public assets; pass mark where an app keeps it elsewhere.
A required label names its role="status", and delay (300 ms by
default) keeps a fast load from flashing it. The mark pulses slowly, or the
Spinner turns when spinner is set; reduced motion stands still.
Adopt them in the dashboard and the status page
The dashboard's usage charts replace their own colour hashing, zero-fill and
axis stepping with the helpers above, pass missing="zero" (their ranges are
measured), and render their legend rows as ChartLegendList entries with the
chain icon in the icon slot. Its LoadingRows becomes SkeletonRows. The
status page installs the same items and keeps missing="gap": a bucket with
no reading stays a break, never a zero.
The aurora canvas
@dwellir/aurora-canvas is the moving aurora curtain of this site's hero and
footer. It draws one multi-coloured curtain on a canvas, waving and pulsing.
It works on its own: it needs no solar wind, no quality governor and no
bootstrap script.
Which aurora
| Need | Install | Frame cost |
|---|---|---|
| A still curtain, or many curtains on one screen | AuroraCurtain (in dwellir-theme) | none: CSS only |
| One moving curtain: a dashboard header, a page backdrop | @dwellir/aurora-canvas | a worker paints at 24 fps; the main thread does no work per frame |
| The full marketing aurora: flux lines through the sheets | @dwellir/solar-wind (installs aurora-canvas) | the wind and the curtain |
Do not stack tinted AuroraCurtain copies and slide them to fake motion.
Their box edges show. Install aurora-canvas instead.
Install
npx shadcn@latest add @dwellir/aurora-canvas
The item writes these files, at the same paths as on this site:
components/aurora/:AuroraCanvas.tsx,AuroraField.tsx,curtainEngine.ts,curtainRenderer.ts,curtain.worker.ts,curtainWorkerConnection.ts,curtainProtocol.tsandcurtainStorm.ts;lib/aurora/:quality.ts,frameLoop.ts,frameGate.tsandfixedClock.ts;- through
field-style:fieldStyle.ts,fieldTypes.tsandquietMask.ts.
Its CSS goes into the app's global stylesheet: the .au-field layer, the
still, and the mapping of --au-dot to --au-dot-4 onto the theme's
--aurora-1 to --aurora-4. Keep the files at their paths. The imports
between them are relative, so the worker resolves without the @/ alias.
Mount
AuroraCanvas fills its nearest positioned ancestor. Size it with that
ancestor. Put it behind the content in an isolated container:
import { AuroraCanvas } from '@/components/aurora/AuroraCanvas';
// A dashboard header: the copy sits on the left.
<header className="relative isolate overflow-hidden">
<AuroraCanvas quiet="left" className="-z-1" />
<h1>Usage</h1>
</header>;
// A page backdrop: the curtain's edge sits at the foot of the page.
<main className="relative isolate min-h-svh">
<AuroraCanvas placement="footer" className="-z-1" />
{/* page content */}
</main>;
| Prop | Default | Effect |
|---|---|---|
className | none | Layout classes, such as -z-1. |
quiet | 'none' | Where text sits: 'top-left', 'left' or 'center'. The light thins out there. |
placement | hero | 'footer' puts the lower edge at the foot and raises the crown. |
The curtain is decorative: it renders aria-hidden="true". It has no
intensity prop. Its light follows the theme's aurora tokens, and only the solar
wind brightens it.
Colours and themes
- The curtain reads
--au-dotto--au-dot-4where it sits. The item maps them onto--aurora-1to--aurora-4. A section can set them locally. - Dark mode is
.darkon<html>. The curtain redraws when the class changes.
Motion and pausing
- It paints only while on screen (
IntersectionObserver) and while the tab is visible. A held curtain runs no timer. - With
prefers-reduced-motion: reduce, it draws one still frame on the canvas. - Without the solar wind, no quality level is set, so the curtain draws at full quality: all motion, at 24 fps, with one canvas pixel per CSS pixel on any screen. The light is smooth, so a denser canvas gains nothing.
- To stop the motion app-wide, set
data-au-quality="still"on<html>. The curtain then draws its still frame.
The still fallback
AuroraCanvas paints a CSS still of the curtain under the canvas: one layer,
coloured by altitude like the canvas. It shows in the server HTML before
JavaScript runs, and it fades out once a canvas draws. The box is never blank:
- Without
OffscreenCanvasor module workers, with a CSP that blocks the worker, when the worker fails to load, or when it does not answer in 5 s, the curtain paints on the main thread. - When no canvas can draw at all (no 2D context), the still stays.
?auroraWorker=offforces the main-thread canvas, for testing.
The worker is a separate same-origin file, so a CSP needs worker-src 'self'.
Frame cost
Measured over 10 s on a 1440 × 288 px dashboard header in a Vite build, in headless Chromium 156 with software rendering:
| Path | Main thread | Worker thread |
|---|---|---|
| Worker (the default) | 1.3 ms/s | 24 paints/s at 2.3 ms each; busy 212 ms/s with frame submission |
Main thread (?auroraWorker=off, or no worker) | busy 96 ms/s, 1.7 ms per paint | none |
The paint cost grows with the box's area: the curtain fills a buffer half the box's width and a quarter of its height. In that build the worker is a 5.5 kB file (2.6 kB gzip). A held curtain costs nothing.
The solar wind
@dwellir/solar-wind is the animated aurora of this site: flux lines that blow
through the sheets and light the edges of hovered cards and section titles.
They feed the curtain of @dwellir/aurora-canvas, which the item installs.
The files are this site's engine, unchanged.
Use it on marketing pages. Do not use it on dense app screens, such as the API
platform dashboard. They keep the still parts (Section, AuroraCurtain, the
rim variants), which cost no frame time. For one moving curtain on an app
screen, install @dwellir/aurora-canvas alone (see above).
Install and mount
npx shadcn@latest add @dwellir/solar-wind @dwellir/card @dwellir/section
The item writes components/aurora/wind/** and the quality governor at the
same paths as on this site, and its CSS into the app's global stylesheet.
aurora-canvas writes the curtain and lib/aurora/*. Keep the files at
those paths: the relative imports and the two workers depend on them.
Mount SolarWind once, in the root layout, as pages/_app.tsx does here:
import SolarWind from '@/components/aurora/wind/SolarWind';
export default function RootLayout({ children }) {
return (
<>
{children}
<SolarWind />
</>
);
}
It renders nothing on the server. After mount it adds its canvas layer to
<body> and starts the quality governor. For the quality level to apply before
the first paint, also inline QUALITY_BOOTSTRAP_SCRIPT from
@/lib/aurora/quality in <head>, as pages/_document.tsx does.
Where the wind shows
- Inside every
Section tone="sky"andtone="alt"sheet. A page with no sheet has no wind. - Round a hovered
Cardon the plain ground: a small window opens, and the card's edge lights. - Every
Cardis a hover collider. Itscolliderprop pickshover,alwaysoroff. Theh1,h2andh3inside aSectionare quiet text colliders. Tag any other element withdata-au-collider.
The wind layer sits at z-index: -1 in the root stacking context. While it is
mounted, the item's CSS paints each sheet's ground one layer lower, at
z-index: -2. So:
- give
<body>the ground colour (bg-background, asshadcn initdoes), not a wrapperdiv; - keep every element between
<body>and aSectionfree of a background,z-index,transformandisolation, or the wind cannot show through; - give a
Sectionno background class.
AuroraField (from aurora-canvas) fills its positioned parent. Put it
behind the content in an isolated container, as the site footer does. On a
marketing page the wind feeds it; AuroraCanvas adds the still fallback:
import AuroraField from '@/components/aurora/AuroraField';
<footer className="relative isolate overflow-hidden">
<AuroraField placement="footer" quiet="none" className="-z-1" />
{/* footer content */}
</footer>;
Colours, themes and motion
- The engine reads
--au-dotto--au-dot-4and--au-ink. Theaurora-canvasitem maps the first four onto the theme's--aurora-1to--aurora-4; this item maps--au-inkonto--foreground. - Dark mode is
.darkon<html>. The wind redraws when the class changes. - With
prefers-reduced-motion: reduce, the wind draws nothing andAuroraFielddraws one still frame. html[data-au-quality]holds the motion quality:full(no attribute),liteorstill. The governor steps it down while frames stay slow. Lite draws fewer lines and drops the blur ofglasssurfaces. Still draws no wind. With the bootstrap script in<head>,?quality=litepins a level for the browser session.
Limits
- The workers load with
new Worker(new URL('./wind.worker.ts', import.meta.url), { type: 'module' }). Vite and Next.js bundle that pattern. Other bundlers must support it too. - Without
OffscreenCanvasor workers, or with?auroraWorker=off, the wind paints on the main thread. That path loads a separate engine chunk. - Cost in a Vite build: the wind worker is 36 kB, the curtain worker 6 kB, and the fallback engine chunk 20 kB (minified, before gzip).
- The shadcn CLI drops the doc comment at the top of each file. The comments stay in this repo.
How it differs from this site
- Units. This site's root font size is 62.5%, so its theme scales every Tailwind token by 1.6. The registry theme uses stock units for a 16px root. A class gives the same pixels in both places.
- Colours. This site maps the theme onto its
--au-*identity tokens; the registry ships the resolved values. When the palette changes, update thedwellir-themevalues inregistry.json.tests/designSystemRegistry.test.cjschecks that they meet WCAG AA in every pairing the parts use. - Sheets. Here
styles/aurora-sections.cssand the solar wind styleSection. The registry theme carries a plain version: inset rounded sheets that swap the ground and card colours. Each sheet is isolated, so a stillAuroraCurtainat-z-1paints over the sheet's ground. Thesolar-winditem removes that isolation while the wind is mounted and moves each sheet's ground under the wind. - Preflight. Consumers keep Tailwind's preflight. This site scopes it to
design-system parts (
styles/tailwind.css).
Base styles and overrides
Parts pass shared utility names to cva or cn. Keep appearance choices in
cva variants. Call sites use layout classes such as w-full and mt-6.
Keep classes that callers override visible to cn, including font size,
line height, width, and margin. tailwind-merge cannot inspect declarations
inside a custom utility. A layout override in className replaces a visible
class from the same group. Use a variant to change a part's appearance.
Page-only utilities stay local. The dashboard uses
views/HomePage/PlatformHero/dashboardParts.css through its CSS Module.
Those rules are not part of the shared registry theme.
Copy fields and action lists
npx shadcn@latest add @dwellir/copy-field @dwellir/action-list
CopyField copies the complete value when the field or its native button
is clicked. A drag that selects text in the read-out does not copy. Its variant is inline, field (default), or multiline.
Inline chips and single-line fields truncate. Single-line fields follow --radius-control.
Multiline fields cap their corners at --radius-control-max-2xl.
Both field variants use the input fill and edge. Inline chips retain their existing shape.
The field variant renders a
read-only input with its copy button inside the field. Multi-line read-outs have a
fixed height and scroll internally, including with the keyboard.
<CopyField variant="inline" name="install command" value="npm install @dwellir/sdk" />
<CopyField label="Endpoint URL" value={endpointUrl} />
<CopyField label="API key" value={apiKey} masked />
<CopyField variant="multiline" label="Connection prompt" value={prompt} />
label shows above the field and names the copy button. Without a visible
label, use name. Pass id to bind the visible label to a specific input.
Accessible names never include the value.
masked hides the value until reveal and adds PostHog privacy markers.
Reveal does not copy. Replacing the value hides it again.
disabled blocks copying and reveal. React 19 refs and span attributes
pass to the root.
The whole field acts like a button. The pointer is a hand over the read-out,
and a disabled field shows not-allowed. Hover fills the field with the outline
button's muted tint and the copy button with the secondary button's hover fill.
Press steps one fill further. The copy button swaps to a check after a copy.
The ui-copy-field-control utility (in dwellir-theme) holds these states and
the focus ring. The copy button is the one tab stop, so Tab skips the single-line
read-out and rings the whole field. Enter and Space copy. The multiline read-out
stays a tab stop because it scrolls with the keyboard. Update the part and the
theme together: older copies keep the text cursor over the read-out.
The status page can replace its local subscription copy field with the same API:
import { CopyField } from '@/components/ui/copy-field';
<CopyField id="feed-atom-url" label="Atom feed URL" value={atomUrl} />
ActionList and ActionListItem provide list semantics and spacing.
Choose ActionListLink, ActionListCopy, or ActionListButton for each row.
Each accepts a leading icon slot and children for its label.
Long labels wrap within the row.
<ActionList aria-label="Tool actions">
<ActionListItem>
<ActionListLink href="/docs" target="_blank">Open documentation</ActionListLink>
</ActionListItem>
<ActionListItem>
<ActionListCopy value={installCommand}>Copy install command</ActionListCopy>
</ActionListItem>
<ActionListItem>
<ActionListButton pending={pending} done={done} onClick={connect}>
Connect tool
</ActionListButton>
</ActionListItem>
</ActionList>
Links preserve anchor semantics. New tabs receive noopener noreferrer
and an accessible hint. Buttons accept pending, done, disabled,
and an optional trailing mark slot. Pending buttons block activation.
Pending rows keep keyboard focus through completion, including clipboard writes.
Only caller-supplied disabled sets the native disabled state.
Buttons default to type="button" so tool actions do not submit forms.
Pending and done announcements sit outside the button. Their spinner is
decorative. Copy actions announce only their clipboard result.
The external-link mark and new-tab hint appear only with target="_blank".
Both copy controls use the Clipboard API, which requires HTTPS or localhost.
Pending copy buttons keep focus and reject extra pointer or keyboard activation.
Successful writes show a check and announce “Copied” for two seconds.
Failed writes show a retry message. onCopy runs only after success.
Pending writes cannot confirm a value that the caller has replaced.
Change or add a part
- Edit or add the file in
components/uiorcomponents/brand. Update its base rule instyles/design-system-parts.csswhen needed. The registry build copies utilities used by published parts fromstyles/design-system-parts.cssinto thedwellir-themeCSS inregistry.json. Show newcvavariants on/design-system. Nest no@mediaor other at-rule in a published utility: the shadcn CLI appends it again on everyadd. Put it at the top level of the theme CSS, as theglassutility's reduced-transparency rule does. - Add a new part to
registry.json(name, npm dependencies, file path). Give a dependency a version range when the part needs that major, astailwind-merge@^3andclass-variance-authority@^0.7do. - Run
npm run registry:build. It writespublic/r, which the site serves. It also copiesstyles/aurora-canvas.cssandstyles/solar-wind.css(the site's aurora layers), each followed by its-registry.cssmapping onto the theme, into theaurora-canvasandsolar-winditems (scripts/registryCss.cjs). A new file undercomponents/auroraorlib/auroramust join one of the two items: the curtain's files joinaurora-canvas, the wind'ssolar-wind. The registry test fails until it does, and when two items ship the same file. - Commit the source,
registry.jsonandpublic/rtogether. CI fails whenpublic/rno longer matches the source. CI also compiles published parts against the published theme, including its shared utilities.
The registry can move to a repo of its own later: copy registry.json, the
part files and lib/utils.ts, and point @dwellir at the new host.
Compact controls and access choices
Reinstall the theme and affected parts together:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/button @dwellir/sidebar @dwellir/choice-group @dwellir/select
A mouse sees every control at its base size.
On a coarse pointer, a compact control's transparent hit region grows toward 44 px into half the gap around it.
Where the box and the gap make less than 44 px, the box itself grows by the difference: an sm button (32 px) is 36 px tall in a row with gap-2 and stays 32 px with gap-3. See "Touch and phones" for each part.
Hit regions never add margin or padding to existing layouts.
Block addons extend away from their text field.
The phone announcement bar and dismiss target measure 44 px on coarse pointers.
CSS sets this height before first paint. Fine-pointer and desktop bars keep their base height.
Adjacent targets consume at most half the nearest declared gap on each side.
The gap inherits through wrappers, so a control nested in a gapped row keeps to that row's gap.
Dense groups cap target growth instead of covering a neighbor.
With no gap declared above it, a control takes a full 44 px region.
A joined group of controls must declare gap-0, as ToggleGroup does.
Input addons and the announcement bar provide their own outward space.
The website loads nonvisual gap measurements with the full stylesheets.
Its announcement geometry remains in critical CSS for the first paint.
Use at least 32 px between compact controls when the group needs complete 44 px targets.
Sidebar rows and actions have separate hit regions; the icon rail keeps its width.
SidebarInset renders main by default.
Inside an existing main, use asChild with a div to avoid a second main landmark.
Use native radio choices for access levels:
<ChoiceGroup orientation="vertical" variant="pills" value={scope} onValueChange={setScope} aria-label="Access level">
<ChoiceGroupItem value="read" description="Inspect usage and configuration.">Read only</ChoiceGroupItem>
<ChoiceGroupItem value="write" description="Change configuration.">Read and write</ChoiceGroupItem>
<ChoiceGroupItem value="spend" disabled={!canSpend} description="Requires billing access.">Allow purchases</ChoiceGroupItem>
</ChoiceGroup>
Vertical choices have a radio marker alongside their selected surface.
Horizontal segmented choices and tiles keep their base appearance and dimensions.
Home and End select and focus the first and last enabled vertical choices.
Descriptions have their own accessible association.
Group disabled locks every item; item disabled locks only that choice.
Import selectTriggerVariants from @/components/ui/select for custom filter triggers.
Apply selectTriggerVariants({ size: 'sm' }) to a native button inside DropdownMenuTrigger or PopoverTrigger.
The default size matches a 40 px SelectTrigger; sm matches its 32 px size.
Supply the label and a decorative chevron as children.
Both sizes keep the base branch's rendering on fine and coarse pointers.
Under a coarse pointer the recipe adds SelectTrigger's tap area, capped at half the nearest declared gap.
Its token-based heights can scale below 44 px, including 38.4 px and 30.72 px on the mobile website.
Pass touch: false where the trigger already sits in a larger tap area or an input group.
Identity metadata uses neutral badges: ecosystem, network, plan tier and role. Reserve badge colour for state, such as operational, warning or error.
Touch and phones
Reinstall the theme and these parts together:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/utils @dwellir/button @dwellir/input @dwellir/textarea @dwellir/input-group @dwellir/command @dwellir/option-picker @dwellir/copy-field @dwellir/dialog @dwellir/alert-dialog @dwellir/sheet @dwellir/sidebar @dwellir/field @dwellir/empty @dwellir/site-header @dwellir/container @dwellir/tabs @dwellir/toggle @dwellir/toggle-group @dwellir/choice-group @dwellir/select @dwellir/pagination @dwellir/calendar @dwellir/data-table
The parts follow the pointer, not the screen width. An iPad is md wide and has a finger.
- 16px fields. Text fields keep
text-lg(16px or more) under a coarse pointer at every width, so iOS does not zoom in on focus. Only a fine pointer getstext-sm(14px), frommd. - No keyboard on open. Under a coarse pointer,
Dialog,Sheet,CommandDialogandPopoverfocus their content, not their first field, so a Popover + Command picker waits for a tap on its search field: the keyboard opens when the reader taps a field.@dwellir/touch-focusdoes this. Your ownonOpenAutoFocusruns first; callevent.preventDefault()in it to choose the focus yourself. A mouse keeps Radix's focus on the first field.AlertDialogneeds no change: Radix focuses its Cancel button. - Safe areas. With
viewport-fit=cover, dialogs and sheets sit 12px from the screen edge, or clear the status bar, notch and home indicator where they are deeper. The theme'smodal-*,corner-*anddocked-btokens hold themax(12px, env(safe-area-inset-*))sums, andmax-h-modalsubtracts them. The fixed sidebar ish-dvhand pads its edge, top and foot by the safe area.SiteHeaderpads its sides by the safe area, so in landscape its bar clears the notch; the sticky header also pads its top, so its bar sits under the status bar (position="static"does not).Containerpads each side by 16px (24px fromlg), or by the deeper of the left and right safe areas, so the column stays centred: the theme'sgutterandgutter-lgtokens. Withoutviewport-fit=coverevery inset is 0 and nothing moves. dwellir.com setsviewport-fit=cover(pages/_app.tsx), so in landscape its header and Container clear the notch. - Press feedback. A finger never hovers, so a held
Buttonshrinks to 97% under a coarse pointer. It eases out in 150ms and stays still under reduced motion. Disabled andvisualbuttons do not move. - Hover only for hovering pointers. A touch screen keeps
:hoverafter a tap. Every hover style in the parts and utilities,rim-hoverincluded, sits behind@media (hover: hover). Write[&_a]:hover:x, not[&_a:hover]:x: an arbitrary variant does not add the media query. - 44px targets. Under a coarse pointer the controls below take a tap of at least 44 by 44px; "Not grown" lists the rest. A mouse keeps every size and takes clicks on the painted box only:
xsandicon-xsmeasure 24px, the WCAG 2.5.8 minimum, and a mouse needs no more. Each part takes one of two routes:- The box grows where the part is wide:
Input,InputGroupand the defaultSelectTriggerare 44px tall under a finger.Calendardays touch, so each column asks for 44px and gives way to 36px, its mouse size: a calendar never grows past a container that holds it at 36px. - The hit area grows where the part is compact: every
Buttonsize,Toggle,ToggleGroupandChoiceGroupitems,SelectTrigger size="sm"and theselectTriggerVariantsrecipe on a button of your own (touch: falseopts out), aBadgethat renders a button, the pagination links, theDataTablesort buttons, the code block's copy button, theSiteHeaderbrand link, theme switch and menu button, and the dialog and sheet close buttons. The tap area is an::afterpast the painted box (touch-target), border included.
- The box grows where the part is wide:
- Hit areas meet halfway. A hit area takes at most half the nearest declared gap on each side, so two controls never share a tap point. Where the box and the gap make less than 44px, the box grows by the difference (
touch-fit): ansmbutton, 32px, is 36px tall in a row withgap-2and keeps 32px withgap-3. With no gap declared above it, a control keeps its box and takes the full 44px. A positioned button and alinkbutton do not grow. Lay out withgap-*, not margins: the parts read their room from the gap. - One-line groups.
ToggleGroupspaces its items withgap-x-*, so the room above and below them is the parent's gap. Joined items (spacing={0}) have no gap, so each grows to 44px wide and its hit area spans its own box: neighbours meet on the seam. A segmentedChoiceGroupitem takes its room from the track's 4px padding, so a 36px item taps at 44px inside the track. An inlineInputGroupAddonlies inside the 44px field, so its buttons tap at the field's height. - Lists in a modal.
OptionPicker's list renders inside the picker, so a modal's scroll lock lets it scroll by touch. Inside aDialog,SheetorAlertDialogit opens on the side with room, as anywhere else. If it then reaches past the modal's edge, the modal scrolls until it shows, and the list keeps that side while the modal scrolls, so it never flips over the summary's Clear. - Dense rows.
Checkboxand horizontal pills grow into half the gap around them, as buttons do; pills, at least 44px wide, grow in height only. A checkbox in a row withgap-2taps at 24px, the WCAG 2.5.8 minimum; itsLabelis the larger target. TheSliderthumb takes a 44px tap (slider-touch): nothing else sits in its reach. - Not grown. These stay under 44px on a coarse pointer:
Checkbox,RadioGroupItemandSwitchtake their larger tap through theirLabel: put the label beside them in aField.CopyField's copy button sits in a 44px field that copies on a tap; its reveal button has no such twin and taps at about 40 by 44px.visualbuttons and toggles take their children's size. Text links (linkbuttons, breadcrumb links) keep their line.DropdownMenuandSelectoption rows are 36px tall.UptimeBarstrips are 32px.StatHinttaps at about 46 by 36px.DataTablesort buttons in adensity="compact"table tap at the 36px row less its gap.- A control at a scrolling table's edge loses the part of its hit area past the edge; one under a later positioned box loses what that box covers.
OptionPicker's Clear keeps its 24px box and taps at 32px: it sits 4px above the field, and growing it would push the field and its list down.- Sidebar rows reach their row pitch: 36px with the menu's
gap-1; give the menugap-3for 44px.
- The sidebar row's
::afteris its tap area. ASidebarMenuButtontakes no pointer itself under a coarse pointer, so an app rule on its::after(a rim,rim,rim-hover) leaves the row dead to a finger. Draw marks on a sidebar row with::before, asaurora-currentdoes. - Swipe past a slider. A horizontal
Slidertakes a sideways drag and lets a vertical swipe scroll the page (touch-pan-y).
tests/registryTouch.test.cjs checks these rules under touch emulation at 390px and 820px, and with a mouse at 1440px.
App defaults, motion and type roles
These fixes come from the dashboard's seven interface audits. Reinstall the theme and these parts together:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/utils @dwellir/modal-surface @dwellir/button @dwellir/badge @dwellir/card @dwellir/checkbox @dwellir/choice-group @dwellir/slider @dwellir/select @dwellir/table @dwellir/data-table @dwellir/dialog @dwellir/alert-dialog @dwellir/sheet @dwellir/popover @dwellir/dropdown-menu @dwellir/tooltip @dwellir/sidebar @dwellir/uptime-bar @dwellir/input-group @dwellir/calendar @dwellir/tabs @dwellir/site-header
Then make these changes in the app:
-
One tooltip provider. Mount
TooltipProvideronce at the app's root, around the router. After one tooltip shows, the next opens at once for 300ms (skipDelayDuration), so a row of icon buttons reads quickly. ATooltipwith no provider above it still works and brings its own, with no shared warm state. -
A tooltip on a disabled button. A disabled button takes no pointer and no focus, so its tooltip never opens. Put the tooltip on a focusable
spanaround it:<Tooltip> <TooltipTrigger asChild> <span tabIndex={0} className="inline-flex rounded-control outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"> <Button disabled>Rotate key</Button> </span> </TooltipTrigger> <TooltipContent>Only an admin can rotate the key</TooltipContent> </Tooltip>aria-disableddoes not help:ui-buttongives itpointer-events: nonetoo./design-systemshows the pattern under Tooltip. -
Plain modals by default. Mount
ModalSurfaceProvideronce at the root:<ModalSurfaceProvider surface="plain">. EveryDialog,AlertDialogandSheetthat names no surface is then plain. Asurfaceprop still wins. Without the provider the default staysgrain, the website's look.
The parts and the theme also change:
- Reduced motion. Dialogs, alert dialogs, sheets, popovers, dropdown menus, selects and tooltips still fade under
prefers-reduced-motion: reduce, but they do not zoom or slide (ui-popup-motion). The sidebar's gap, container and rail stop animating. - Hover on the sidebar and in wells. The sidebar's ground is a tone surface: a
bg-mutedhover onbg-sidebarmixes with the sidebar, not the page. Awellcard hands its own fill to its children, so a ghost button, table row or copy field inside it hovers one step darker than the well. - Titles.
CardTitle,DialogTitleandAlertDialogTitlebalance their lines; their descriptions avoid a lone last word (text-pretty). - Type roles.
text-section(24px, line height 1.2, -0.025em) titles a section of an app screen.text-card-title(18px, line height 1.3) titles a card.cnknows both as sizes. - Figures.
<TableHead numeric>and<TableCell numeric>align a column of figures to the end, with digits of one width. - A lighter glass.
<Card surface="glass-lite">frosts with an 8px blur and no saturate. Use it for a pane over moving content, such as the live aurora or a scrolling list, where every frame counts. - Select text.
SelectTriggertakes the field sizes: 16px or more under a coarse pointer at every width, 14px frommdwith a mouse, asInput,Textareaand theOptionPickerfield do. A trigger is a button and iOS would not zoom into it, but it sits in rows of fields, which must stay at 16px, and one size reads as one row. The calendar's hidden month and year selects take the same sizes, since a native select does zoom. - Uptime strip on touch. The track reads the period under the pointer from its x, so a finger can drag along a 90-day strip; a vertical swipe still scrolls (
touch-action: pan-y), and a sideways drag no longer goes back in history. A tap on the strip reads its bar and keeps the tooltip open. The tooltip's anchor is placed in CSS from the period's index, with no layout read per bar. Figures read99.00%, with digits of one width. - Modals.
DialogContentandSheetContentsetdata-closablewhen they show the close button, and their headers make room for it from that attribute. A:has()selector made every DOM change restyle the page. Their content keeps its scroll from reaching the page (overscroll-contain). - Slider.
valueTextformats what a screen reader says for a value, such as "120,000,000 responses a month". Under a coarse pointer a horizontal slider is 12px taller (py-3). - Calendar day names. A day's accessible name starts with the number on the button, so "click 31" works in voice control (WCAG 2.5.3). The full date follows: "31, Today, Saturday, October 31st, 2026, selected". A label from
labels.labelDayButtonor a locale that already has the number as a word stays as it is. - Glass composer focus.
InputGroup surface="glass"draws a 2px ring on its edge inforeground/50on keyboard focus, where it only brightened a 1px edge. - Header on a phone in landscape.
SiteHeaderis 56px tall when the screen is in landscape and at most 500px tall. At the top of the page its pane is clear, so it drops the blur. Withviewport-fit=cover, setscroll-padding-top: calc(var(--navbar-height) + env(safe-area-inset-top, 0px))onhtml, so an anchor clears the bar. - Text selection. Accordion triggers, tabs, the data table's sort buttons and select triggers do not select their text on a long press. Table and tab scrollers keep a sideways scroll to themselves (
overscroll-x-contain). - Data table cells.
flexRenderrenders a column'scellfunction as a component. Define the columns outside the render, or memoise them: a newcellfunction on each render remounts every cell and drops its state and focus.
Dark elevation
Reinstall the theme:
npx shadcn@latest add -o @dwellir/dwellir-theme
At night a surface rises by lightness, and one 1px edge marks it.
- Lightness. The ground is
oklch(0.3 0 0), a card0.33and a popover0.36. The alt sheet (alt-ground) stays at0.26, so its cards (alt-card,0.3) still rise. - No shadows.
--shadow-alpha: 0in.darkscalesshadow-xs,shadow-cardandshadow-popoverto nothing, the top light too. Black does not read on near-black. The theme now exportsshadow-xs: Tailwind's value (black at 5%) by day. The top light would add a second line under the edge. - One edge. Dark
--borderisoklch(1 0 0 / 0.1). A translucent edge shows 1.36:1 above any fill under it. A solid0.35edge was darker than the popover. Cards and popovers keep theirborder border-border, so no ring is added. Dividers and separators take the same edge. - Inks. Badge, status and accent inks are lighter, for 4.5:1 on their tint over the popover.
--input-strongisoklch(0.68 0 0), for 3:1 on the secondary fill. - Inactive controls.
foreground-75mixes the ink at--foreground-75-mix: 75% by day, 85% at night. An inactive tab or choice keeps 4.5:1 on its muted track over the popover. - Light mode does not change.
Theming, type and fonts
Reinstall the theme and these parts:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/card @dwellir/chain-icon @dwellir/select @dwellir/tooltip @dwellir/uptime-bar
- Muted text at night. Dark
--muted-foregroundisoklch(0.78 0 0): APCA Lc −59 on the ground and −61 on a card, where 0.72 gave −49 and −51. It stays 11 Lc below the text colour, so the hierarchy holds. - The card's top light is white at 6% by day. In light it used the text colour, so a card got a dark 2px top edge against 1px sides. At night it goes with the other shadows (see "Dark elevation").
- Chain logos draw a 1px edge in 10% of the text colour (
chain-icon-edge): a logo as dark as the card under it keeps its shape. Whether a logo also takes a backplate is measured from its colours (npm run generate:chain-icon-contrast); do not editiconBackplates.jsonby hand. - Small tooltips (
size="sm") have 8px corners, as other floating surfaces do. The uptime tooltip's second lines usebackground-80(about 5.2:1 at 12px). - Select items keep their weight when chosen; the check mark shows the choice.
- Corners.
CollapsibleCardtakes the card corner (rounded-card). A stacked tab inside a card takes the card corner less its edge and two steps of gap. - Titles balance their lines in the
card-titleandaccordion-triggerutilities. - Scrollbars. Code blocks and table scrollers show a thin bar in the edge colour (
scrollbar-quiet). - Fonts.
--font-displayreadsvar(--font-ubuntu)first, then the theme'sUbuntu Fallback(below). Load the fonts from your own origin, not from Google Fonts: copypublic/fonts/standard/from this repository (Ubuntu 400 and 700, and the variable Roboto and JetBrains Mono files). A shadcn registry item carries text files only, so the registry cannot ship them. Declare Roboto as one face over100 900and JetBrains Mono over400 800, aslib/fonts.tsdoes, sofont-mediumis a real 500. As two faces at 400 and 700, 500 snaps to 400. In a Vite app, write the same@font-facerules withfont-weight: 100 900. - Display fallback. The theme's
parts.cssdeclaresUbuntu Fallback: Arial, or Liberation Sans where Arial is missing, scaled to Ubuntu's widths and given Ubuntu's ascent and descent.--font-displaylists it aftervar(--font-ubuntu, 'Ubuntu'), not inside it, so it works whether or not the app sets--font-ubuntu. Until Ubuntu loads, a heading keeps Ubuntu's line breaks and baseline, so it does not reflow when Ubuntu arrives. An app that loads Ubuntu throughnext/fontsetsadjustFontFallback: falsefor Ubuntu, aslib/fonts.tsdoes. Otherwise next generates a face namedubuntu Fallback. CSS matches family names without regard to case, so that face joins the theme's'Ubuntu Fallback'family and mixes its metrics into the theme's. Usenext/font/local: under Turbopack,next/font/googleadds its fallback even with this setting (vercel/next.js#74134). The status page loads Ubuntu 700 throughnext/font/localwith this setting. With Ubuntu held back 1.5 s, its 40 headings changed line count 0 times at 360, 768 and 1440 px (1 at 360 px with next's fallback). Their widths changed by 3, 4 and 5 px at most, and the font-swap CLS was 0. The faces come from the site'sstyles/font-fallbacks.css;npm run registry:buildcopies them. Where neither Arial nor Liberation Sans is installed (Android, some Linux), headings usesystem-uiuntil Ubuntu loads.
The current item
The current sidebar row and the selected tab carry the aurora mark.
The theme utility aurora-current draws it in two layers:
- a wash under the label: a 100° gradient of
--aurora-1,--aurora-2and--aurora-3at 22%, 12% and 8%; - a 1px rim on
::beforein the four aurora colours, at 70% opacity.
sidebar-menu-button applies it to a SidebarMenuButton with isActive.
That row keeps the wash on hover and draws its icon at stroke width 2.25.
A SidebarMenuSubButton keeps the sidebar-accent tint.
ui-tab applies it to every selected tab of the default, stretch and stack lists.
The rim replaces the tab's border. The line variant keeps its underline.
The rim uses ::before, because the coarse-pointer touch target uses ::after.
In forced-colours mode the rim is hidden; the bold label marks the row.
With reduced motion, sidebar rows change state with no transition.
Upgrade: reinstall the theme. The sidebar and tabs files are unchanged.
npx shadcn@latest add -o @dwellir/dwellir-theme
The dashboard can then delete the sidebar and tab rules from src/styles/aurora-active.css.
It can also delete the stroke rule from src/styles/shell.css.
Delete them in the same change as the reinstall. Otherwise the row draws two rims.
Keep the collapsible-card rules: the registry does not ship them.
Hairlines, inset rows, focus ring and modal fill
These come from the status page audit. Reinstall the theme and these parts:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/utils @dwellir/card @dwellir/separator @dwellir/page-header @dwellir/sheet @dwellir/dialog @dwellir/alert-dialog
- Hairlines. The theme sets
--border-hairlineon:root: 0.5px on a screen with 2 or more device pixels per CSS pixel, and 1px below that.Separatorand thePageHeaderborder use it. Draw your own line withborder-b-(length:--border-hairline), or grid lines withgap-(--border-hairline). Table rows keep 1px: the site inlines that rule on every page, and the token's media query added 130 B to each page (measured on 30 October 2026). Under Playwright's device-scale emulation, Firefox 155 paints a 0.5px box as one device pixel at 2x, and Chromium 156 paints it as 1px. Both engines paint a 0.5px border as 1px. WebKit was not measured. - Inset rows.
<Card surface="plain" size="xs" inset>is a bordered row inside a card's content, such as an endpoint in a status list. Its corner isrounded-inset(see "The radius scale"): 12px in the theme's 18px card. Use it withplainorwell, and drop hand-built row classes such asrounded-[12px] border p-4. - Modal motion.
SheetContent,DialogContentandAlertDialogContenttime their open and close keyframes withanimation-duration-*. They no longer set a transition, so a theme switch or a resize does not ease their colours or size. Open and close still animate, and reduced motion still drops the slide and the zoom.
Three looks changed after the audit:
-
The focus ring is neutral.
--ringis the ink:oklch(0.26 0.01 248)in light andoklch(0.84 0 0)in dark. It was the teal accent ink. Against solid grounds it measures, with WCAG 1.4.11 asking 3:1:Ground Light Dark Page ground 15.46:1 8.29:1 Card 13.9:1 9.47:1 Popover 15.46:1 9.47:1 Muted (wells, tracks) 13.58:1 7.37:1 Alt sheet 13.9:1 9.47:1 Sky sheet 12.7:1 10.29:1 --sidebar-ringtakes the same ink, so sidebar rows draw the same ring. -
The focus ring has a second edge. Reinstall
@dwellir/dwellir-theme; no part changes. A ring drawn outside the control (outline-offset-2oroutline-offset-4withoutline-ring) sits in a band of the ground under the control. The band fills the 2px gap and runs 2px past the ring. It follows the two-colour idea of WCAG technique C40: where the backdrop is pale, the dark band shows; where it is dark, the ink shows. It does not meet C40's 9:1 between the two colours in dark (see below).- On a solid ground the band is the ground, so the ring looks as before. The band takes the tone surface,
--tone-surface(the website calls it--ds-bg), the ground the tones already mix tints from. - These wrappers set the ground for their descendants:
bg-background,bg-card,bg-sky,bg-popover,bg-sidebar,bg-field(an input group, a copy field, or your own field wrapper),bg-foreground,bg-primary,bg-muted, aCardof any surface (awellhands its muted fill on), aSectionband, popover, select and dropdown-menu content, a tooltip, thedefaultandstretchTabs tracks, and asegmentedChoiceGroup. Any other painted wrapper passes on the ground above it. Paint a custom wrapper with one of these classes, or set its fill as the tone surface:.my-panel { --tone-surface: var(--my-panel-fill); }. Tints and focus bands inside it then mix from that fill. - A control that paints one of these surfaces itself would draw the band in its own fill.
--focus-groundoverrides the band's colour for these: the Slider sets the ground around it for its thumb (bg-background), and a card link (Card asChild) takes the band's ground,--background. Set it on your own focusable surface:.my-tile:focus-visible { --focus-ground: var(--background); }. - Where something crosses the ring, the band covers it. A slider's range now stops 2px short of the ring. Before, the dark ring sat on the primary range at 1.55:1.
- An inset ring (
-outline-offset-2: menu items, sidebar rows, tabs, a copy field's reveal button) draws no band. It sits on the control's own fill. - A suppressed outline draws no band.
outline-noneandoutline-hiddenclear it in the same rule, so a tab focused by the pointer (data-pointer) and a copy button, whose field draws the ring, show nothing extra. - A part that recolours the ring after
outline-ringkeeps the band: the Buttonwarninganddestructivevariants andaria-invalid:outline-destructive. We kept it on purpose. On a solid ground it does not show. Where the primary fill crosses the ring, the ring would sit on it at 2.32:1 (warning) and 2.60:1 (destructive) in light, and 1.38:1 and 1.40:1 in dark. With the band it sits on the ground at 5.90:1 and 6.60:1 in light, and 7.39:1 and 7.46:1 in dark. A ring that never takesoutline-ring, such as a footer link's ink, has no band. - The band is Tailwind's ring-offset shadow, so
shadow-xs,shadow-cardand the other theme shadows still show. A control that setsbox-shadowin its own CSS loses the band while it has focus.
The best of the two edges against each backdrop we measured on deployed previews (1440px, Chromium 156). In both themes, one edge reaches 3:1 or more on every one of them:
Backdrop Light, ring only Light, two edges Dark, ring only Dark, two edges Solid grounds (page, card, popover, muted, alt, sky) 12.7:1 or more 12.7:1 or more 7.37:1 or more 7.37:1 or more Primary fill, the slider range 6.08:1 6.08:1 1.55:1 6.11:1 Homepage hero, moving (lowest of 9 frames, 3 runs) 3.06:1 3.57:1 1.14:1 3.21:1 Homepage hero, still 3.92:1 3.92:1 1.72:1 3.21:1 Design system sky band (lowest of 3 runs) 3.88:1 3.88:1 1.76:1 3.21:1 This holds for these backdrops only. C40 asks 9:1 between the two colours, so that one of them reaches 3:1 on any backdrop. Light meets it: the ink and the band differ by 12.7:1 or more. Dark does not: they differ by 8.29:1 on the page ground and 7.37:1 on a muted fill. On the worst mid-grey backdrop that leaves 2.88:1 on the page and 2.71:1 on a muted fill. No measured backdrop is that grey. The owner's ink token stays as approved.
- On a solid ground the band is the ground, so the ring looks as before. The band takes the tone surface,
-
Plain modals take the popover fill. A plain dialog, alert dialog or sheet takes
--popover, as a grain one and a popover do. Every overlay now shares one fill. In light that fill is white (oklch(1 0 0), CIELAB L 100); plain modals were--card(L 95.7). In dark every overlay isoklch(0.26 0 0)(L 14.2). In light a plain modal matches the page ground, so its border, shadow and scrim set it apart.
Sidebar collapse and the status bar
Reinstall the theme and the sidebar:
npx shadcn@latest add -o @dwellir/dwellir-theme @dwellir/sidebar
- Collapse without layout per frame. The gap beside the page changes width at once, so the page lays out once per toggle. Offcanvas slides the container with
translate. Icon collapse clips the container withclip-pathand sets its width in one step: at once on expand, after the 200ms clip on collapse. Rows and group labels switch to the rail at once; labels still fade. Do not put atransformonsidebar-inner: it becomes the rail's containing block and moves the rail. - Top safe area. The fixed sidebar pads its top by
env(safe-area-inset-top). An installed iOS app can then useapple-mobile-web-app-status-bar-styleblack-translucent: the header clears the status bar.
tests/sidebarCollapse.test.cjs counts layouts during each collapse and expand, and checks the top padding under a 24px status bar.
Then remove the app rules that animate the sidebar's layout. Unlayered app CSS beats the theme's utilities at any specificity, so the part cannot guard against them. In the dashboard's src/styles/shell.css:
- Delete
[data-slot="sidebar"][data-variant="inset"] [data-slot="sidebar-container"] { transition-property: left, right, width, padding; }. It replaces the clip and the slide withwidthandpaddingtransitions. Keep the collapsedpadding-leftandpadding-rightrule: the padding now changes in one step. - Drop
margin-topfrom both group-label transitions,[data-sidebar="sidebar"] [data-sidebar="group-label"]and[data-collapsible="icon"] [data-sidebar="group-label"]. Keep thecolorfade and the collapsedmargin-top: -1.5rem: the label then closes in one step. - Optional: the
transition-timing-function: var(--ease-in-out)rule onsidebar-gapandsidebar-containeris harmless. The gap no longer transitions, and the clip takes the curve.
Measured on the published sidebar (icon, inset, left) beside that shell.css: 13 to 14 layouts per toggle as it stands, 1 to 2 after these edits. A container guard with !important brought back the clip, but the label's margin-top transition alone still cost 13 to 14 layouts per toggle. To check an app, record one toggle in the DevTools Performance panel. It should show 1 or 2 Layout events.
Which picker
- Use
selectfor a short fixed list without search. - Use
option-pickerfor searchable single or multiple choice from known options. Multiple mode shows removable chips. - Use
commandfor a command palette or action menu. It does not represent a form value.
Option picker
Install @dwellir/option-picker for searchable single or multiple selection.
This intentionally uses an options API. It is not the stock shadcn Combobox.
The combobox registry name and source path remain free for a future stock-compatible part.
Exports include OptionPicker, OptionPickerChip, their option and props types, and optionPickerTriggerVariants.
The field is an input. Click, type, or press ArrowDown to open its list.
Each search edit highlights the first enabled match. Enter selects it.
Pointer selection keeps the caret in the field.
Single mode shows the committed label as a placeholder while closed.
Typing starts a fresh query. Escape discards that query and restores the label.
Backspace on an empty single query preserves the selection. Use Clear to remove it.
Escape closes the list before a surrounding Dialog.
Use listPlacement="inline" inside Dialogs so the list expands their layout.
The default popover placement floats below the field.
<OptionPicker
aria-label="Endpoints"
multiple
options={options}
value={selectedIds}
onValueChange={setSelectedIds}
listPlacement="inline"
maxSelections={20}
maxChips={3}
action={({ open, onOpenChange }) => (
<Button onClick={() => onOpenChange(!open)} aria-label={open ? 'Done' : 'Add endpoints'}>
<PlusIcon aria-hidden="true" />
<span className="max-sm:sr-only">{open ? 'Done' : 'Add endpoints'}</span>
</Button>
)}
/>
Give each option a unique value and a readable label.
Supply ordered rows with group, level, and search keywords.
The levels are chain, network, and endpoint.
Supply the chain logo through icon; endpoint options use their owner's logo.
The same node appears in the row and its chip.
This slot accepts the registry ChainIcon without a dependency on it.
maxSelections adds a live count and blocks additions at the limit.
The count and Clear appear when a selection exists or maxSelections is set.
Set summary={false} to hide that row, or summary={true} to keep it visible when empty.
Use controlled value and onValueChange to render your own count and Clear action.
Routine selection announcements are screen-reader only. The limit message remains visible.
Clear resets selection and search. Backspace removes the last chip when search is empty.
maxChips limits visible chips; every value remains selected.
maxVisibleOptions caps mounted rows after filtering the full catalog. It defaults to 150.
Use a positive integer. A remainder line asks users to narrow the search.
Keyboard navigation stays within the visible rows. Hidden matches become available through search.
Use defaultValue for local state. Omit multiple for one string value.
The size variants are default and sm, with SelectTrigger's appearance.
Place the part in a min-w-0 grid column.
Endpoint labels wrap and expose their full text in a title. Other labels truncate.
The website, status page, and dashboard install the same item.
Keep domain rules, such as implied endpoint coverage, in the caller.
Pass covered options as disabled and supply their search aliases in keywords.
Set name to submit selected option values through a native form.
Multiple values use repeated entries with that name; search text never enters form data.
Use form to associate the part with an external form. Disabled selections do not submit.
required validates committed selections through the visible input's custom validity.
Search text cannot satisfy the requirement. Browser validation focuses the visible input.
Native form reset restores uncontrolled defaultValue and clears search, including externally associated forms.
Controlled selections remain owned by the caller on reset.
Options use aria-selected for committed values. Active navigation uses aria-activedescendant and data-active.
Arrow keys never change committed selection. Buttons retain native Enter, Space, and arrow behavior.
Chain identity
Install @dwellir/chain-icon and @dwellir/network-badge for shared chain identity.
Both work with React and Tailwind without a Next.js image loader or theme provider.
import { ChainIcon } from '@/components/ui/chain-icon';
import { EcosystemBadge, NetworkBadge, NodeTypeBadge } from '@/components/ui/network-badge';
<NetworkBadge
name="Ethereum"
type="mainnet"
icon={<ChainIcon name="Ethereum" src="/partners/ethereum.svg" size="sm" />}
/>;
<NetworkBadge
name="Sepolia"
type="testnet"
icon={<ChainIcon name="Ethereum" src="/partners/ethereum.svg" size="sm" />}
/>;
<NodeTypeBadge>Archive</NodeTypeBadge>;
<EcosystemBadge>EVM</EcosystemBadge>;
ChainIcon sizes are sm (24px), md (32px), and lg (48px).
frame="tile" adds a bordered tile: 40px, 48px, or 64px.
Pass src for light artwork and optional darkSrc for dark artwork.
CSS theme classes select the source before hydration.
Single-source catalog artwork receives a backplate when its outline falls below 3:1 on a dark ground.
The outline is every opaque pixel that touches the ground; half of it must reach 3:1.
A light glyph inside a dark disc does not count: the disc vanishes on a dark card.
The generator takes the worst of the dark background, card, popover and muted grounds.
The backplate must also reach 3:1. Acurast keeps its backplate from the first render.
The chain-icon-ground theme token controls that backplate: oklch(0.963 0.003 248) by day, oklch(0.87 0.003 248) at night.
The night plate stays below the title colour (0.93), so a plate never outshines the page.
npm run generate:chain-icon-contrast measures SVG, PNG and WebP artwork against those grounds and the backplate.
It writes lib/networkCatalog/iconBackplates.json, which ships with the registry part.
registry:build regenerates these decisions after changes to partner artwork or theme grounds.
The component matches catalog filenames, including absolute URLs and query strings. Unknown filenames keep their native ground.
Missing and failed images show a neutral initials disc with the same dimensions.
Failed dark artwork falls back to the light source with its backplate.
After hydration, completed images with zero intrinsic width retry once. Only an image error activates fallback.
Changed sources retry without carrying the previous failure.
Icons are decorative by default. Give standalone icons an alt name.
NetworkBadge accepts mainnet, testnet, canary, or devnet as its type.
It appends that word unless the name already includes it.
NodeTypeBadge and EcosystemBadge take display labels as children.
All three use neutral badges. Reserve colour for service state.
Supply catalog URLs and labels in each app; the parts ship no chain catalog or logo assets.
Use the website catalog's public/partners artwork in every app.
Other apps can pass absolute website URLs.
Rune icons
Install @dwellir/rune-icons for Dwellir's icon set: 176 icons cut as runes.
The set replaces Tabler, lucide and react-icons in the dashboard, the website, the status page and these parts.
Brand marks (GitHub, LinkedIn, Telegram, X, YouTube, Slack) and chain logos are logos, not icons, so they stay outside the set.
The part needs React and nothing else: no theme, no cn() and no npm dependency.
npx shadcn@latest add @dwellir/rune-icons
import { RuneChevronDown, RuneClose, RuneLoader } from '@/components/brand/rune-icons';
<RuneChevronDown className="size-4 text-muted-foreground" />;
<RuneClose size={20} />;
<RuneLoader className="size-4 animate-spin" />;
The install adds one module per icon, five category barrels, the main barrel, the factory and a scoped package.json.
components/brand/rune-icons.tsx keeps the same exports and import path.
The category barrels group the icon modules under components/brand/rune-icons/:
| File | Icons | Jobs |
|---|---|---|
navigation.tsx | 37 | Arrows, chevrons, the selector, sidebar toggles, menus, layout, links, sign in and out |
actions.tsx | 33 | Search, add, copy, download, share, refresh, delete, save, filter, sort, play, edit, send, power |
status.tsx | 28 | Info, help, alert, warning, error, success, the spinner, bell, clock, flame, star, shield, 404 |
data.tsx | 23 | Charts, gauge, calculator, numbers, lists, reports, dates, usage and logs |
nouns.tsx | 55 | Keys, servers, databases, cloud, network, API, webhook, terminal, billing, support, hardware, theme |
components/brand/rune-icons/rune.tsx holds RuneIconProps, the RuneIcon type and the two factories.
Each category has a matching folder, such as navigation/home.tsx.
The scoped package.json declares only the rune folder free of side effects.
Keep that file when copying or updating the icons.
Turbopack needs both the separate modules and this declaration to isolate routes when another page imports the whole namespace.
Existing consumers can update the registry item without changing their imports.
The codemod keeps its --import default at @/components/brand/rune-icons.
/design-system lists every icon by category, with its job and the rune or object it is cut from.
Size and colour:
- Pass
sizeto set width and height together, in px or any CSS length. - Without
size, CSS sizes the icon. UseclassName="size-5", or[&_svg]:size-5on a parent. A rune has no default size; lucide and Tabler draw at 24px. - Each icon draws in
currentColor. Set a theme text colour:text-foreground,text-muted-foregroundortext-accent-ink.lint:dslets a rune take layout and colour classes andanimate-spin, and nothing else. - Every icon is
aria-hiddenby default. Passaria-labelandaria-hidden={false}for a standalone icon. - Other props go to the
<svg>, and they override the defaults.
Filled states and the spinner:
runeSolid(name, d)makes a filled rune: it fills incurrentColorwith no stroke. Use it only where a filled state is the job. The set hasRuneStopSolid,RuneSuccessSolidandRuneDot.RuneLoaderis the spinner: three quarters of a cut hexagon. It is still. Spin it withanimate-spin. It replaces lucideLoader2andLoaderCircle.
Rules for a new icon:
- Use straight strokes only. The path commands are
M,L,H,VandZand their lowercase forms. No curves and no arcs. - Cut a ring as a hexagon (
M12 3l8 4.5v9L12 21l-8-4.5v-9z), a diamond or a square, never an octagon. A convex outline has six corners at most: an octagon reads as a circle at 14px.tests/runeIcons.test.cjsfails on a ring with more. - Cut square ends and mitred corners. The factory sets both.
- Draw on the 24px grid. Every point stays inside
0 0 24 24. Keep the shape in the live area the set shares, about 3 to 21. - Keep the 2 stroke. The factory sets it, so every rune has one weight at every size. At 14px it draws 1.2px, enough for a cut corner to survive.
- Leave room for the stroke: two parallel strokes sit at least 4 units apart, centre to centre. A dot is a
.5square (M11.75 11.75h.5v.5h-.5z), which draws 2.5 units wide. - Make the cuts big. A rune feature that matters (a bell's shoulder, a sun's core, a moon's tip) needs a facet of 3 units or more, or it blurs into a curve at 14px.
- Draw one
<path>withrune(). For a filled state, draw withruneSolid(): put the outline 1 unit (half the stroke) outside the stroked twin, so both cover the same area. A second subpath cuts a hole. - Mark the call pure:
export const RuneX = /* @__PURE__ */ rune('RuneX', '…'). This lets Vite and esbuild remove unused calls. - Keep each rune in its own module. The Turbopack CI guard builds Pages Router and App Router routes beside a gallery that imports every rune. Each small route must ship only its own rune.
- Make the icon read as its job first, at 14px, for someone who knows Tabler or lucide. The rune is flavour, and it must survive at 14px too.
- Mirror shapes that pair: chevrons, sort up and down, play and pause, sun and moon.
To add an icon:
- Add a module to its category folder under
components/brand/rune-icons/. Include a doc comment that names its job and its rune or object. Re-export it from the category barrel and list the module in therune-iconsregistry item. - Map its Tabler, lucide and react-icons names in
scripts/rune-icons/rune-icon-map.json. - Run
npm run registry:build. It refreshes the/design-systemcatalog (views/DesignSystem/runeIconCatalog.json) andpublic/r. Commit them with the source.
tests/runeIcons.test.cjs fails when a path has a curve or a point outside the grid, when an icon has no doc comment, when the map names a rune that does not exist, or when the site imports a Tabler, lucide or react-icons name with no map entry.
Name map
scripts/rune-icons/rune-icon-map.json maps each library name to its rune:
{
"tabler": { "IconChevronDown": "RuneChevronDown", "IconX": "RuneClose" },
"lucide": { "ChevronDown": "RuneChevronDown", "ChevronDownIcon": "RuneChevronDown", "LucideIcon": "RuneIcon" },
"react-icons": { "TbChevronRight": "RuneChevronRight", "LuGlobe": "RuneWorld" },
"near": { "lucide:Award": "a medal becomes a trophy" }
}
It covers every Tabler name the dashboard imports, every lucide and react-icons name the website and the status page import, the registry parts' lucide names, and the neighbours a team reaches for next.
Every lucide name also maps with its Icon suffix. The types map too: LucideIcon and Tabler's Icon to RuneIcon, LucideProps and IconProps to RuneIconProps.
One rune serves each job, whatever a library calls it. near marks a name whose picture differs from its rune, such as lucide Award (a medal) to RuneTrophy.
The map follows the picture, not the name: lucide Bolt is a hex nut and maps to RuneNut; Tabler IconBolt and lucide Zap are lightning and map to RuneBolt.
/design-system renders the map as a searchable table.
Codemod
Install @dwellir/rune-icons-codemod for scripts/rune-icons/codemod.mjs and the map beside it. It is a Node script with no npm dependency. Both files install at the project root, also in a Vite app with a src/ folder, so run the commands below from the root.
node scripts/rune-icons/codemod.mjs --dry-run src
node scripts/rune-icons/codemod.mjs --import @/components/brand/rune-icons src
It rewrites imports and re-exports from @tabler/icons-react, lucide-react, react-icons/tb and react-icons/lu:
- It moves each mapped name to one import from the rune part (
--import, default@/components/brand/rune-icons), and renames every use: JSX tags, values, types, shorthand properties and exports. An alias (X as XIcon) and a lucide…Iconname become the rune name. - It drops
stroke(Tabler),strokeWidthandabsoluteStrokeWidth. A rune's stroke is fixed. - It keeps
size. A JSX icon with nosize,width,heightor size class gets its old library's default:size={24}, orsize="1em"for react-icons. - It moves a re-export (
export { Check } from 'lucide-react') to the rune part and keeps its public name:export { RuneCheck as Check } from '@/components/brand/rune-icons'. - It renames only the names an icon import binds. Imports and re-exports from other modules keep their names. An import inside a comment or a string is not an import.
- It leaves strings, comments and JSX text alone. When a file already declares or imports the rune's name, it imports the rune under the old name.
- It prints every name it cannot map, with its file and line, and leaves that import in place. It also reports default, namespace and deep imports and
export *, and leaves them. It prints each near match, so a person checks the picture.
Migrate a repo:
- Install the icons and the codemod:
npx shadcn@latest add @dwellir/rune-icons @dwellir/rune-icons-codemod. - Run the codemod with
--dry-runand read the report. Then run it without--dry-run. - Fix what it prints. Draw a rune for a missing job (and add it to the map in this repo), or keep a brand mark from its own package.
- Check icons passed as values (
icon: RuneX) render with a size: a rune has no default. - Run the formatter, the type check and the tests.
- Remove
@tabler/icons-react,lucide-reactorreact-iconsfrompackage.jsononce nothing imports it.
tests/runeIconsCodemod.test.cjs runs the codemod on the fixtures in tests/fixtures/rune-icons-codemod/.
Upgrade: parts draw their icons as runes
The parts no longer import lucide-react. They import @dwellir/rune-icons, so a reinstall adds the runes and drops the lucide dependency.
Parts that changed: accordion, action-list, breadcrumb, calendar, checkbox, code-block, collapsible, command, copy-field, data-table, date-picker, dialog, dropdown-menu, option-picker, pagination, select, sheet, sidebar, site-header, sonner, spinner and stat. The button item no longer lists lucide-react; it never used it.
-
Reinstall the parts you use. The CLI installs
@dwellir/rune-iconswith them:npx shadcn@latest add -o @dwellir/select @dwellir/dialog @dwellir/dropdown-menu -
Install
@dwellir/rune-iconsif you do not use a part that needs it yet:npx shadcn@latest add @dwellir/rune-icons. -
Check your own code that passes a part's icon slot an icon. A rune has no default size; lucide drew at 24px. Give it
className="size-4"or a parent[&_svg]:size-4. -
Move your own lucide imports to runes with the codemod above.
-
Remove
lucide-reactfrompackage.jsononce nothing imports it.npm ls lucide-reactshows what still needs it.
The dashboard and the status page reinstall the parts after this change.
tests/runeIcons.test.cjs fails when a file in components/ui or components/brand imports lucide-react, @tabler/icons-react or react-icons.
Responsive tables
Table and DataTable keep their stock markup when the new props are absent.
Stacking and pinning are opt-in. Install @dwellir/table or @dwellir/data-table with the theme.
The table item includes table-responsive.tsx; keep that helper beside table.tsx.
On this website, its utilities load only on /design-system.
Registry consumers generate the utilities from their installed source files.
Breakpoints and pinning
stack="auto" stacks rows below the container width in stackBelow.
The default is stack="never", with stackBelow="3xl" when enabled.
Tokens are lg (512px), xl (576px), 2xl (672px), 3xl (768px), and 4xl (896px).
stickyFirstColumn pins the first column in the table layout. It defaults to false.
The scroller measures the pinned cell to reserve keyboard focus space.
Its edge shadow appears only while content is scrolled underneath.
There is no end-pinned column mode.
<DataTable columns={columns} data={rows} stack="auto" stackBelow="xl" stickyFirstColumn />
Surfaces
surface sets the ground for pinned cells and stacked rows.
Choose card (default), background, muted, or glass to match the surrounding surface.
Use muted for a well inside a card.
Glass pinned cells intentionally keep 80% fill with 18px blur and 150% saturation.
Reduced transparency replaces that glass treatment with a solid card ground.
<Table stickyFirstColumn surface="glass">{children}</Table>
Loading and empty states
Table state="loading" replaces its scroller with a full-width status region.
loadingRows defaults to five. visibleColumns defaults to one.
state="empty" accepts an empty message or uses the default message.
DataTable uses its existing loading flag and empty data to choose the state.
Stacking enables outside states. statePlacement="outside" also enables them without stacking.
statePlacement="table" is the default for tables without stacking or pinning.
<Table state="loading" loadingRows={3} visibleColumns={4} />
<Table state="empty" empty="Create a project to track requests." />
<DataTable columns={columns} data={rows} loading={loading} statePlacement="outside" />
Expansion
renderSubComponent enables expansion for every row by default.
A disclosure control calls the row's getToggleExpandedHandler().
options.getRowCanExpand can restrict expansion.
Stacked detail content joins the row above without a second card border.
<DataTable columns={columns} data={rows} renderSubComponent={row => <ProjectDetails project={row.original} />}
options={{ getRowCanExpand: row => row.original.hasDetails }} />
Stacked roles and labels
ColumnDef.meta.stackRole accepts title, action, or hidden.
The default column remains a labelled field.
TableCell stackRole provides the same roles for a composed table.
Use one title column to identify each stacked record.
const nameColumn = { accessorKey: 'name', header: 'Project', meta: { stackRole: 'title' } };
<TableCell stackRole="action">{actions}</TableCell>
ColumnDef.meta.stackLabel sets the field label in stacked rows.
Without it, a text header supplies the label; other headers fall back to the column identifier.
TableCell label provides the label for a composed table.
const usageColumn = { accessorKey: 'requests', header: RequestHeader, meta: { stackLabel: 'Requests' } };
<TableCell label="Requests">{requests}</TableCell>
Start free.1 response = 1 credit.
100,000 responses a day at 20 per second, on 150+ networks. Upgrade when you need more.