-
Notifications
You must be signed in to change notification settings - Fork 27
Animation System
Status: Design exploration. Only basic transition tokens exist today. This spec is the blueprint for the full animation system. Nothing here is implemented yet — it captures the target design for when animation work begins.
Exploration, January 2026
Astryx's core architecture enforces constraints: zero styling, theme-driven tokens, typed APIs. This exploration examines how animation fits into that constraint-based model.
The question isn't "which animation library should we use?" but rather "how does animation integrate with Astryx's philosophy of no arbitrary values, theme as source of truth, and AI-friendly by constraint?"
Assumption: This document assumes Astryx uses StyleX for styling (see Why StyleX). Animation implementation aligns with StyleX's compile-time, zero-runtime philosophy.
Animation is styling's sibling. Same rules apply.
| Astryx Principle | Animation Application |
|---|---|
| Zero-styling: no inline styles, no style props |
Zero animation code: no animate={{}}, no transition props |
| Theme as source of truth | Animation config lives in theme.motion
|
| Props define intent, not style |
open={true} expresses intent; animation is implementation detail |
| Typed, constrained APIs | Animation types are enums ('fade' | 'scale'), not arbitrary values |
| Swizzle for edge cases | Swizzle component to override animation behavior |
| AI-friendly by constraint | AI writes <Dialog open>, knows nothing about animation |
// What developers write:
<Dialog open={isOpen}>
<DialogTitle>Confirm</DialogTitle>
<DialogContent>Are you sure?</DialogContent>
</Dialog>
// Animation just happens (configured in theme)AI can implement animations easily either way, whether it's Motion or StyleX. The question is whether the tradeoffs justify adding a dependency.
| CSS / StyleX | Motion (motion/react) | |
|---|---|---|
| Enter animations | ✅ @starting-style handles it |
✅ initial → animate
|
| Exit animations | ✅ AnimatePresence for free |
|
| Gesture animations | ✅ First-class drag/swipe | |
| Spring physics | ✅ Real springs | |
| Bundle size | ✅ 0KB | |
| Philosophy fit | ✅ Compile-time, no runtime |
The honest assessment:
- For internal tools and standard UI, CSS handles 90% of animation needs
- Exit animations matter less than enter animations. Instant removal is acceptable
- Fancy gesture animations (drag-to-dismiss, spring physics) require more code with CSS, but AI can still write it
Decision: Start with CSS. Avoid the dependency unless we want to go all-in on animations. If exit animations or gestures become critical later, we can adopt Motion for specific components. The public API (<Dialog open>) stays the same either way.
createTheme({
motion: {
// ─────────────────────────────────────────────
// GLOBAL CONTROLS
// ─────────────────────────────────────────────
enabled: true, // Kill switch for all animations
reducedMotion: 'respect', // 'respect' | 'always-reduce' | 'ignore'
// ─────────────────────────────────────────────
// TIMING TOKENS
// ─────────────────────────────────────────────
duration: {
instant: 0,
fast: 100,
normal: 200,
slow: 300,
},
// ─────────────────────────────────────────────
// EASING TOKENS
// ─────────────────────────────────────────────
easing: {
linear: 'linear',
default: 'cubic-bezier(0.4, 0, 0.2, 1)',
easeIn: 'cubic-bezier(0.4, 0, 1, 1)',
easeOut: 'cubic-bezier(0, 0, 0.2, 1)',
easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
},
// ─────────────────────────────────────────────
// PER-COMPONENT DEFAULTS
// ─────────────────────────────────────────────
components: {
dialog: { type: 'fade', duration: 'normal', easing: 'easeOut' },
drawer: { type: 'slide', duration: 'normal', easing: 'easeOut' },
accordion: { type: 'collapse', duration: 'fast', easing: 'easeOut' },
popover: { type: 'fade', duration: 'fast', easing: 'default' },
tooltip: { type: 'fade', duration: 'fast', easing: 'default' },
bottomSheet: { type: 'slide-up', duration: 'normal', easing: 'spring' },
toast: { type: 'slide', duration: 'fast', easing: 'easeOut' },
menu: { type: 'fade', duration: 'fast', easing: 'default' },
...
[typeof Component]: { ... }, // Type-safe config for all components
},
}
})The motion config is fully typed. Each component only accepts animation types that make sense for it:
type DurationToken = 'instant' | 'fast' | 'normal' | 'slow';
type EasingToken = 'linear' | 'default' | 'easeIn' | 'easeOut' | 'easeInOut' | 'spring';
// Each component defines which animation types it supports
type DialogAnimationType = 'fade' | 'scale' | 'none';
type DrawerAnimationType = 'slide' | 'none';
type AccordionAnimationType = 'collapse' | 'none';
// ... etc
// Theme authors get constrained autocomplete
createTheme({
motion: {
components: {
dialog: { type: 'fade' }, // ✅ Valid
dialog: { type: 'slide' }, // ❌ Type error: dialog doesn't support 'slide'
drawer: { type: 'slide' }, // ✅ Valid
accordion: { type: 'collapse' }, // ✅ Valid
},
}
})The theme's motion config generates StyleX variables that components consume internally:
import * as stylex from '@stylexjs/stylex';
// Theme defines motion tokens via defineVars
export const motionTokens = stylex.defineVars({
// Durations
durationInstant: '0ms',
durationFast: '100ms',
durationNormal: '200ms',
durationSlow: '300ms',
// Easings
easingLinear: 'linear',
easingDefault: 'cubic-bezier(0.4, 0, 0.2, 1)',
easingEaseIn: 'cubic-bezier(0.4, 0, 1, 1)',
easingEaseOut: 'cubic-bezier(0, 0, 0.2, 1)',
easingSpring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
});
// Components use tokens in stylex.create()
const dialogStyles = stylex.create({
overlay: {
opacity: 1,
transition: `opacity ${motionTokens.durationNormal} ${motionTokens.easingEaseOut}`,
},
});Snappy theme (power users, internal tools):
const snappyTheme = createTheme({
motion: {
duration: { fast: 50, normal: 100, slow: 150 },
easing: { default: 'linear' },
}
})Smooth theme (consumer-facing):
const smoothTheme = createTheme({
motion: {
duration: { fast: 150, normal: 250, slow: 400 },
easing: { default: 'spring' },
components: {
dialog: { type: 'scale', easing: 'spring' },
bottomSheet: { easing: 'spring' },
},
}
})No animation theme (accessibility, performance, testing):
const noMotionTheme = createTheme({
motion: { enabled: false }
})Animation customization follows the dual-path swizzle model (see Swizzle Ergonomics).
For timing/easing/type changes:
npx xds customize motion --theme=corporateGenerates themes/corporate/motion.ts:
import { extendTheme } from '@xds/core';
export const corporateMotionTheme = extendTheme({
motion: {
duration: { fast: 80, normal: 160, slow: 240 },
easing: { default: 'easeOut', spring: 'cubic-bezier(...)' },
components: {
dialog: { type: 'scale' },
bottomSheet: { easing: 'spring' },
},
}
});AI vibes: ✅ Excellent: structured config, no animation code.
For completely custom animation behavior:
npx xds swizzle DialogFull component source with animation implementation. Yours to modify.
AI vibes:
Components fall into a few animation patterns. Here's how each works with the system.
What animates: Backdrop fades in, content slides/scales in.
How it works: Component reads open prop, applies styles based on data-state="open|closed". StyleX styles use @starting-style for enter animation.
// Internal implementation (user never sees this)
const styles = stylex.create({
overlay: {
opacity: {
default: 1,
'@starting-style': 0,
},
transition: `opacity ${motionTokens.durationNormal} ${motionTokens.easingEaseOut}`,
},
content: {
transform: {
default: 'translateY(0)',
'@starting-style': 'translateY(100%)',
},
transition: `transform ${motionTokens.durationNormal} ${motionTokens.easingEaseOut}`,
},
});Theme config: motion.components.dialog, motion.components.drawer, etc.
What animates: Height from 0 to auto.
How it works: CSS grid trick: grid-template-rows: 0fr to 1fr. No JS measurement needed. See Appendix: CSS Grid Height Trick for implementation.
Theme config: motion.components.accordion, motion.components.collapsible
What animates: Fade in (optionally scale from anchor point).
How it works: Similar to overlays but positioned relative to trigger. Fast timing.
const styles = stylex.create({
popup: {
opacity: {
default: 1,
'@starting-style': 0,
},
transition: `opacity ${motionTokens.durationFast} ${motionTokens.easingDefault}`,
},
});Theme config: motion.components.popover, motion.components.tooltip, motion.components.menu
What animates: Slide in from edge of screen.
How it works: Positioned fixed, slides from off-screen. May need exit animation for dismiss.
const styles = stylex.create({
toast: {
transform: {
default: 'translateX(0)',
'@starting-style': 'translateX(100%)',
},
transition: `transform ${motionTokens.durationFast} ${motionTokens.easingEaseOut}`,
},
});Theme config: motion.components.toast
What animates: Content panel crossfade (optional).
How it works: If enabled, outgoing panel fades out while incoming fades in. Often skipped. Instant switch is fine.
Theme config: motion.components.tabs (default: { type: 'none' })
Handled by default. Astryx respects prefers-reduced-motion automatically. Duration becomes 0ms when the OS preference is set. Developers write no motion-awareness code.
Themes can optionally override via motion.reducedMotion:
-
'respect'(default): honor OS preference -
'always-reduce': force reduced motion -
'ignore': ignore OS preference (accessibility concern)
Not exported. Animation is internal implementation detail. Swizzle is the escape hatch for custom behavior.
- Prototype a few components with StyleX animations to validate this approach works well in practice
- Test
@starting-stylebehavior across browsers (Chrome, Safari, Firefox fallback) - Ensure the developer experience is not too cumbersome for building animated components
- Evaluate if exit animations are truly skippable or if we need the
useAnimatedUnmounthook
- Should exit animations be skipped entirely (instant removal), or should we implement a small
useAnimatedUnmounthook to delay unmount? - Are we definitely using StyleX? (See Why StyleX, this doc assumes yes)
This section documents approaches that were evaluated but not selected.
We chose CSS/StyleX over Motion. Here's the detailed comparison:
| Aspect | CSS / StyleX | Motion |
|---|---|---|
| Bundle size | ~0KB | ~18KB |
| Enter animations | ✅ @starting-style
|
✅ initial → animate
|
| Exit animations | ✅ AnimatePresence
|
|
| Spring physics | ✅ Real springs | |
| Philosophy fit | ✅ Compile-time |
Browser support note: @starting-style works in Chrome 117+, Safari 17.4+. Firefox falls back to no enter animation (content just appears, not broken, just not animated).
StyleX animation example:
import * as stylex from '@stylexjs/stylex';
import { motionTokens } from '@xds/theme';
const styles = stylex.create({
dialog: {
opacity: 1,
transform: 'scale(1)',
transition: `opacity ${motionTokens.durationNormal} ${motionTokens.easingEaseOut}, transform ${motionTokens.durationNormal} ${motionTokens.easingEaseOut}`,
},
});Ship <Fade>, <Slide>, <Collapse> as public components.
import { Fade } from '@xds/motion'
<Fade in={isOpen}>
<Dialog>...</Dialog>
</Fade>| Issue | Impact |
|---|---|
| Wrapper div problem | Primitives wrap children in a <div>, breaks flexbox/grid, CSS selectors, accessibility |
| Cognitive load | Devs must know which primitive goes with which component |
| Inconsistency | Team A uses <Fade>, Team B uses <Scale>, leads to disjointed UX |
| AI complexity | AI must generate animation code, more failure modes |
| Contradicts philosophy | Astryx promises no styling decisions; exposing animation primitives is a styling decision |
-
asChildpattern (Radix-style) to merge into child element -
useAnimationhook for devs who want no wrapper - Render props pattern
All add complexity. If animation is internal, none of this is needed.
Add transition prop to each component:
<Dialog open={isOpen} transition="fade" duration="normal">
...
</Dialog>- Animation decisions scattered across component usage
- Different devs choose different animations, leads to inconsistent UX
- Every component needs animation props
- Still requires animation knowledge from developers
| Library | Size | Exit Animations | Layout Animations | Notes |
|---|---|---|---|---|
| CSS-only | 0KB | ❌ | ❌ | Limited capability |
| Headless UI Transition | ~3KB | ✅ | ❌ | Class-string API, Tailwind-flavored |
| @formkit/auto-animate | ~2KB | ✅ | ✅ | Magic "just works", less control |
| Motion (motion/react) | ~18KB | ✅ | ✅ | Full capability, best DX |
| React Transition Group | ~7KB | ✅ | ❌ | Dated API, class-based |
| View Transitions API | 0KB | ✅ | ✅ | Browser-native, Chrome/Safari only |
Why not Headless UI Transition? Class-string API (enter="transition duration-200") exposes styles, contradicting Astryx philosophy.
Why not View Transitions API? Good for page-level transitions, not component-level. Firefox still behind flag.
| Design System | Animation Approach | Dependency |
|---|---|---|
| Radix UI | CSS-only, data-state attributes for enter (no exit) |
None |
| shadcn/ui | Tailwind animations, optional Framer | CSS or Framer |
| Chakra UI | Built on Framer Motion | Framer (~30KB) |
| Material UI | Internal Transition components | Internal ~5KB |
| Ant Design | rc-motion (internal lib) | Internal ~8KB |
| Headless UI |
<Transition> component, CSS + JS lifecycle |
~3KB |
| Mantine | CSS transitions, optional Framer | CSS or Framer |
Pattern: Most serious design systems either ship CSS-only (limited) or build/use a thin transition layer (3-8KB) for exit animations.
Animating height: auto is historically difficult. The CSS grid trick makes it work without JS measurement:
const styles = stylex.create({
wrapper: {
display: 'grid',
gridTemplateRows: '0fr',
transition: `grid-template-rows ${motionTokens.durationFast} ${motionTokens.easingDefault}`,
},
wrapperOpen: {
gridTemplateRows: '1fr',
},
content: {
overflow: 'hidden',
},
});Works in all modern browsers. See CSS-Tricks: CSS Grid Can Do Auto Height Transitions.
- System Architecture — Core philosophy this animation system extends
- Swizzle Ergonomics — Customization paths referenced for theme/swizzle integration
- Why StyleX — Styling approach decision (this doc assumes StyleX)
- AI and Design Systems — Why constraints matter for AI code generation