@animus-ui/system
v0.1.25
Published
Animus design system builder — tokens, prop groups, global styles
Maintainers
Readme
@animus-ui/system
Design system builder for React. Type-driven CSS-in-JS with zero runtime — styles extract to static CSS at build time.
Install
npm install @animus-ui/systemPair with an extraction driver: @animus-ui/vite-plugin,
@animus-ui/next-plugin, @animus-ui/unplugin, or the @animus-ui/cli.
Quick Start
1. Define the theme
import { createTheme } from '@animus-ui/system';
const theme = createTheme()
.addBreakpoints({ sm: 480, md: 768, lg: 1024 })
.addColors({
gray: { 100: '#f0f0f0', 800: '#1a1a1a' },
blue: { 400: '#3d94ff', 700: '#003d99' },
red: { 500: '#e63946' },
})
.addColorModes('dark', {
dark: {
primary: 'blue.400',
danger: 'red.500',
bg: 'gray.800',
text: 'gray.100',
},
light: {
primary: 'blue.700',
danger: 'red.500',
bg: 'gray.100',
text: 'gray.800',
},
})
.addScale({
name: 'space',
values: { sm: '0.5rem', md: '1rem', lg: '1.5rem' },
})
.build();
type AppTheme = typeof theme;
declare module '@animus-ui/system' {
interface Theme extends AppTheme {}
}2. Create the system with prop groups
Pre-built groups ship with the package. Compose them into your own semantic groups:
import { createSystem } from '@animus-ui/system';
import {
background,
border,
color,
flex,
layout,
shadows,
space,
typography,
} from '@animus-ui/system/groups';
const bundle = createSystem()
.addGroup('surface', { ...color, ...border, ...shadows, ...background })
.addGroup('space', space)
.addGroup('text', typography)
.addGroup('arrange', { ...flex, ...layout })
.build();
export const { createGlobalStyles, createKeyframes } = bundle;build() returns a bundle, not the system. Keyframes collections and
global-style blocks are registered on the bundle under their module-scope
export names, and seal() produces the system components are built from:
export const motion = createKeyframes({
pulse: { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.6 } },
});
export const globalStyles = createGlobalStyles({
body: { margin: 0, bg: 'bg', color: 'text' },
});
export const ds = bundle
.registerKeyframes({ motion })
.registerGlobalStyles({ globalStyles })
.seal();The registration key must equal the export name, because the extractor
resolves motion.pulse references by that name. Keyframes and global-style
blocks share one vocabulary namespace.
To build on a published design-system kit, start either chain with
.extend() — it merges the kit's registries and tokens into yours (kit as
base, your later calls win on conflict), and the extraction pipeline
discovers the kit through the same edge:
import { system as kitSystem, theme as kitTheme } from '@acme/kit';
const theme = createTheme().extend(kitTheme).build();
const bundle = createSystem()
.extend(kitSystem)
.addProps({ cursor: { property: 'cursor' } })
.build();createSystem({ includes: [...] }) and .from() are deprecated: they add
discovery membership without merging registries.
Each group becomes an opt-in set of props that components enable with .system():
const Box = ds
.styles({})
.system({ surface: true, space: true })
.asElement('div');
// Box now accepts: color, bg, border, shadow, p, m, gap, etc.
<Box bg="bg" p="md" borderBottom="1px solid" />;3. Build components
export const Alert = ds
.styles({
display: 'flex',
alignItems: 'flex-start',
p: 'md',
borderRadius: '4px',
fontSize: '14px',
lineHeight: '1.5',
})
.variant({
prop: 'variant',
variants: {
filled: { color: 'bg' },
outline: { bg: 'transparent', borderWidth: '1px', borderStyle: 'solid' },
},
})
.variant({
prop: 'intent',
variants: {
info: { bg: 'primary' },
danger: { bg: 'danger' },
},
})
.compound(
{ variant: 'outline', intent: 'info' },
{ borderColor: 'primary', color: 'primary' }
)
.compound(
{ variant: 'outline', intent: 'danger' },
{ borderColor: 'danger', color: 'danger' }
)
.states({
disabled: { opacity: 0.5, pointerEvents: 'none' },
})
.system({ space: true })
.asElement('div');
<Alert variant="filled" intent="info" m="sm" disabled />;Builder Chain
The chain enforces cascade ordering — each method maps to a CSS @layer:
ds.styles() → @layer anm-base
.variant() → @layer anm-variants
.compound() → @layer anm-compounds
.states() → @layer anm-states
.system() → @layer anm-system
.props() → @layer anm-custom
.asElement() → typed React componentThe type system prevents calling methods out of order.
Extending a component
.extend() on a component starts a chain that inherits everything the
component declares, custom props and their callbacks included. Its methods
can be called in any order; a custom prop redeclared in .props() replaces
the inherited one for this extension and its own extensions only.
const Card = ds
.props({
inset: { property: 'padding', transform: (v) => `${Number(v) * 4}px` },
})
.asElement('div');
export const Panel = Card.extend()
.styles({ display: 'grid' })
.asElement('section');
<Panel inset={3} />; // padding: 12px, computed by Card's callbackThe extension reads inherited callbacks from the parent component when its
module runs, just as the authored Card.extend() does. Two consequences:
- The parent's module must finish initializing before the extension's module: an extension cannot be declared across an import cycle that evaluates the extending module first.
- A parent with custom-prop callbacks that is defined in a client module
(
'use client') cannot be extended from a server module; declare the extension in a client module.
Color Modes
addColorModes(initialMode, modeConfig) emits a [data-color-mode="…"] block
per mode. An optional third argument opts the theme into OS participation:
const theme = createTheme()
.addColors({ gray: { 100: '#f0f0f0', 800: '#1a1a1a' } })
.addColorModes(
'dark',
{
dark: { bg: 'gray.800', text: 'gray.100' },
light: { bg: 'gray.100', text: 'gray.800' },
},
{
// OS preference → declared mode name.
systemPreference: { light: 'light', dark: 'dark' },
// CSS `color-scheme` per mode. The two mapped modes default to
// 'light'/'dark', so `{}` is the whole opt-in here; classify any
// additional modes explicitly.
browserColorScheme: {},
}
)
.build();systemPreference enables guarded fallback emission:
@media (prefers-color-scheme: dark) {
:root:not([data-color-mode]) {
/* the dark mode's declarations */
}
}The :not([data-color-mode]) guard is what makes an explicit attribute win — in
CSS alone, with no script. Both values must name declared modes.
browserColorScheme adds the CSS color-scheme property so native
scrollbars, form controls, and UA styling track the active mode. When supplied it
must classify every declared mode, otherwise a mode would silently inherit the
previous one's native scheme — with one carve-out: the two modes named by
systemPreference are forced to light/dark by validation anyway, so they
default and may be omitted. An explicit entry on a mapped mode is honored and
still conflict-checked.
A theme that opts into neither emits exactly the bytes it emitted before.
"System" is the absence of the attribute
There is no data-color-mode="system" — the OS-following state is the attribute
being absent, which is the only value the media guard above can fall through.
system is a reserved mode name and is rejected.
The appearance record
Persisted appearance lives under one versioned key, animus:appearance:
{ "v": 1, "mode": "system" | "<mode name>", "theme": "default" }The theme axis is reserved and currently ignored — a writer that owns only the
mode axis must preserve the fields it does not own. The package ships that
write discipline so you don't hand-roll it: @animus-ui/system/appearance is a
tiny, storage-only runtime subpath (no React, no listeners, no DOM — applying
data-color-mode stays yours):
import {
SYSTEM_MODE,
migrateLegacyModeKey,
persistColorMode,
} from '@animus-ui/system/appearance';
persistColorMode('midnight'); // read-modify-write; unowned fields survive
persistColorMode(SYSTEM_MODE); // "follow the OS" — bootstrap restores absence
// One-shot: move YOUR app's old key into the record, then delete it.
migrateLegacyModeKey('my-app-color-mode', ['midnight', 'paper']);It refuses to downgrade a record written by a newer version, and refuses to
migrate the shared color-mode key (that one may belong to another app on your
origin; the bootstrap already reads it, read-only). A failed write is swallowed
rather than thrown, so a caller that has already applied data-color-mode keeps
the user's choice for that session even when storage rejects the write.
Call migrateLegacyModeKey post-paint, from the app entry: it returns the
migrated mode name to apply for that one visit, or null when nothing was
migrated. The visit it migrates on paints in the OS-resolved mode before
correcting itself — one accepted flash, once — and every later load restores the
mode pre-paint through the generated bootstrap.
To restore it before first paint, generate an inline snippet from the built theme:
import { createAppearanceBootstrap } from '@animus-ui/system/bootstrap';
const { code, cspHash } = createAppearanceBootstrap(theme, {
storageKey: 'animus:appearance', // default
});code is a dependency-free IIFE for the document head: it reads the record,
validates mode against the theme's declared names, sets data-color-mode for a
valid explicit mode, and removes the attribute for "system", a missing
record, or an unrecognized value — handing control back to the media query. It
never writes storage and never calls matchMedia (materializing the OS answer
into the attribute would freeze it against later OS changes). The pre-record
plain-string color-mode key is read once, only when the record is absent, and
is never written.
cspHash authorizes that exact script. Derive the header from the artifact at
build time and single-quote the value — script-src 'sha256-…'. Unquoted it
parses as a host source and silently blocks the script; hand-copied, it goes
stale the moment a declared mode name or the storage key changes, and a stale
hash is a blocked script and a flash of the wrong mode.
This subpath is build tooling. It is never imported by the component or runtime
entries, so it cannot reach an extracted application bundle — generate the
artifact in your bundler config and hand it to the plugin
(@animus-ui/vite-plugin
accepts appearanceBootstrap; in Next.js the application places code itself,
so it can control CSP nonce and ordering).
Integration contract
Publishing custom aliases. Augment both registries from the built system:
import type { ConditionsOf, SelectorsOf } from '@animus-ui/system';
declare module '@animus-ui/system' {
interface Selectors extends Record<SelectorsOf<typeof ds>, true> {}
interface Conditions extends Record<ConditionsOf<typeof ds>, true> {}
}While both interfaces are empty, _-prefixed block keys stay permissive.
Augmenting either one makes the whole _ namespace validating, so the other
registry's aliases are rejected until it is augmented too. Registered selector
aliases also type as component callsite props (<Box _hoverChild={{ p: 8 }} />);
condition aliases are style-block keys only.
Keyframe bodies. Frames accept CSS property names (camelCase, converted to
kebab-case at emission), raw CSS values, and {scale.key} token references such
as {shadows.glow-text}, which the theme resolver substitutes. A bare scale key
with no braces is emitted verbatim, so always write the delimited form.
className ordering. On the normal render path a consumer-supplied
className merges after the generated classes, and prop forwarding skips
className because that merge owns it. That is what lets className="group"
survive on the rendered element, so ancestor patterns such as .group:hover &
work.
Exports
| Path | What's in it |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @animus-ui/system | Full API — builder, theme, runtime, types |
| @animus-ui/system/groups | Pre-built prop groups: space, color, typography, layout, flex, grid, border, shadows, background, positioning, transitions, mode, vars |
| @animus-ui/system/compose | compose — slot families with shared variant propagation |
| @animus-ui/system/compose-with-context | composeWithContext — slot families whose shared props travel by React context |
| @animus-ui/system/runtime | createComponent and the composed-family runtime |
| @animus-ui/system/class-resolver | createClassResolver — class resolution without the React runtime, for non-React consumers |
| @animus-ui/system/bootstrap | createAppearanceBootstrap — build-time only, never imported by application code |
| @animus-ui/system/appearance | persistColorMode, migrateLegacyModeKey, SYSTEM_MODE — storage-only appearance record write path (runtime, client-safe) |
License
MIT
