Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes

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.

  1. 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 -y
    

    Confirm components.json contains "style": "radix-nova". The default base-nova style 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 as noscript: and wrap-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-css npm dependency. Its CSS import is required for accordion and sheet animations. The last path assumes the global stylesheet and styles/ share a directory. Adjust it to match the installed file location shown by shadcn. Before installing, check whether styles/dwellir/parts.css already exists at the target path. If it is not the Dwellir-generated file, move it first: -o overwrites the whole file.

  2. Add the registry to the app's components.json:

    {
      "registries": {
        "@dwellir": "https://www.dwellir.com/r/{name}.json"
      }
    }
    
  3. 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, including destructive-foreground for 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-hover and aurora-curtain utilities, and the sheet styles for Section;
  • shared part utilities, including ui-button, card-title, and ui-table-cell. These base styles come from styles/design-system-parts.css on 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.ts first. 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:

TokenLightDarkUse
--inputoklch(0.87 0.005 248)oklch(0.4 0 0)Field edge, 1.2–1.8:1
--input-hoveroklch(0.74 0.006 248)oklch(0.52 0 0)Field edge on hover, 1.9–3.1:1
--input-strongoklch(0.58 0 0)oklch(0.68 0 0)Edge of a control with no other cue, 3:1 or more
--fieldoklch(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.

ItemInstallAbout
@dwellir/dwellir-themethemeDwellir 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/utilslibclsx + tailwind-merge, taught the theme names (text-heading, rounded-card, ...).Needs clsx@^2, tailwind-merge@^3
@dwellir/brandcomponentThe 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-stylecomponentBrowser colour decoder and shared aurora field types.
@dwellir/input-modalitylibMarks <html data-input> as keyboard or pointer, so menu items ring only for keyboard readers.
@dwellir/portal-containerlibRenders the tooltips, menus and selects opened in a landmark into that landmark, so their content stays inside the page's landmarks.
@dwellir/touch-focuslibAn onOpenAutoFocus for modals: under a coarse pointer the modal's content takes the focus, not its first field, so the phone keyboard stays shut.
ItemInstallAbout
@dwellir/carduiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slot, class-variance-authority@^0.7
ItemInstallAbout
@dwellir/buttonuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/spinner, @radix-ui/react-slot, class-variance-authority@^0.7
@dwellir/badgeuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slot, class-variance-authority@^0.7
@dwellir/toggleuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-toggle, class-variance-authority@^0.7
ItemInstallAbout
@dwellir/inputuiNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/input-groupuiA 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/textareauiA 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/labeluiNeeds @dwellir/dwellir-theme, @dwellir/utils
@dwellir/fielduiA 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/formuishadcn'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/checkboxuiA 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-groupuiOne 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/switchuiAn 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/selectuiNeeds @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-groupuiNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/toggle-groupuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/choice-group, @dwellir/toggle, @radix-ui/react-toggle-group, class-variance-authority@^0.7
@dwellir/slideruiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-slider
@dwellir/kbduiA 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
ItemInstallAbout
@dwellir/copy-fielduiCopyable 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
ItemInstallAbout
@dwellir/action-listuiStacked 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
ItemInstallAbout
@dwellir/accordionuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/rune-icons, @radix-ui/react-accordion
@dwellir/collapsibleuiContent 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/tabsuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-tabs, class-variance-authority@^0.7
ItemInstallAbout
@dwellir/site-headercomponentThe 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/breadcrumbuiThe 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/paginationuiReal 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
ItemInstallAbout
@dwellir/sidebaruishadcn'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
ItemInstallAbout
@dwellir/dialoguiNeeds @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/sheetuiNeeds @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-surfacelibSets the surface of every Dialog, AlertDialog and Sheet that names none. Mount it once at an app's root with surface="plain".
@dwellir/popoveruiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/portal-container, @dwellir/touch-focus, @radix-ui/react-popover, class-variance-authority@^0.7
@dwellir/dropdown-menuuiNeeds @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/tooltipuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/portal-container, @radix-ui/react-tooltip, class-variance-authority@^0.7
ItemInstallAbout
@dwellir/commanduiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/dialog, @dwellir/input-modality, @dwellir/rune-icons, class-variance-authority@^0.7, cmdk@^1
@dwellir/calendaruiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/rune-icons, class-variance-authority@^0.7, react-day-picker@^9.11.2
@dwellir/date-pickeruiNeeds @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
ItemInstallAbout
@dwellir/option-pickeruiSearchable 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
ItemInstallAbout
@dwellir/tableuiA 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-blockuiA 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-baruiA 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/skeletonuiLoading 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/separatoruiNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/scroll-areauiContent 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/emptyuiWhat 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/spinneruiA 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-splashcomponentThe 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
ItemInstallAbout
@dwellir/chartuishadcn'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-legenduiA 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-tableuiA 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
ItemInstallAbout
@dwellir/statuiA 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-headeruiAn 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
ItemInstallAbout
@dwellir/alertuiNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/alert-dialoguiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/modal-surface, @radix-ui/react-alert-dialog@^1, class-variance-authority@^0.7
@dwellir/avataruiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-avatar@^1, class-variance-authority@^0.7
@dwellir/progressuiNeeds @dwellir/dwellir-theme, @dwellir/utils, @radix-ui/react-progress@^1, class-variance-authority@^0.7
@dwellir/sonneruiNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/button, @dwellir/rune-icons, sonner@^2
ItemInstallAbout
@dwellir/sectioncomponentNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/aurora-curtain, class-variance-authority@^0.7
@dwellir/section-headingcomponentNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/containercomponentNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/prosecomponentNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/icon-chipcomponentNeeds @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
@dwellir/aurora-curtaincomponentNeeds @dwellir/dwellir-theme, @dwellir/utils
ItemInstallAbout
@dwellir/aurora-canvascomponentThe 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-windcomponentThe 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
ItemInstallAbout
@dwellir/page-herocomponentNeeds @dwellir/dwellir-theme, @dwellir/utils, @dwellir/container, @dwellir/section, @dwellir/section-heading, class-variance-authority@^0.7
ItemInstallAbout
@dwellir/rune-iconscomponentDwellir'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-codemodfileA Node script that moves a codebase from Tabler, lucide and react-icons (tb, lu) to the rune icons, with the name map it reads.
ItemInstallAbout
@dwellir/chain-iconuiTheme-aware chain artwork with initials, a dark-ground backplate and optional tile frame.Needs @dwellir/dwellir-theme, @dwellir/utils, class-variance-authority@^0.7
ItemInstallAbout
@dwellir/network-badgeuiNeutral 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.

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:

TierFillInkMeans
Operationalstatus-operationalstatus-operational-inkClean: at or above the clean cut
Minorstatus-minorstatus-minor-inkSome downtime, at or above the minor cut
Majorstatus-majorstatus-major-inkAt or above the major cut
Downstatus-downstatus-down-inkBelow the major cut
No datastatus-no-datastatus-no-data-inkNo 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.

PropTypeDefault
periods{ label: string; uptime: number | null; details?: string[]; marker?: 'lag' }[]requiredUptime 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-labelstringrequiredNames the strip.
size'default' | 'fill''default'default: 32 px bars. fill: fills a positioned parent.
thresholdsPartial<{ clean; minor; major }>{ clean: 99.99, minor: 95, major: 80 }The lowest uptime of each tier.
start, endReactNodefirst and last labelThe readout's ends while no period is read.
readoutbooleantrueShows 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, and marker: 'lag' for a lag of 5 minutes or more. Set readout={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-ink to 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
labelWhat the figure measures. A string also names the hint button.
valueA number to format, or a ready-made string such as "99.98". Null reads as a dash, and as "Not available" to screen readers.
formatFormats a numeric value, also mid-count. Pass the app's own number format.
unit, delta, descriptionA quiet suffix, a StatDelta (arrow plus change), and a line under the figure. Children go under the meta: a progress bar, an uptime bar.
hintA short explanation behind an info button next to the label.
toneneutral (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.
layoutstacked (a tile), inline (label left, figure right), or auto (inline in a narrow container, stacked from the md container width, inside a StatGroup).
size, alignThe figure's size (sm, md, lg) and, for tiles, its alignment.
loadingA skeleton in the figure's place, at the figure's height.
animate, durationCounts 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
brandrequiredThe logo link. Wrap it in SiteHeaderBrand, which adds the focus ring to the link.
childrenLinks centred in the bar from the collapse width, such as the website's menus. Hide them below it (hidden sm:flex).
actionsThe 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.
morphHolds 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) and chartConfigForSeries(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.
  • missing on ChartContainer says what a bucket with no value means. The default, "gap", leaves it missing, so unknown data breaks the line. Pass missing="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 with useChartWidth (from @/components/ui/chart-series): put its ref on the ChartContainer and 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_PROPS and TIME_SERIES_MARGIN are 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

NeedInstallFrame cost
A still curtain, or many curtains on one screenAuroraCurtain (in dwellir-theme)none: CSS only
One moving curtain: a dashboard header, a page backdrop@dwellir/aurora-canvasa 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.ts and curtainStorm.ts;
  • lib/aurora/: quality.ts, frameLoop.ts, frameGate.ts and fixedClock.ts;
  • through field-style: fieldStyle.ts, fieldTypes.ts and quietMask.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>;
PropDefaultEffect
classNamenoneLayout classes, such as -z-1.
quiet'none'Where text sits: 'top-left', 'left' or 'center'. The light thins out there.
placementhero'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-dot to --au-dot-4 where it sits. The item maps them onto --aurora-1 to --aurora-4. A section can set them locally.
  • Dark mode is .dark on <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 OffscreenCanvas or 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=off forces 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:

PathMain threadWorker thread
Worker (the default)1.3 ms/s24 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 paintnone

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" and tone="alt" sheet. A page with no sheet has no wind.
  • Round a hovered Card on the plain ground: a small window opens, and the card's edge lights.
  • Every Card is a hover collider. Its collider prop picks hover, always or off. The h1, h2 and h3 inside a Section are quiet text colliders. Tag any other element with data-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, as shadcn init does), not a wrapper div;
  • keep every element between <body> and a Section free of a background, z-index, transform and isolation, or the wind cannot show through;
  • give a Section no 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-dot to --au-dot-4 and --au-ink. The aurora-canvas item maps the first four onto the theme's --aurora-1 to --aurora-4; this item maps --au-ink onto --foreground.
  • Dark mode is .dark on <html>. The wind redraws when the class changes.
  • With prefers-reduced-motion: reduce, the wind draws nothing and AuroraField draws one still frame.
  • html[data-au-quality] holds the motion quality: full (no attribute), lite or still. The governor steps it down while frames stay slow. Lite draws fewer lines and drops the blur of glass surfaces. Still draws no wind. With the bootstrap script in <head>, ?quality=lite pins 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 OffscreenCanvas or 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 the dwellir-theme values in registry.json. tests/designSystemRegistry.test.cjs checks that they meet WCAG AA in every pairing the parts use.
  • Sheets. Here styles/aurora-sections.css and the solar wind style Section. The registry theme carries a plain version: inset rounded sheets that swap the ground and card colours. Each sheet is isolated, so a still AuroraCurtain at -z-1 paints over the sheet's ground. The solar-wind item 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

  1. Edit or add the file in components/ui or components/brand. Update its base rule in styles/design-system-parts.css when needed. The registry build copies utilities used by published parts from styles/design-system-parts.css into the dwellir-theme CSS in registry.json. Show new cva variants on /design-system. Nest no @media or other at-rule in a published utility: the shadcn CLI appends it again on every add. Put it at the top level of the theme CSS, as the glass utility's reduced-transparency rule does.
  2. Add a new part to registry.json (name, npm dependencies, file path). Give a dependency a version range when the part needs that major, as tailwind-merge@^3 and class-variance-authority@^0.7 do.
  3. Run npm run registry:build. It writes public/r, which the site serves. It also copies styles/aurora-canvas.css and styles/solar-wind.css (the site's aurora layers), each followed by its -registry.css mapping onto the theme, into the aurora-canvas and solar-wind items (scripts/registryCss.cjs). A new file under components/aurora or lib/aurora must join one of the two items: the curtain's files join aurora-canvas, the wind's solar-wind. The registry test fails until it does, and when two items ship the same file.
  4. Commit the source, registry.json and public/r together. CI fails when public/r no 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 gets text-sm (14px), from md.
  • No keyboard on open. Under a coarse pointer, Dialog, Sheet, CommandDialog and Popover focus 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-focus does this. Your own onOpenAutoFocus runs first; call event.preventDefault() in it to choose the focus yourself. A mouse keeps Radix's focus on the first field. AlertDialog needs 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's modal-*, corner-* and docked-b tokens hold the max(12px, env(safe-area-inset-*)) sums, and max-h-modal subtracts them. The fixed sidebar is h-dvh and pads its edge, top and foot by the safe area. SiteHeader pads 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). Container pads each side by 16px (24px from lg), or by the deeper of the left and right safe areas, so the column stays centred: the theme's gutter and gutter-lg tokens. Without viewport-fit=cover every inset is 0 and nothing moves. dwellir.com sets viewport-fit=cover (pages/_app.tsx), so in landscape its header and Container clear the notch.
  • Press feedback. A finger never hovers, so a held Button shrinks to 97% under a coarse pointer. It eases out in 150ms and stays still under reduced motion. Disabled and visual buttons do not move.
  • Hover only for hovering pointers. A touch screen keeps :hover after a tap. Every hover style in the parts and utilities, rim-hover included, 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: xs and icon-xs measure 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, InputGroup and the default SelectTrigger are 44px tall under a finger. Calendar days 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 Button size, Toggle, ToggleGroup and ChoiceGroup items, SelectTrigger size="sm" and the selectTriggerVariants recipe on a button of your own (touch: false opts out), a Badge that renders a button, the pagination links, the DataTable sort buttons, the code block's copy button, the SiteHeader brand link, theme switch and menu button, and the dialog and sheet close buttons. The tap area is an ::after past the painted box (touch-target), border included.
  • 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): an sm button, 32px, is 36px tall in a row with gap-2 and keeps 32px with gap-3. With no gap declared above it, a control keeps its box and takes the full 44px. A positioned button and a link button do not grow. Lay out with gap-*, not margins: the parts read their room from the gap.
  • One-line groups. ToggleGroup spaces its items with gap-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 segmented ChoiceGroup item takes its room from the track's 4px padding, so a 36px item taps at 44px inside the track. An inline InputGroupAddon lies 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 a Dialog, Sheet or AlertDialog it 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. Checkbox and 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 with gap-2 taps at 24px, the WCAG 2.5.8 minimum; its Label is the larger target. The Slider thumb takes a 44px tap (slider-touch): nothing else sits in its reach.
  • Not grown. These stay under 44px on a coarse pointer:
    • Checkbox, RadioGroupItem and Switch take their larger tap through their Label: put the label beside them in a Field.
    • 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.
    • visual buttons and toggles take their children's size. Text links (link buttons, breadcrumb links) keep their line.
    • DropdownMenu and Select option rows are 36px tall. UptimeBar strips are 32px. StatHint taps at about 46 by 36px.
    • DataTable sort buttons in a density="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 menu gap-3 for 44px.
  • The sidebar row's ::after is its tap area. A SidebarMenuButton takes 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, as aurora-current does.
  • Swipe past a slider. A horizontal Slider takes 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 TooltipProvider once 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. A Tooltip with 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 span around 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-disabled does not help: ui-button gives it pointer-events: none too. /design-system shows the pattern under Tooltip.

  • Plain modals by default. Mount ModalSurfaceProvider once at the root: <ModalSurfaceProvider surface="plain">. Every Dialog, AlertDialog and Sheet that names no surface is then plain. A surface prop still wins. Without the provider the default stays grain, 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-muted hover on bg-sidebar mixes with the sidebar, not the page. A well card 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, DialogTitle and AlertDialogTitle balance 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. cn knows 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. SelectTrigger takes the field sizes: 16px or more under a coarse pointer at every width, 14px from md with a mouse, as Input, Textarea and the OptionPicker field 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 read 99.00%, with digits of one width.
  • Modals. DialogContent and SheetContent set data-closable when 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. valueText formats 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.labelDayButton or 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 in foreground/50 on keyboard focus, where it only brightened a 1px edge.
  • Header on a phone in landscape. SiteHeader is 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. With viewport-fit=cover, set scroll-padding-top: calc(var(--navbar-height) + env(safe-area-inset-top, 0px)) on html, 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. flexRender renders a column's cell function as a component. Define the columns outside the render, or memoise them: a new cell function 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 card 0.33 and a popover 0.36. The alt sheet (alt-ground) stays at 0.26, so its cards (alt-card, 0.3) still rise.
  • No shadows. --shadow-alpha: 0 in .dark scales shadow-xs, shadow-card and shadow-popover to nothing, the top light too. Black does not read on near-black. The theme now exports shadow-xs: Tailwind's value (black at 5%) by day. The top light would add a second line under the edge.
  • One edge. Dark --border is oklch(1 0 0 / 0.1). A translucent edge shows 1.36:1 above any fill under it. A solid 0.35 edge was darker than the popover. Cards and popovers keep their border 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-strong is oklch(0.68 0 0), for 3:1 on the secondary fill.
  • Inactive controls. foreground-75 mixes 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-foreground is oklch(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 edit iconBackplates.json by hand.
  • Small tooltips (size="sm") have 8px corners, as other floating surfaces do. The uptime tooltip's second lines use background-80 (about 5.2:1 at 12px).
  • Select items keep their weight when chosen; the check mark shows the choice.
  • Corners. CollapsibleCard takes 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-title and accordion-trigger utilities.
  • Scrollbars. Code blocks and table scrollers show a thin bar in the edge colour (scrollbar-quiet).
  • Fonts. --font-display reads var(--font-ubuntu) first, then the theme's Ubuntu Fallback (below). Load the fonts from your own origin, not from Google Fonts: copy public/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 over 100 900 and JetBrains Mono over 400 800, as lib/fonts.ts does, so font-medium is a real 500. As two faces at 400 and 700, 500 snaps to 400. In a Vite app, write the same @font-face rules with font-weight: 100 900.
  • Display fallback. The theme's parts.css declares Ubuntu Fallback: Arial, or Liberation Sans where Arial is missing, scaled to Ubuntu's widths and given Ubuntu's ascent and descent. --font-display lists it after var(--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 through next/font sets adjustFontFallback: false for Ubuntu, as lib/fonts.ts does. Otherwise next generates a face named ubuntu 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. Use next/font/local: under Turbopack, next/font/google adds its fallback even with this setting (vercel/next.js#74134). The status page loads Ubuntu 700 through next/font/local with 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's styles/font-fallbacks.css; npm run registry:build copies them. Where neither Arial nor Liberation Sans is installed (Android, some Linux), headings use system-ui until 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-2 and --aurora-3 at 22%, 12% and 8%;
  • a 1px rim on ::before in 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-hairline on :root: 0.5px on a screen with 2 or more device pixels per CSS pixel, and 1px below that. Separator and the PageHeader border use it. Draw your own line with border-b-(length:--border-hairline), or grid lines with gap-(--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 is rounded-inset (see "The radius scale"): 12px in the theme's 18px card. Use it with plain or well, and drop hand-built row classes such as rounded-[12px] border p-4.
  • Modal motion. SheetContent, DialogContent and AlertDialogContent time their open and close keyframes with animation-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. --ring is the ink: oklch(0.26 0.01 248) in light and oklch(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:

    GroundLightDark
    Page ground15.46:18.29:1
    Card13.9:19.47:1
    Popover15.46:19.47:1
    Muted (wells, tracks)13.58:17.37:1
    Alt sheet13.9:19.47:1
    Sky sheet12.7:110.29:1

    --sidebar-ring takes 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-2 or outline-offset-4 with outline-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, a Card of any surface (a well hands its muted fill on), a Section band, popover, select and dropdown-menu content, a tooltip, the default and stretch Tabs tracks, and a segmented ChoiceGroup. 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-ground overrides 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-none and outline-hidden clear 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-ring keeps the band: the Button warning and destructive variants and aria-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 takes outline-ring, such as a footer link's ink, has no band.
    • The band is Tailwind's ring-offset shadow, so shadow-xs, shadow-card and the other theme shadows still show. A control that sets box-shadow in 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:

    BackdropLight, ring onlyLight, two edgesDark, ring onlyDark, two edges
    Solid grounds (page, card, popover, muted, alt, sky)12.7:1 or more12.7:1 or more7.37:1 or more7.37:1 or more
    Primary fill, the slider range6.08:16.08:11.55:16.11:1
    Homepage hero, moving (lowest of 9 frames, 3 runs)3.06:13.57:11.14:13.21:1
    Homepage hero, still3.92:13.92:11.72:13.21:1
    Design system sky band (lowest of 3 runs)3.88:13.88:11.76:13.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.

  • 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 is oklch(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.

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 with clip-path and 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 a transform on sidebar-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 use apple-mobile-web-app-status-bar-style black-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 with width and padding transitions. Keep the collapsed padding-left and padding-right rule: the padding now changes in one step.
  • Drop margin-top from both group-label transitions, [data-sidebar="sidebar"] [data-sidebar="group-label"] and [data-collapsible="icon"] [data-sidebar="group-label"]. Keep the color fade and the collapsed margin-top: -1.5rem: the label then closes in one step.
  • Optional: the transition-timing-function: var(--ease-in-out) rule on sidebar-gap and sidebar-container is 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 select for a short fixed list without search.
  • Use option-picker for searchable single or multiple choice from known options. Multiple mode shows removable chips.
  • Use command for 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/:

FileIconsJobs
navigation.tsx37Arrows, chevrons, the selector, sidebar toggles, menus, layout, links, sign in and out
actions.tsx33Search, add, copy, download, share, refresh, delete, save, filter, sort, play, edit, send, power
status.tsx28Info, help, alert, warning, error, success, the spinner, bell, clock, flame, star, shield, 404
data.tsx23Charts, gauge, calculator, numbers, lists, reports, dates, usage and logs
nouns.tsx55Keys, 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 size to set width and height together, in px or any CSS length.
  • Without size, CSS sizes the icon. Use className="size-5", or [&_svg]:size-5 on 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-foreground or text-accent-ink. lint:ds lets a rune take layout and colour classes and animate-spin, and nothing else.
  • Every icon is aria-hidden by default. Pass aria-label and aria-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 in currentColor with no stroke. Use it only where a filled state is the job. The set has RuneStopSolid, RuneSuccessSolid and RuneDot.
  • RuneLoader is the spinner: three quarters of a cut hexagon. It is still. Spin it with animate-spin. It replaces lucide Loader2 and LoaderCircle.

Rules for a new icon:

  • Use straight strokes only. The path commands are M, L, H, V and Z and 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.cjs fails 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 .5 square (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> with rune(). For a filled state, draw with runeSolid(): 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:

  1. 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 the rune-icons registry item.
  2. Map its Tabler, lucide and react-icons names in scripts/rune-icons/rune-icon-map.json.
  3. Run npm run registry:build. It refreshes the /design-system catalog (views/DesignSystem/runeIconCatalog.json) and public/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 …Icon name become the rune name.
  • It drops stroke (Tabler), strokeWidth and absoluteStrokeWidth. A rune's stroke is fixed.
  • It keeps size. A JSX icon with no size, width, height or size class gets its old library's default: size={24}, or size="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:

  1. Install the icons and the codemod: npx shadcn@latest add @dwellir/rune-icons @dwellir/rune-icons-codemod.
  2. Run the codemod with --dry-run and read the report. Then run it without --dry-run.
  3. 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.
  4. Check icons passed as values (icon: RuneX) render with a size: a rune has no default.
  5. Run the formatter, the type check and the tests.
  6. Remove @tabler/icons-react, lucide-react or react-icons from package.json once 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.

  1. Reinstall the parts you use. The CLI installs @dwellir/rune-icons with them:

    npx shadcn@latest add -o @dwellir/select @dwellir/dialog @dwellir/dropdown-menu
    
  2. Install @dwellir/rune-icons if you do not use a part that needs it yet: npx shadcn@latest add @dwellir/rune-icons.

  3. 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.

  4. Move your own lucide imports to runes with the codemod above.

  5. Remove lucide-react from package.json once nothing imports it. npm ls lucide-react shows 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.