Accepted 2026-10-31 by the owner: “all accepted merege and proceed” (design-system session c6,
STA-1371). Earlier the owner approved the plan: “we need to create our own theme styles … I need
just to make sure that we are 100% setting variables, all control from a single thing”, then “yes
go ahead with the full list, and all recommendations”. Once accepted it amends
0302 (the token schema),
0329 (motion steps become a
placeholder family owned by the motion work) and
0340
(decision 7: the DTCG export covers every family).
Context
A conformance study against HeroUI (2026-10-31,stackra-notes/design-system-research-2026-10-31/) found that a
theme could not reach every visual value: components drew Tailwind shadow-sm, duration-300, z-50, text-[10px]
and numeric native styles; HeroUI’s own radius ladder leaked 10px and 12px corners past our roles; a field stood 38px
beside a 36px button; there was no density, control-height, elevation, z-index or type-scale token; and the segment
colour reached no component. A preset could only fix that with a CSS layer of overrides (the Medusa and Linear
presets carry one), which is code, not configuration. The owner asked for one schema that sets every value, a
HeroUI-style preset beside the Stackra look, and checks that keep it so.
Decision
1. One schema
Theme(@stackra/tokens/contracts) is the only source of visual values.defineThemevalidates it; three generators read it:renderWebCss(CSS custom properties, HeroUI’s names where HeroUI has one),renderNativeCssplusnativeTokens(theme)(Uniwind variables and px numbers for React Native), andthemeToDtcg(W3C DTCG).- The schema’s blocks, each a kebab-case-keyed record unless typed:
- Every block left out of a stored layer keeps the base theme’s value (
mergeTheme), so a v1 layer still loads.TOKEN_SET_VERSIONmoves only when a path is renamed or removed, never for an addition.
2. Families and names
Web names are CSS custom properties on:root (mode-independent) or in the light and dark blocks (anything that
depends on a colour). Native keys are paths in nativeTokens(theme), numbers in px or ms; colours reach native as
the Uniwind variables of the same web name.
3. Rules every component and preset follow
- A component reads a family’s variable (or, on native, its
nativeTokenskey), never a literal of that family. Data colours (social marks, swatches, a picker’s default) and lengths relative to the font (1em) are not theme values. - A HeroUI class whose radius, height or shadow Stackra does not own is mapped onto a role in
renderWebCss, so no HeroUI-derived value reaches the page unmapped. - A preset is a theme layer (
stackra.theme-diff/2) and needs no CSS: if a preset cannot be said in tokens, the schema grows. Existing CSS layers (Medusa, Linear) are allowed until their values move into tokens. - In dark mode depth comes from the surface lightness ladder; a dark shadow is subtle or none.
- The stock Stackra theme’s look is the default; a preset is chosen, never implied.
4. Presets, nested themes and switching
- Presets ship as layers: Stackra (
STACKRA_THEME, in@stackra/tokens), the brands (ADR 0340: layers above Stackra, outside@stackra/tokens), the inspired-by presets (Medusa, Linear) and Stackra’s own style presets (HeroUI-style, tactile, glass, brutal; tokens only, no CSS layer) in the playground and the mobile app. The HeroUI-style preset is written fresh from measurements; no HeroUI Pro code or CSS is copied (ADR 0469); free HeroUI (MIT) values may be referenced. - A nested theme is
renderWebCss(theme, { scope: "<selector>" }): every block is written under the scope, so one section wears another theme. Colour-derived values sit in the mode blocks, so they recompute inside the scope. - Switching replaces the theme stylesheet’s text (
applyBrandTheme) or the native theme store’s variables; no reload. checkThemeruns the contrast pairs on every preset in its test, and a preset that fails a minimum does not ship.
5. A theme is data
- A theme a person builds is a
ThemeDocument(@stackra/tokens/contracts):format(stackra.theme-document/1), a kebab-caseid, alabel, thebasetheme’s name, thetokenSet, astatus(draftorpublished) and thechangesover the base (thestackra.theme-diff/2changes). The built-in presets (Stackra, HeroUI-style, the brands) are seed documents of the same shape. parseThemeDocument(input, base)validates a document and builds its theme;createThemeDocument(base, theme, frame)writes one. A document is saved, named, previewed, published and applied at runtime with no rebuild.- Applying is a function on each platform, which the appearance settings call: on the web
applyThemeDocument(document, { base?, scope? })(@stackra/ui/react) validates the document and replaces one stylesheet’s text; on nativeapplyNativeTheme(document, base?)(@stackra/ui/native) validates it, pushes the theme’s variables to Uniwind and returns the theme withnativeTokens(theme). - Storage, selection, the allowed presets and the tenant lock are appearance settings
(
feat/settings-foundation);@stackra/tokensand@stackra/uitake no settings dependency. - The Theme Studio (the theme builder) edits a document; each value carries a lock that Randomize respects,
Randomize draws within each knob’s range and the contrast pairs, Inspect opens the controls of a pressed part of
the preview, undo and redo step the history, the device frames preview native, and a colour-recipes section
shows how each derived colour is mixed. It exposes every family as a control: colour roles and
surfaces, radius, borders, density and sizes, typography and the text scale, elevation, the state layer, layers,
and the motion and material families once their owners fill them. Its output is a document (and the DTCG export);
it imports a document or a DTCG tree (
dtcgToThemeDiff).
6. DTCG export
themeToDtcg writes every family it can express: colours (by mode), dimensions (px, rem; calc(var(--spacing) * n)
resolved against layout.spacing), durations, numbers (opacity, z-index, state shares), cubicBezier easings,
shadow composites and typography composites. A value with no DTCG form (var(), a percentage, em) is left out,
as before.
7. Lint rules
The oxlintstackra plugin (@stackra/coding-standard) fails the build on a literal of a token family in
apps/** and packages/design-system/** (tests exempt), each in the style of stackra/no-literal-radius:
| Rule | Refuses | Passes |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| stackra/no-literal-radius (exists) | rounded-* literals, numeric border*Radius | rounded-[var(--radius-*)], rounded-none, rounded-[inherit] |
| stackra/no-literal-shadow | shadow, shadow-xs … shadow-2xl, shadow-inner, shadow-[<literal>], numeric shadowRadius, shadowOpacity, elevation | shadow-surface, shadow-overlay, shadow-field, shadow-none, shadow-(--*), shadow-[var(--*)] |
| stackra/no-literal-duration | duration-<n>, delay-<n>, duration-[<literal>], ease-[<literal>], a cubic-bezier( in text, a number above 0 in a timing name (duration, delay, *Duration, *Delay, *Ms, *_MS) as a property, a default or a .duration(n) call, a numeric stiffness, damping or mass, a string or number-list easing/ease property, Easing.bezier(<numbers>) | duration-0, duration-(--*), ease-(--*), Tailwind’s keyword easings, motionTiming(...), motionSpring(...), motionEasing(...) |
| stackra/no-literal-z-index | z-<n>, numeric zIndex | z-(--z-*), z-auto |
| stackra/no-literal-length | arbitrary [<n>px | rem]utilities, numericfontSize, lineHeight, letterSpacing, borderWidth | -(--*), [var(--*)], Tailwind’s scale steps (they read --spacing), em |
8. Motion, haptics and accessibility modes
Accepted 2026-10-31 with the rest of this ADR (motion work, STA-1371). This section fills the
motion placeholder; the values are the Stackra theme’s defaults, and every one is a theme value
a preset or Theme Studio can change.- Duration scale.
Theme.motionkeeps its record of steps, each a CSS duration and easing, rendered as--duration-<step>and--ease-<step>and read in script withmotionTiming(theme, step). The scale isinstant100ms,fast150ms,base250ms,slow300ms andslower450ms. Named roles sit beside it:moderate200ms (a small move),indicator250ms (a selection sliding),reveal400ms (an entrance),count900ms (a number rolling),pulse1200ms (a looping cue),blink(a caret) andstagger40ms (the delay between items). Overlays get their own pair, measured on HeroUI (2026-10-31):overlay-in250ms andoverlay-out200ms (drawer, sheet, modal) andtoast-in350ms andtoast-out300ms, on the fluid curvecubic-bezier(0.32, 0.72, 0, 1). A sliding selection indicator playsindicator. - Easings.
Theme.animation.easings, each a CSS named easing orcubic-bezier(), so Motion and Reanimated play the curve CSS plays:standardcubic-bezier(0.2, 0, 0, 1)(a move within the screen),emphasizedcubic-bezier(0.32, 0.72, 0, 1)(a sheet, a large surface),entercubic-bezier(0.22, 1, 0.36, 1)(arriving, decelerating) andexitcubic-bezier(0.4, 0, 1, 1)(leaving, accelerating). Web:--ease-<name>, and Tailwind’sease-in,ease-outandease-in-outreadexit,enterandstandard. Script:motionEasing(theme, name)gives the control points (Easing.bezier(...points)in Reanimated,ease: pointsin Motion). - Springs.
Theme.animation.springs, each{ stiffness, damping, mass }:gentle120, 20, 1 (a large surface settling),snappy260, 22, 0.8 (a control, a thumb, a drag letting go) andbouncy220, 16, 1 (a playful indicator). The HeroUI Pro study adds measured presets:follow180, 28, 1 (a pointer-following tilt),settle120, 22, 1 (a return to rest with no overshoot, flat to the eye at about 550ms),flip60, 12, 1.2 (a heavy card turn with a small rebound) andmorph300, 25, 0.8 (a FAB or morph button). The material work adds Apple’s two (ADR 0320 as amended 2026-10-31):default322, 36, 1 (damping ratio 1.0, response 0.35s: popovers, menus, toasts, tab content) andsheet439, 35.6, 1 (0.85, 0.3s: sheets and drawers).motionSpring(theme, name)is the config both libraries take:withSpring(to, spring)in Reanimated,{ type: "spring", ...spring }in Motion. The web stylesheet samples each into a CSSlinear()easing,--spring-<name>, with the time it takes to settle,--spring-<name>-duration(springCurve), so a CSS transition plays the same physics. - Stagger and press. Items revealed in a row wait
staggereach. A pressed control shrinks toanimation.pressScale(0.97;--press-scale) on thefaststep and springs back onsnappy. - Patterns. Enter: fade in and rise
animation.enterDistance(8px;--enter-distance) onrevealorbasewithenter. Exit: fade out onfastwithexit; an exit is shorter than its entrance. Layout: a moving or resizing element springs onsnappy(web: Motionlayoutwith the spring; native: Reanimated layout transitions with the spring). Lists reveal as one block (ADR 0329 decision 4). - Page and view transitions. Web:
ViewStackplays its moves through the View Transitions API (document.startViewTransition), falling back to animating the entering view; a push slides from the reading end onrevealwithenter. Native:ViewStackand the navigators map the same transitions to Reanimated entering and exiting animations and to the native stack animation (nativeStackAnimationOf). - Theme switch. A scheme, brand or material switch plays the provider’s
transitionpreset (circle-origin,fade,none) through the View Transitions API onslowerwithstandard; under reduced motion the preset becomes a fade on the same step, and with no View Transitions API ornonethe change is instant. - One intensity knob.
DesignSystemProvider’smotionisfull,reducedornone(off).fullis lowered toreducedbyprefers-reduced-motionon the web and iOS Reduce Motion (Android remove animations) on native.reducedkeeps fades and drops movement:--press-scalebecomes 1 and--enter-distance0px, Motion’sMotionConfigdrops transforms, Reanimated’sReducedMotionConfigdrops layout movement and components swap a slide for a fade.nonesets every duration, spring duration and transition to 0, so end events still fire. The system setting alone drops the CSS durations to 0ms (the conservative reading of the media query). - Haptics (native).
Theme.hapticsmaps each kind a control plays to anexpo-hapticspattern:selection(a changed choice) toselection,lightandimpact(a press) toimpact-light,mediumtoimpact-medium,rigid(a bound) toimpact-rigid, andsuccess,warning,errorto the notification patterns;nonesilences a kind.expo-hapticsis an optional peer of@stackra/ui. The on/off preference is the provider’shapticsprop anduseHaptic().setEnabled, for an app’s settings screen. - Accessibility modes are theme modes the web stylesheet generates; each follows its system setting and the
provider forces it with
accessibility(an attribute on<html>):
9. Materials
Accepted 2026-10-31 with the rest of this ADR (material work, STA-1371). This section fills the
materials placeholder. The owner’s five decisions on glass, Android and visionOS are recorded in
0320 as amended 2026-10-31; this
section is the schema they need.- Levels.
MaterialLevelisultra-thin,thin,regular,thick,glassandglass-clear, thinnest first and then the two glass variants. EachMaterialLevelTokenkeepsfill,blurandsaturationand gains optional fields, so a theme written before them still validates:tintSource(surface,accent,overlay,none) andtint(0 to 0.24),brightness,edgeandrim(overriding the theme-wideedgeandhighlight),specular(a share of the rim),innerDepth,elevation(none,raised,floating,overlay),dim(glass-clearonly) andvibrancy(the label roles the level allows). Web:--material-<level>-*and.material-<level>. - Roles and families.
MaterialRolenames where a material is used:nav-bar,tab-bar,sidebar,toolbar,sheet,modal,popover,menu,tooltip,toast,command,card,action-bar,fab,segmented-track,search-in-barandmedia-controls.materials.family(frontorstudio) picks one of the two maps inMATERIAL_FAMILY_ROLES, andmaterials.rolesoverrides single roles per theme. Each role is a level orsolid, and a component asks for its role, never a level: web.material-role-<role>reading--material-role-<role>-*, nativeuseMaterialRole(role)andGlassSurface.<html data-family>switches the map at runtime; its blocks come after:rootso the attribute wins.
- Default mode.
materials.defaultModeis whatDesignSystemProviderapplies when it is given nomaterial; the provider no longer hard-codessolid. The Stackra theme isstudioandsolid, so product UI changes only when a theme or an app opts in. - Legibility. Overlays that carry muted text (popover, menu, sheet, command, modal) keep their role’s glass
look but draw the
thickfill, the only level where muted text holds 4.5:1 (ADR 0320 decision 4).glass-clearnever dims below 0.50 under glyphs and 0.55 under labels; the web renderer clamps to those floors (GLASS_CLEAR_DIM_FLOORS, also innativeTokens), and the contrast test proves them. - Fallbacks. Reduced transparency drops the rim and specular on top of the solid mode; more contrast drops the
tint, raises every edge to 0.45 and puts a 0.70 plate under
glass-clear. Each follows its system setting and the provider’sdata-transparencyanddata-contrast(section 8). Without a backdrop filterglass-cleardims 0.15 more; forced colours draw aCanvasTextedge on every material class and role. - Other families.
shadows.floatingjoins the elevations;iconStrokemaps a font weight (100 to 900) to an icon stroke, so icons match the text beside them.nativeTokenscarries the families, the role maps and the dim floors. - Presets. Stackra Glass (
front,glass), Stackra Studio (studio,frosted), HeroUI-style (studio,solid) and Apple-style (front,liquid) ship as tokens only, with no CSS layer. - Future native targets. visionOS and watchOS are planned targets, each with its own ADR (ADR 0320 decision
12). The schema keeps what they need expressible: depth levels beyond the four elevations, ornaments as a role,
and a small-screen type scale as a
typeScalevariant. No change here may make any of them unrepresentable.
Consequences
- One edit to a theme (or a layer) reaches every component on web and native; Theme Studio’s controls are the schema.
- The HeroUI-style preset proves the schema: it needs no CSS layer.
- Components get longer class names (
z-(--z-overlay)), and a new component must pick a family’s token or add one. - The motion and material agents extend
motionandmaterialson this schema; neither renames a family here. - A component that wants glass asks for a material role.
stackra/no-backdrop-filterrefuses a backdrop filter,expo-blurorexpo-glass-effectanywhere else (ADR 0320 decision 11). - A component’s motion is retimed from the theme: changing
snappyorbasemoves every control that plays it, on both platforms, and a component that needs a timing the scale lacks adds a step rather than a literal. - Tailwind breakpoints are build-time:
--breakpoint-*change JS and native reads at runtime, and media queries only when a build regenerates its@theme.
Enforcement
packages/design-system/tokens/src/core/functions/define-theme.function.tsvalidates every family (grammars, ranges, duplicate property names); its tests and therender-web-css/render-native-cssfile snapshots hold the output.packages/developer/coding-standard/src/core/constants/no-literal-*-rule.constant.ts, on inpackages/tooling/oxlint/src/base.jsoncforapps/**andpackages/design-system/**.motionEasing,motionSpring,springCurveand the modes stylesheet:motion-family.function.test.tsandrender-modes-css.function.test.tsin@stackra/tokens; the haptic patterns in@stackra/ui’s haptics test; the accessibility attributes in the webDesignSystemProvidertest.- The material:
render-material-css.function.test.ts(levels, roles, family blocks after:root, fallbacks under the system settings and the attributes) andstackra-theme.constant.test.ts(the dim floors and the thick fill under muted text);no-backdrop-filter-rule.constant.tsin@stackra/coding-standard, on inbase.jsonc. checkThemecontrast pairs per preset: the presets’ test inapps/playground/src/constants/brand-themes/__tests__.- Visual regression: planned — a Playwright screenshot per preset and mode in
apps/playground/e2e. - Generated tokens docs page: planned — a playground route that lists
themeVariables(theme)for the picked preset.
Supersedes and sources
Supersedes: none. Amends 0302 (schema), 0329 (motion becomes a placeholder family), 0340 decision 7 (DTCG coverage).stackra-notes/design-system-research-2026-10-31/heroui-design-language.mdandheroui-conformance-gaps.md.stackra-notes/design-system-research-2026-10-31/token-audit.md(the hard-coded values this ADR retires).- Free HeroUI v3 (MIT)
@heroui/stylesvariables, for reference values only.