import { Meta } from '@storybook/addon-docs/blocks';
This guide covers the design system foundations — tokens, presets, colors, units, and patterns — needed to build and maintain components in Cube UI Kit.
For styling engine internals see the Tasty documentation. Tasty docs are also bundled with this package in docs/tasty/:
- usage.md —
tasty()component creation, state mappings, sub-elements, variants, extending components, hooks. - configuration.md —
configure()options: tokens, recipes, custom units, style handlers, TypeScript module augmentation. - styles.md — Complete reference for all enhanced style properties (
fill,flow,preset,border,radius,transition, etc.). - debug.md —
tastyDebugruntime API for inspecting injected CSS and troubleshooting. - injector.md — Low-level style injector:
inject(),injectGlobal(), deduplication, SSR. - tasty-static.md — Zero-runtime
tastyStatic(), Babel plugin, Next.js/Vite integration.
Pass your router's useHref and useNavigate hooks through Root's navigation adapter. Menu.Item, like Button, ItemButton, and Link, accepts to destinations. Its label and description expose native anchors for modified-click, middle-click and link context-menu commands; the complete row supports ordinary click and keyboard activation. Use navigationOptions for router options and target, rel, or download for native link behavior. Nested providers and overlays inherit the adapter unless they explicitly supply another one.
DialogContainer accepts type="tray" and styles for its overlay content shell. Its child Dialog has separate styles and size bounds. Layout.Panel exposes those same channels through dialogProps.styles and dialogStyles in dialog mode; inline panel styles and stored width remain independent. Keep the tray type and change dimensions to expand it without remounting its content.
UI Kit ships components optimized with React Compiler and requires React and React DOM 19.3 or newer. Your application does not need to enable Compiler to use this build. Adoption is incremental: functions with known compatibility diagnostics remain unoptimized, and the legacy form's mutable render paths are explicitly excluded.
Compiling UI Kit does not make mutable legacy form reads in your application reactive. When enabling Compiler in an application, migrate those consumers to the modern Form subscriptions (Form.useValue, Form.useSelector, or Form.Subscribe) or keep the affected consumer components uncompiled with "use no memo". Forms without an explicit instance still use the legacy implementation.
Tasty provides custom units that map to CSS custom properties defined on the Root component. Use these instead of raw pixel values to keep styling consistent.
| Unit | CSS Variable | Example | Computed (defaults) |
|---|---|---|---|
x |
var(--gap) |
2x → calc(var(--gap) * 2) |
16px |
r |
var(--radius) |
1r → var(--radius) |
6px |
cr |
var(--card-radius) |
1cr → var(--card-radius) |
10px |
bw |
var(--border-width) |
1bw → var(--border-width) |
1px |
ow |
var(--outline-width) |
1ow → var(--outline-width) |
3px |
These CSS custom properties are defined in src/tokens/base.ts and applied globally.
| Token | Default | Description |
|---|---|---|
$gap |
8px |
Base spacing unit (used with x multiplier) |
$radius |
6px |
Base border radius (used with r multiplier) |
$border-width |
1px |
Base border width (used with bw multiplier) |
$outline-width |
3px |
Width of a field's thick focus ring (used with ow multiplier) |
$outline-offset |
1px |
Gap between a focus outline and its element |
$ring-width |
1px |
Width of a hairline focus or selection ring |
$item-gap |
1px |
Hairline gap between adjacent items in a list, menu or action group |
$stroke-width |
1.5 |
Icon stroke width |
$disabled-opacity |
0.4 |
Opacity for disabled elements |
$transition |
80ms |
Default transition duration |
$disclosure-transition |
120ms |
Disclosure animation duration |
$tab-transition |
120ms |
Tab animation duration |
$fade-transition |
200ms |
Fade animation duration |
$overlay-transition |
250ms |
Modal, tray and underlay enter/exit duration |
$scrollbar-transition |
150ms |
Auto-hiding (tiny) scrollbar fade duration |
$large-radius |
(1r + .5x) |
Larger radius for cards/dialogs |
$card-radius |
(1r + .5x) |
Card border radius |
$dialog-max-size |
calc(100dvw - 8x), calc(100dvw - 4x) up to 700px |
Maximum dialog width |
$min-dialog-size |
min(288px, $dialog-max-size) |
Minimum dialog width |
Defined in src/tokens/spacing.ts. Values use the x unit.
| Token | Value | Computed (gap=8px) |
|---|---|---|
$space-xs |
0.5x |
4px |
$space-sm |
0.75x |
6px |
$space-md |
1x |
8px |
$space-lg |
1.5x |
12px |
$space-xl |
2x |
16px |
Defined in src/tokens/sizes.ts. Used for component heights and icon sizes.
| Token | Value |
|---|---|
$size-xs |
24px |
$size-sm |
28px |
$size-md |
32px |
$size-lg |
40px |
$size-xl |
48px |
Defined in src/tokens/shadows.ts. Shadow colors (#shadow-sm, #shadow-md, #shadow-lg) are generated by Glaze and adapt automatically to dark / high-contrast schemes.
| Token | Value |
|---|---|
$item-shadow |
0 1px 0.375x #shadow-sm |
$card-shadow |
0 0.5x 2x #shadow-md |
$shadow |
0 0.5x 2x #shadow-md |
$dialog-shadow |
0 1x 4x #shadow-lg |
Defined in src/tokens/layout.ts.
| Token | Value |
|---|---|
$max-content-width |
1440px |
$topbar-height |
48px |
$sidebar-width |
200px |
All color tokens use #name syntax in styles. Opacity variants use #name.NN (e.g. #surface-text.06 for 6% opacity).
The palette is generated by @tenphi/glaze (see src/tokens/palette.ts). Every color token is emitted as a state map with light, @dark, and @hc (high-contrast) variants, so the entire UI Kit adapts to dark / high-contrast schemes automatically.
The seeds it is generated from — brand hue, saturation, per-status hues, pastel, contrastLevel — are tunable at runtime. See Theming.
The @dark and @hc predefined states are wired up in src/components/Root.tsx to support both an attribute opt-in and the user's system preference:
<!-- Force dark scheme -->
<html data-scheme="dark">
<!-- Force high-contrast scheme -->
<html data-contrast="high">
<!-- Both can be combined -->
<html data-scheme="dark" data-contrast="high">When the attribute is absent, the scheme falls back to @media (prefers-color-scheme: dark) and @media (prefers-contrast: more).
For styling, use the state map — { '': light, '@dark': dark, '@hc': hc }. Two cases it cannot serve: a surface the stylesheet never reaches (a Vega spec, a CodeMirror / Monaco theme, an iframe), and a control whose value is the ambient condition (a Light/Dark preview selector). For those, read the same two conditions from JS:
import { useHighContrast, useScheme } from '@cube-dev/ui-kit';
const scheme = useScheme(); // 'light' | 'dark' — the JS answer to `@dark`
const isHighContrast = useHighContrast(); // boolean — the JS answer to `@hc`Both follow the attribute opt-in first and the media query second, exactly as the states do, and re-render when either changes. Outside React: resolveScheme(), resolveHighContrast() and subscribeScheme(listener) (fires on either condition; re-read in the listener). This is the conditions half of the gap resolveTokenValue() closes for token values.
Root-level only — there is no element-scoped or generic state reader, on purpose. Tasty's state vocabulary mixes element-local states (hovered, pressed, disabled) with ambient ones (@media(…), @root(…)), and only the ambient half is answerable without an element.
The neutral palette uses a surface / surface-text model. Each surface depth has its own AAA text color, plus a soft (AA) variant for secondary copy:
| Token | Role |
|---|---|
#surface |
Primary background |
#surface-2 |
Slightly recessed background (cards, inputs) |
#surface-3 |
Deepest background (panels, code blocks) |
#surface-4 |
Subtle recessed background for radio-tab containers |
#surface-text |
AAA text on #surface |
#surface-text-soft |
AA text on #surface (secondary copy, body text) |
#surface-text-soft-2 |
AA-large text on #surface (placeholders, captions) |
#surface-2-text, #surface-2-text-soft |
Same scale, anchored to #surface-2 |
#surface-3-text, #surface-3-text-soft |
Same scale, anchored to #surface-3 |
| Token | Role |
|---|---|
#border |
Default border color |
#placeholder |
Input placeholder text |
#focus |
Focus ring color |
#disabled |
Disabled state fill / text |
#pink |
Accent pink (independent hue) |
#shadow-sm / #shadow-md / #shadow-lg |
Tinted shadow colors (used by $*-shadow tokens) |
#overlay |
Dim backdrop (50% transparent) |
#clear |
transparent |
Tokens a container sets to steer a component that cannot resolve the color on its own. They ship with a default, so nothing has to be set unless the default is wrong.
| Token | Default | Role |
|---|---|---|
#current-fill |
#surface |
The label color current.primary punches out of its currentcolor chip — read by that one flavour and nothing else |
theme="current" type="primary" fills with the color it inherits, so its label cannot come from that color too and reads #current-fill instead. The page (#surface) is the right answer almost everywhere, and wrong in exactly one place: a container whose own text color is the page paints #white, which is #surface in light mode, so the label would vanish into its own chip. Such a container sets the token to its own fill, which contrasts with its own text by construction:
<Block fill="#fixed-dark" color="#white" styles={{ '#current-fill': '#fixed-dark' }}>
<Button theme="current" type="primary">Legible in both schemes</Button>
</Block>Setting it moves the label, the icon slots and the chip's rim together. It also takes the alpha suffix like any other token (#current-fill.5 is the disabled label), because it is a declared token rather than a bare custom property.
Each themed color (default, primary, success, danger, warning, note) provides an accent-* family anchored to a fixed white "accent surface text". These tokens stay recognizable across schemes (used for branded buttons, CTAs, etc.):
| Token (default theme; prefixed in others) | Role |
|---|---|
#accent-surface |
Saturated brand fill (button bg) |
#accent-surface-2, #accent-surface-3 |
Darker / more saturated variants |
#accent-surface-text |
Near-white text on accent fill |
#accent-text |
Saturated colored text (used on neutral surface) |
#accent-icon |
Saturated colored icon |
In the default theme these tokens are unprefixed (#accent-surface). In colored themes they are prefixed (#success-accent-surface, #danger-accent-text, …).
Each colored theme (primary, purple, success, danger, warning, note) also provides its own tinted <theme>-surface (banner / chip background) and <theme>-surface-text (banner text).
The hues below are the defaults; each is tunable at runtime (see Theming).
| Group | Default hue | Purpose |
|---|---|---|
#primary-* / #purple-* |
280.3° | Primary actions, brand color |
#success-* |
156.9° | Success states, confirmations |
#danger-* |
23.1° | Errors, destructive actions |
#warning-* |
84.3° | Warning states, caution |
#note-* |
302.3° | Informational notes |
For example, a success banner uses fill: '#success-surface' + color: '#success-surface-text', while a primary button uses fill: '#primary-accent-surface' + color: '#primary-accent-surface-text'.
The four status themes (success, danger, warning, note) add one more pair, <theme>-alert-surface and <theme>-alert-border, which Alert uses. They carry three times the saturation of <theme>-surface (twice in dark), so an alert's theme reads at a glance, and they keep #dark-02 text at AA (AAA in high contrast). primary, purple and the unprefixed default theme do not have them. The shared <theme>-surface / <theme>-border stay quieter because the Item family and table tints sit on them.
setPaletteConfig() re-seeds the palette after the kit has mounted; every token re-resolves and <Root> re-injects the token block. No component re-render is involved, because every color compiles to a CSS custom property.
import { resetPaletteConfig, setPaletteConfig } from '@cube-dev/ui-kit';
setPaletteConfig({ accent: { hue: 210 }, themes: { danger: '#b91c1c' } });
resetPaletteConfig();Also available: getPaletteConfig(), subscribePaletteConfig(), the usePaletteConfig() hook, a <Root palette={…}> prop, DEFAULT_PALETTE_CONFIG, and invalidatePaletteTokens() for when you drive glaze.configure(...) yourself after mount. The palette is process-global — one palette per process, not per tree or per request.
To theme a region instead of the document — a theme preview, or a dark panel inside a light page — use renderColorTokens(). It resolves one config and one scheme to flat values you apply via a tokens prop, without touching the live palette:
<Block tokens={renderColorTokens({ accent: { hue: 210 }, scheme: 'dark' })} fill="#surface">
…
</Block>Full contract, caveats and a live playground: Theming.
The previous token names continue to work as aliases (defined at the bottom of src/tokens/colors.ts). New code should prefer the Glaze tokens, but existing components do not need to be migrated:
| Legacy | Resolves to |
|---|---|
#text |
#surface-text-soft |
#dark / #dark-01 |
#surface-text |
#dark-02 |
#surface-text-soft |
#dark-03 |
#surface-text-soft-2 |
#dark-04 |
#placeholder |
#dark-05 |
#border |
#minor |
#surface-text-soft.65 |
#shadow |
#shadow-md |
#light |
#surface-3 |
#dark-bg |
#surface-2 |
#primary / #purple |
#primary-accent-surface |
#primary-text / #purple-text |
#primary-accent-text |
#primary-bg / #purple-bg |
#primary-surface |
#primary-icon / #purple-icon |
#primary-accent-icon |
#<status> |
#<status>-accent-surface |
#<status>-text |
#<status>-accent-text |
#<status>-bg |
#<status>-surface |
#<status>-icon |
#<status>-accent-icon |
#<status>-disabled |
#disabled |
#white, #black, and #clear are not defined in the palette — #white and #black are tasty's built-in named colors, and #clear is an alias for transparent.
Use via the preset style property. Modifiers are appended with a / separator: 't1 / strong', 'h2 / italic'. Modifier-only shorthand like 'bold' is also supported (equivalent to 'inherit / bold').
Defined in src/tokens/typography.ts.
| Preset | Size | Line Height | Weight |
|---|---|---|---|
h1 |
36px | 44px | 600 |
h2 |
24px | 32px | 600 |
h3 |
20px | 28px | 600 |
h4 |
18px | 24px | 600 |
h5 |
16px | 22px | 600 |
h6 |
14px | 20px | 600 |
| Preset | Size | Line Height | Weight | Notes |
|---|---|---|---|---|
t1 |
18px | 24px | 400 | |
t2 |
16px | 22px | 400 | |
t2m |
16px | 22px | 500 | Medium weight variant |
t3 |
14px | 20px | 400 | Base text size |
t3m |
14px | 20px | 500 | Medium weight variant |
t4 |
12px | 18px | 500 | Small text |
t4m |
12px | 18px | 600 | Small text, semi-bold |
t4n |
12px | 18px | 400 | Small text, normal weight — for secondary metadata beside t4 |
Paragraph presets have slightly increased line heights compared to text presets at the same size, optimized for multi-line reading.
| Preset | Size | Line Height | Weight |
|---|---|---|---|
p1 |
18px | 28px | 400 |
p2 |
16px | 24px | 400 |
p3 |
14px | 22px | 400 |
p4 |
12px | 20px | 500 |
Largest line heights, designed for long-form content blocks.
| Preset | Size | Line Height | Weight |
|---|---|---|---|
m1 |
18px | 32px | 400 |
m2 |
16px | 28px | 400 |
m3 |
14px | 24px | 400 |
Sized to parallel t2–t4. Each preset sets font-family to var(--font-mono) (the design-system monospace stack from Root).
| Preset | Size | Line Height | Weight | Notes |
|---|---|---|---|---|
s2 |
16px | 22px | 400 | Larger code / snippet text |
s3 |
14px | 20px | 400 | Default snippet size (matches Prism) |
s4 |
12px | 18px | 400 | Compact code / snippet text |
| Preset | Description |
|---|---|
c1 |
14px, weight 600, uppercase — section captions |
c2 |
12px, weight 600, uppercase — small captions |
tag |
12px, weight 600 — tag/badge text |
strong |
Inherits everything, applies bold weight |
em |
Inherits everything, applies italic style |
default |
Aliases t3 values — the base text style |
Some surfaces cannot take a var(--…) reference — a third-party iframe (Stripe Elements), a CodeMirror or Monaco theme object, a Vega spec. Those take colors, lengths and font descriptors as values, so they need the token resolved. resolveTokenValue() and its siblings read that value out of the DOM, from the element Root declares the tokens on, and refuse to hand back a placeholder.
import { resolvePresetValues, resolveTokenValue, resolveTokenValues } from '@cube-dev/ui-kit';
resolveTokenValue('#purple'); // 'oklch(0.571 0.0971 280.3)'
resolveTokenValue('$space-md'); // '8px'
resolveTokenValues(['#surface', '#surface-text', '#border']); // keyed by the tokens as written
resolvePresetValues('s3'); // { fontFamily: 'ui-monospace, …', fontSize: '14px', lineHeight: '20px', … }useTokenValue(), useTokenValues() and usePresetValues() are the same three readers as hooks, re-resolved whenever the palette is re-seeded or the scheme / contrast tier flips. The object useTokenValues() returns keeps its identity while the values do, so it is safe as a dependency of the theme object built from it.
Every color token computes to oklch(...), and a large class of consumers validates colors against "HEX, rgb(), or hsl()" and silently drops anything else — Stripe's Appearance API falls back to its own light theme, d3-color (so Vega) throws, mapbox-gl rejects even an #rrggbbaa tail. Nothing warns, and a round trip through the DOM does not help: Chrome serializes oklch back as oklch. So resolveTokenValue() hands back a value that is correct and unusable at the same time, and toLegacyColor() is the step between them.
import { resolveTokenValue, toLegacyColor } from '@cube-dev/ui-kit';
toLegacyColor(resolveTokenValue('#purple')); // '#6b53e4'
toLegacyColor('oklch(0.55 0.21 285 / 0.4)'); // 'rgba(107, 83, 228, 0.4)'
toLegacyColor('oklch(0.55 0.21 285 / 0.4)', { alpha: 'hex' }); // '#6b53e466'An opaque color is always #rrggbb, which everything accepts. Only the translucent case has a choice, and alpha picks it, because the acceptable form differs per consumer: 'rgba' (the default) is the widest — Stripe, mapbox-gl and CSS all take it — while 'hex' gives the shorter #rrggbbaa that Vega and plain CSS prefer and mapbox-gl drops.
It takes null so it composes with resolveTokenValue() directly, and reads every notation the kit emits or accepts: oklch(), okhsl(), okhst(), rgb(), hsl(), hex (3/4/6/8 digits) and transparent, in either the slash or the legacy comma alpha syntax, including the components a hand-rolled regex misses — a negative hue, any angle unit, scientific notation, a none component. A chroma outside the sRGB gamut is clipped to the gamut boundary rather than allowed to overflow a channel.
A value it cannot read returns fallback ?? null rather than the input, which is the whole point: color-mix(), relative syntax (oklch(from …)) and a bare var() reference only have a value inside a CSS engine, and handing one back unchanged is exactly what makes the consumer drop the color and revert to its own theme.
These resolve token values. For the ambient conditions themselves — which scheme and contrast tier the document is in — see Reading the scheme from JS.
So these helpers do not inspect the value. They read $tokens-applied, a marker the token block declares alongside the tokens, which answers the question that matters — are the kit's tokens in effect on this element? Off that surface every read returns null (or fallback) and warns once in development; on it, the computed value is taken as the truth.
All six take { element, fallback }. Pass element to read a local override instead of the document — a subtree carrying its own tokens prop (renderColorTokens()), or one under a data-scheme / data-contrast that differs from the document's. Pass fallback for the value to use when a token cannot be resolved, including during SSR, where there is no DOM to read.
Why getComputedStyle by hand does not work. Root declares the token block on <body>, so <html>, a detached node, and a tree that has not mounted Root yet are all outside it — and a token read from outside does not come back empty. Tasty registers @property rules with real initial-values, so --gap reads 8px there, --radius reads 6px, --transition reads 80ms. Those are tasty's defaults, matching the kit's own values only by coincidence: the value looks entirely ordinary, and for a token the kit sets differently it is simply wrong. Inspecting it cannot help in the other direction either: #clear really is transparent and $h2-letter-spacing really is 0px, indistinguishable from an unset property of the same type.
Component themes are set via the theme prop or theme=name modifier:
| Theme | Purpose |
|---|---|
default |
Standard appearance |
danger |
Destructive / error actions |
special |
Highlighted / promoted actions |
success |
Success / positive states |
warning |
Warning / caution states |
note |
Informational / note states |
current |
No palette of its own — every color is mixed from the inherited text color (#current), so the component adopts whatever its container paints with. Supported by Button, Item (and ItemButton, Item.Action, ItemBadge) |
Most components are a design contract: customize them through their own props (type, theme, size, …), tokens and mods rather than by restyling them. The layout and typography primitives (Element, Block, Flex, Flow, Grid, Space, Prefix, Suffix, Text, TextItem, Title, Paragraph, Typography, ButtonGroup and Button.Group), the Action base and the icons (including IconSwitch) are the exception: their styles are their API.
The package's tasty.config.ts lists the other components as protectedComponents, and tasty/no-protected-restyle from @tenphi/eslint-plugin-tasty 1.12.0 or newer reports a restyle of one in your code:
- instance
styles, and every*Stylesprop such asinputStyles; - style props other than the ones that place or size the component (
width,height,gridArea,margin,inset, …). Containers (Card,Panel,ResizablePanel,Content,Header,Footer, andLayoutandGridLayoutwith their sub-components) also keep the ones that lay out their content:padding,gap,flow,place*,align*,justify*,grid*,overflowandscrollbar. Rootstylesthat sets only allowed props passes too; tasty()wrappers that restyle a protected component, and wrappers of those wrappers.
On an input, the style props that place or size it act on its outer element: the field wrapper when it renders (see Field properties), the input itself otherwise. Its styles always style the input, so a root styles that only sizes it passes the rule but sizes the input inside the field, not the field. To size the input alone, beside a side label for example, use its controlWidth prop. Input components take no other style props.
A component's own prop that shares a name with a style passes, such as a field's direction and filter, a text field's resize, ColorSwatch's color or AlertDialog's content.
A sub-component counts as the component it hangs off, so Button.Split is protected like Button, and Layout.Content is a container like Layout. The list names the exceptions, such as Button.Group.
To turn it on, extend the package's config and enable the rule:
// tasty.config.ts
export default { extends: '@cube-dev/ui-kit' };An intended override takes a disable comment with its reason on the line before it. Among JSX children, only the {/* … */} form works:
<Space>
{/* oxlint-disable-next-line tasty/no-protected-restyle -- the empty state's call to action takes the brand fill */}
<Button fill="#purple">Get started</Button>
</Space>With this rule on, tasty/no-styles-prop can stay off, so the primitives keep their styles.
The same extends gives the plugin's other rules the names UI Kit defines: its tokens, presets and recipes, and the @dark, @hc and @reduced-motion state aliases. tasty/valid-color-token, tasty/valid-custom-property, tasty/valid-preset, tasty/valid-recipe, tasty/valid-state-key and tasty/no-unknown-state-alias then report only names that don't exist. The tokens include the ones a component's docs offer you, to read inside that component or set on it, such as a dialog's $dialog-padding-h or a layout's $content-padding. A component's other custom properties are internal and can change, so the rules report them too. List what your app defines itself, in its own configure() or tokens, in its own config, which adds to the lists it extends:
// tasty.config.ts
export default {
extends: '@cube-dev/ui-kit',
states: ['@mobile'],
tokens: ['#brand'],
};Reusable style presets applied via the recipe style property. Defined in src/components/Root.tsx.
| Recipe | Purpose |
|---|---|
reset |
Zero out margin, padding, border, outline; set box-sizing: border-box |
button |
Base button appearance: no native styling, touch-action: manipulation |
input |
Base input appearance: no native styling, inherit color |
input-autofill |
Handle browser autofill styling (:autofill, :-webkit-autofill, :-internal-autofill-*) |
input-placeholder |
Placeholder text styling via -webkit-text-fill-color |
input-search-cancel-button |
Hide native search cancel button |
These are data-attribute modifiers used in component style maps (via the mods prop):
| Modifier | Description |
|---|---|
disabled |
Disabled state |
readonly |
Read-only state: the value shows but cannot change |
hovered |
Hover state |
focused |
Focus state |
pressed |
Active/pressed state |
selected |
Selected state |
checked |
Checked state (checkbox, switch) |
invalid |
Invalid validation state |
valid |
Valid validation state |
indeterminate |
Indeterminate state |
collapsed |
Collapsed state |
type=primary |
Primary variant |
type=outline |
Outline variant |
size=small / size=medium / size=large |
Size modifiers |
In style value maps, keys define when styles apply. States can be combined with boolean logic:
fill: {
'': '#surface', // default
':hover': '#surface-hover', // pseudo-class
disabled: '#disabled-surface', // data-attribute modifier
'type=danger': '#danger', // data-attribute with value
'@media(w < 768px)': '...', // media query
'@(panel, w >= 300px)': '...', // container query
'disabled & :hover': '...', // combined with AND
'a | b': '...', // combined with OR
'!disabled': '...', // negated
}<Root> registers a global @reduced-motion state beside @dark and @hc. It matches @media (prefers-reduced-motion: reduce). Use it to stop or shorten motion for users who ask for less:
const Spinner = tasty({
styles: {
animation: 'spin 1s linear infinite',
animationPlayState: { '': 'running', '@reduced-motion': 'paused' },
},
});It follows the system preference alone, with no attribute opt-in.
Icons live in src/icons/. Use existing icons when possible; otherwise use @tabler/icons-react.
<Icon>— wrapper component for sizing and coloring icons. Props:size,stroke, plus style props.wrapIcon(name, icon)— creates a Cube icon component from a tabler icon.- Always wrap tabler icons with
<Icon>for custom size/color. Never add props directly to tabler icons. - To add new icons run:
pnpm add-icons
Illustrations are not icons and do not live in src/icons/: CubeLogo and NoDataIcon are Icon-based components under src/components/other/. They size like an icon, but NoDataIcon is a three-tone drawing on the shared cube-face tokens (#loading-face-1 … #loading-face-3) rather than a currentColor glyph, so it ignores color and belongs at illustration sizes (size="8x" and up).
ItemTable and DataTable opt into native treegrid behavior with getRowChildren. Pass roots in data; the callback returns already-loaded children, and row keys must be globally unique. Expansion is uncontrolled with defaultExpandedKeys or controlled with expandedKeys / onExpand.
The shared table engine recursively sorts siblings, paginates roots with their complete subtrees, and flattens only expanded rows for rendering and virtualization. ItemTable client search preserves ancestor paths and temporarily opens branches containing matches. Server modes do not transform nested data.
Use treeColumnKey to choose the disclosure/indent column. Its hierarchy chrome is customizable through the TreeContent, TreeToggle, and TreeValue sub-elements. ItemTable multiple selection cascades by default; DataTable cell ranges contain visible rows only. See each component's docs for pagination, numbering, selection, and drag/drop limits.
DataTable also accepts nested column definitions for already-shaped pivot results. Group nodes create accessible multi-row headers with native column spans, while sorting, resizing, ranges and copying continue to operate on their leaf columns. Both tables fill an available flex pane by default; use isAutoHeight for a compact result whose frame should end at its last rendered row. Auto-height renders every row, so bounded tables remain the right choice for large results.
UI Kit provides modern and legacy implementations. Use the modern API for new forms. Existing callers remain on the legacy implementation until explicitly migrated.
For a new modern form, create a typed controller, put defaults on the controller, and bind inputs with name. Modern names accept strings and nested tuples; field is an optional typed binding:
const form = Form.useController<{ email: string | null }>({
defaultValues: { email: null },
});
<Form form={form} onSubmit={(values, { signal }) => save(values, signal)}>
<TextInput name="email" label="Email" isRequired />
<Form.SubmitError />
<Form.Submit>Save</Form.Submit>
</Form>Inputs subscribe to their own values and errors. For other reactive UI, use Form.useValue(form, path) for one value, Form.useFieldState(form, path) for field metadata, or a selector for derived state. <Form.Subscribe> keeps an inline region's subscription separate from its parent. Keep imperative getters in handlers and effects.
Modern Form explains which API to choose for server data, nullable values, nested fields, conditional sections, cross-field and remote validation, errors, and external actions. Use that guide for implementation recipes and the migration checklist when converting an existing form.
Form.useForm() and roots without a modern controller keep the legacy backend. DialogForm also supports modern controllers; Form.Item remains legacy-only. See FormInstance (legacy) for legacy instance methods, defaults, and dot notation.
Built-in inputs have field support; they do not need a Field wrapper. Modern name and field bindings, and legacy string name bindings, connect through useFieldProps. Custom controls use that hook and wrapWithField. Field Properties documents shared labels, validation, state, and presentation props.
Fields render their chrome when there is something to show: a label, a form binding, or a description, message, field styles or props, or isHidden, and on TextInput, TextArea, PasswordInput and NumberInput a validate. A field with none of them renders only the control. Use isBare for an inline control with no room for its chrome, or when a parent owns the field layout and feedback; a supplied label remains accessible but visually hidden. isBare={false} renders the chrome anyway. Message content never chooses whether the wrapper exists. errorMessage={null} suppresses error text and its fallback while preserving the description and invalid state.