@animus-ui/system

中文文档
0.1.87发布于 2026/10/24MITTypeScriptES Module构建来源已验证近 30 天下载 6931

Animus design system builder — tokens, prop groups, global styles

原始使用文档
文档目录17 节
  1. Install
  2. Quick Start
  3. 1. Define the theme
  4. 2. Create the system with prop groups
  5. 3. Build components
  6. Builder Chain
  7. Extending a component
  8. Fallback values
  9. Anchor-position fallbacks
  10. View transitions
  11. Relative-state selectors
  12. Color Modes
  13. "System" is the absence of the attribute
  14. The appearance record
  15. Integration contract
  16. Exports
  17. License

@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/system

Pair 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 component

The 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 callback

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

Fallback values

firstThatWorks() lists values for one property, the preferred one first. Extraction declares them in reverse, so the last one a browser supports wins, and folds a run of bare var() values into one nested fallback:

import { firstThatWorks } from '@animus-ui/system';

const Header = ds
  .styles({
    position: firstThatWorks('sticky', '-webkit-sticky', 'fixed'),
    scrollMarginTop: firstThatWorks(
      'var(--header-height)',
      'var(--bar-height)',
      '4rem'
    ),
  })
  .asElement('header');
.animus-Header-… {
  position: fixed;
  position: -webkit-sticky;
  position: sticky;
  scroll-margin-top: var(--header-height, var(--bar-height, 4rem));
}

Each member types against the property and resolves through its scale. It works in component styles, global styles and keyframes. Written as a call-site prop it is only read at runtime, which applies the preferred value alone and warns (animus.first-that-works.unextracted). A minifier lowering for browser targets, such as Lightning CSS in Vite or Animus's own targets, drops a fallback every target already supports.

Anchor-position fallbacks

createPositionTry, on the built bundle, names fallback positions for anchor positioning. Register the collection under its export name, as for keyframes, and reference its keys in positionTryFallbacks:

export const { createPositionTry } = bundle;
export const placements = createPositionTry({
  above: { positionArea: 'top', marginBottom: '{space.8}' },
  flipStart: { top: 'anchor(bottom)', insetInlineStart: 'anchor(start)' },
});
export const ds = bundle.registerPositionTry({ placements }).seal();

const Tooltip = ds
  .styles({
    positionAnchor: '--trigger',
    positionTryFallbacks: `${placements.above}, flip-block`,
  })
  .asElement('div');
@layer anm-global {
  @position-try --animus-pt-… {
    position-area: top;
    margin-bottom: 0.5rem;
  }
}
.animus-Tooltip-… {
  position-anchor: --trigger;
  position-try-fallbacks: --animus-pt-…, flip-block;
}

Each position is named from its descriptors. Only what @position-try accepts is admitted: inset, margin, sizing and self-alignment properties, positionAnchor and positionArea; anything else is a type error, and throws. Write a list of fallbacks as a template literal, since an array is a responsive value. Every registered position emits, used or not.

View transitions

createViewTransitions, on the built bundle, names view-transition classes. Each transition's group, imagePair, old and new slots style its pseudo-elements, and take keyframe references, tokens and conditions as a global style block does. Register the collection under its export name:

export const { createKeyframes, createViewTransitions } = bundle;
export const motion = createKeyframes({
  slideIn: { from: { translate: '100% 0' } },
  slideOut: { to: { translate: '-100% 0' } },
});
export const transitions = createViewTransitions({
  slide: {
    group: { animationDuration: '250ms', _motionReduce: { animation: 'none' } },
    old: { animationName: motion.slideOut },
    new: { animationName: motion.slideIn },
  },
});
export const ds = bundle
  .registerKeyframes({ motion })
  .registerViewTransitions({ transitions })
  .seal();

const Card = ds
  .styles({
    viewTransitionName: 'match-element',
    viewTransitionClass: transitions.slide,
  })
  .asElement('article');
@layer anm-global {
  ::view-transition-group(*.animus-vt-…) {
    animation-duration: 250ms;
  }
  @media (prefers-reduced-motion: reduce) {
    ::view-transition-group(*.animus-vt-…) {
      animation: none;
    }
  }
  ::view-transition-old(*.animus-vt-…) {
    animation-name: animus-kf-…;
  }
  ::view-transition-new(*.animus-vt-…) {
    animation-name: animus-kf-…;
  }
}

A ref prints as its class, so String(transitions.slide) also names it where a framework takes a class, such as React's <ViewTransition>. Every registered transition emits, used or not.

Relative-state selectors

marker(name) names a class to put on an element whose state styles another: an ancestor, a descendant or a sibling. Register its relations as selector aliases:

import { createSystem, marker } from '@animus-ui/system';

export const row = marker('row'); // row.className === 'animus-marker-row'

const bundle = createSystem()
  .addSelectors({
    _rowHover: row.ancestor(':hover'),
    _rowHasFocus: row.descendant(':focus-visible'),
  })
  .build();

<tr className={row.className}>
  <Cell _rowHover={{ opacity: 1 }} />
</tr>;
RelationSelector
ancestor(':hover')&:where(.m:hover *)
descendant(':hover')&:where(:has(.m:hover))
siblingBefore(...)&:where(.m:hover ~ *)
siblingAfter(...)&:where(:has(~ .m:hover))
anySibling(...)&:where(.m:hover ~ *, :has(~ .m:hover))

A state is a pseudo-class or an attribute selector ([data-open]). The marker sits inside :where(), so a relation adds no specificity beyond the component's class, and a state of the element itself, such as _hover, wins over a relative one. No _group* or _peer* aliases are built in.

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

PathWhat's in it
@animus-ui/systemFull API — builder, theme, runtime, types
@animus-ui/system/groupsPre-built prop groups: space, color, typography, layout, flex, grid, border, shadows, background, positioning, transitions, mode, vars
@animus-ui/system/composecompose — slot families with shared variant propagation
@animus-ui/system/compose-with-contextcomposeWithContext — slot families whose shared props travel by React context; with requireRoot: true, a member outside its Root throws in development
@animus-ui/system/runtimecreateComponent and the composed-family runtime
@animus-ui/system/class-resolvercreateClassResolver — class resolution without the React runtime, for non-React consumers
@animus-ui/system/bootstrapcreateAppearanceBootstrap — build-time only, never imported by application code
@animus-ui/system/appearancepersistColorMode, migrateLegacyModeKey, SYSTEM_MODE — storage-only appearance record write path (runtime, client-safe)

License

MIT

版本与发布记录

@animus-ui/system 共发布 112 个版本,首次发布于 2026/03/26;过去 12 个月发布了 112 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.1.87latest2026/10/2414 天前
0.1.862026/10/2315 天前
0.1.852026/10/2315 天前
0.1.842026/10/2216 天前
0.1.832026/10/2217 天前
0.1.822026/10/2217 天前

其他发布通道:next → 0.1.1-beta.2。

关于 @animus-ui/system 的常见问题

@animus-ui/system 是什么?

Animus design system builder — tokens, prop groups, global styles

如何安装 @animus-ui/system?

在项目目录运行 npm install @animus-ui/system,或使用 pnpm add / yarn add / bun add。

@animus-ui/system 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@animus-ui/system 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@animus-ui/system 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@animus-ui/system 可以商用吗?

@animus-ui/system 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@animus-ui/system 的下载量有多少?

根据 npm 官方下载统计,@animus-ui/system 在 2026/10/06 – 2026/11/04 这 30 天内被下载 6,931 次,日均约 231 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@animus-ui/system 还在维护吗?

最新版本 0.1.87 发布于 2026/10/24(14 天前);至今共发布 112 个版本,其中过去 12 个月发布了 112 个;首次发布于 2026/03/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。