Motion¶
Animation is a quiet feedback channel. We use it to make transitions feel intentional, never to entertain.
Easing¶
A single curve covers ~95% of UI motion:
cubic-bezier(0.4, 0, 0.2, 1)
It's the standard "ease-in-out" — fast in the middle, slow at both ends. Reads as confident, not bouncy.
Reserve other curves for specific cases:
| Use | Curve | Why |
|---|---|---|
| Default UI transitions | cubic-bezier(0.4, 0, 0.2, 1) |
Symmetric ease, feels neutral and intentional |
| Entrance (mount) animations | cubic-bezier(0, 0, 0.2, 1) |
Ease-out — accelerates in, settles |
| Exit (dismiss) animations | cubic-bezier(0.4, 0, 1, 1) |
Ease-in — settles in place, accelerates out |
Springy / playful (rare — only VoxelSlot and onboarding) |
spring(damping: 12, stiffness: 200) |
Reserved for brand-moment surfaces, never product chrome |
Duration¶
Three named durations.
| Token | ms | Use |
|---|---|---|
fast |
150 | Hover/press states, focus rings, instant feedback |
base |
240 | Most transitions — fades, slides, height changes |
slow |
360 | Page-level transitions, sheet present/dismiss, modal in/out |
No transition should exceed 400ms. If it needs to be longer, it's either a marketing animation (different rules apply) or the wrong solution.
Common patterns¶
Fade-in on mount¶
FadeInView wraps content and fades from opacity: 0 to 1 over base (240ms) with the entrance ease curve. Use for first-mount of content that arrives async.
Press feedback¶
Buttons darken their background by ~5% on press, with a fast (150ms) transition. No scale-down, no haptic on web. On native, a single Haptics.selectionAsync() fires on press-in.
Sheet present/dismiss¶
Bottom sheets slide up from the bottom over slow (360ms) with the entrance ease. Dismiss runs the inverse with the exit ease. The backdrop fades from 0 to 40% black over the same duration.
Hover (web only)¶
On web, interactive elements respond to hover with a fast background shift to the next surface tier (e.g. surface → surfaceContainerLow). No transforms, no glow.
Reduced motion¶
Always respect the user's OS-level setting. On native, check AccessibilityInfo.isReduceMotionEnabled(). On web, check prefers-reduced-motion: reduce. When reduce-motion is on:
- All non-functional animation (fades, slides) becomes instant.
- Spring animations become linear ease.
- Parallax / scroll-reveal effects disable entirely.
Wrap any custom animation in a useReducedMotion() hook (in frontend/hooks/useReducedMotion.ts — when added) rather than scattering the check across components.
Things to avoid¶
- Stagger animations on lists. "Each item slides in 50ms after the last" feels showy and slows perceived load time. Render synchronously, fade the container.
- Infinite spinners as primary loading state. Use skeleton screens (matching final layout) or progress bars when duration is knowable. Spinners only for sub-second indeterminate waits.
- Bounce on errors. Don't shake form fields. Show the error message in
errorcolor with afastfade-in. - Easter-egg animations. No confetti, no "you did it!" cascades. Tomoda's voice doesn't celebrate at the user.
Where motion is defined¶
Currently inline in components via Animated.timing / Animated.spring (React Native) and transition: ... CSS (web). When a motion token (duration or easing) gets reused enough to warrant centralization, add it to a frontend/constants/Motion.ts file and update this page in the same PR.