DOMAIN:CREATIVE:UI_ANIMATION_AND_MOTION_TOKENS¶
OWNER: ilian (Motion Designer) UPDATED: 2026-03-28 SCOPE: Motion token systems, UI animation patterns, micro-interactions, page transitions, performance ALSO_USED_BY: alexander (Creative Director — token approval), floris (Team Alfa frontend — implementation), floor (Team Bravo frontend — implementation), martijn (Team Alfa iOS — SwiftUI), valentin (Team Bravo iOS — SwiftUI), felice (Visual Asset Producer — Lottie/Remotion) ALSO_CHECK: domains/creative/motion-design-principles.md (theory, easing, 12 principles), domains/creative/motion-accessibility.md (reduced motion), domains/frontend/performance.md (rendering budget)
MOTION_TOKENS¶
WHAT_ARE_MOTION_TOKENS¶
DEFINITION: Named, reusable values for animation properties — duration, easing, delay, stagger — that enforce brand consistency across platforms (web CSS, React/Framer Motion, SwiftUI, Jetpack Compose).
ANALOGY: Color tokens define what --color-primary means. Motion tokens define what --motion-duration-fast means.
RULE: Every animation in every GE client project MUST use motion tokens. No magic numbers.
DURATION_TOKENS¶
/* CSS Custom Properties — duration scale */
:root {
--motion-duration-instant: 50ms; /* State changes: hover, focus ring */
--motion-duration-fast: 100ms; /* Micro: tooltip, checkbox, icon swap */
--motion-duration-normal: 200ms; /* Standard: dropdown, accordion, tab switch */
--motion-duration-moderate: 300ms; /* Transition: modal open, panel slide */
--motion-duration-slow: 400ms; /* Complex: page transition, shared element */
--motion-duration-slower: 500ms; /* Brand: staggered reveal, onboarding */
--motion-duration-slowest: 700ms; /* Cinematic: splash, brand moment (rare) */
}
RULE: instant through moderate cover 90% of UI needs. slow and above need justification.
RULE: If a new design calls for a duration not in this scale, DISCUSS before adding — drift kills consistency.
EASING_TOKENS¶
/* CSS Custom Properties — easing presets */
:root {
--motion-ease-linear: cubic-bezier(0, 0, 1, 1);
--motion-ease-out: cubic-bezier(0, 0, 0.2, 1); /* ENTER viewport */
--motion-ease-in: cubic-bezier(0.4, 0, 1, 1); /* EXIT viewport */
--motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); /* WITHIN viewport */
--motion-ease-emphasized: cubic-bezier(0.4, 0, 0, 1); /* Large transitions */
--motion-ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1); /* Playful brands */
}
// Framer Motion easing presets (React)
export const easing = {
easeOut: [0, 0, 0.2, 1],
easeIn: [0.4, 0, 1, 1],
easeInOut: [0.4, 0, 0.2, 1],
emphasized: [0.4, 0, 0, 1],
overshoot: [0.34, 1.56, 0.64, 1],
} as const;
// Framer Motion spring presets
export const spring = {
gentle: { type: "spring", stiffness: 120, damping: 14 },
snappy: { type: "spring", stiffness: 400, damping: 30 },
bouncy: { type: "spring", stiffness: 300, damping: 10 },
} as const;
// SwiftUI easing presets
extension Animation {
static let geEaseOut = Animation.timingCurve(0, 0, 0.2, 1)
static let geEaseIn = Animation.timingCurve(0.4, 0, 1, 1)
static let geEaseInOut = Animation.timingCurve(0.4, 0, 0.2, 1)
static let geEmphasized = Animation.timingCurve(0.4, 0, 0, 1)
static let geSpring = Animation.spring(response: 0.4, dampingFraction: 0.7)
}
STAGGER_TOKENS¶
:root {
--motion-stagger-fast: 30ms; /* Tight list (5-10 items) */
--motion-stagger-normal: 50ms; /* Standard list, card grid */
--motion-stagger-slow: 80ms; /* Emphasized reveal, hero section */
}
RULE: Total stagger sequence must not exceed 600ms. Formula: stagger_delay * item_count < 600ms.
RULE: If item_count > 12, use stagger-fast (30ms) or stop staggering after the 8th item.
TOKEN_NAMING_CONVENTION¶
--motion-{property}-{scale}
property: duration | ease | stagger | delay
scale: instant | fast | normal | moderate | slow | slower | slowest
RULE: Platform-specific implementations (CSS, SwiftUI, Compose) MUST use the same semantic names. RULE: Token values can differ per platform (mobile durations 10-20% shorter) but names stay the same.
MATERIAL_DESIGN_MOTION_SYSTEM¶
SOURCE: Material Design 3 (material.io/design/motion)
KEY_CONCEPTS¶
DURATION_THRESHOLDS: - Short 1: 50ms (state layer opacity) - Short 2: 100ms (selection controls) - Short 3: 150ms (small area animations) - Short 4: 200ms (medium area animations) - Medium 1: 250ms - Medium 2: 300ms (medium to large area) - Medium 3: 350ms - Medium 4: 400ms (large area) - Long 1-4: 450-700ms (extra large, full screen)
EASING:
- Standard: cubic-bezier(0.2, 0, 0, 1) — most transitions
- Standard decelerate: cubic-bezier(0, 0, 0, 1) — entering elements
- Standard accelerate: cubic-bezier(0.3, 0, 1, 1) — exiting elements
- Emphasized: cubic-bezier(0.2, 0, 0, 1) — larger transitions
- Emphasized decelerate: cubic-bezier(0.05, 0.7, 0.1, 1) — entering with emphasis
- Emphasized accelerate: cubic-bezier(0.3, 0, 0.8, 0.15) — exiting with emphasis
CONTAINER_TRANSFORM: When navigating between two screens that share a container element (e.g., card to detail), the container morphs smoothly (position, size, shape, elevation).
SHARED_AXIS: Pages that have a spatial or sequential relationship (tabs, stepper) transition along a shared axis (X for horizontal navigation, Y for vertical, Z for depth).
FADE_THROUGH: When navigating between unrelated screens. Outgoing fades out, then incoming fades in. No spatial relationship implied.
APPLE_HIG_MOTION¶
SOURCE: Apple Human Interface Guidelines — Motion (developer.apple.com)
KEY_PRINCIPLES¶
FLUIDITY: Animations should feel physically grounded. Use spring animations for interactive elements. CONTINUITY: Maintain context during transitions. Use matched geometry for elements that persist across screens. FEEDBACK: Every touch should produce an immediate visual response (<100ms).
SWIFTUI_ANIMATION_PATTERNS¶
// Matched geometry transition (shared element)
@Namespace var animationNamespace
// In list view
ForEach(items) { item in
CardView(item: item)
.matchedGeometryEffect(id: item.id, in: animationNamespace)
.onTapGesture { selectedItem = item }
}
// In detail view
if let item = selectedItem {
DetailView(item: item)
.matchedGeometryEffect(id: item.id, in: animationNamespace)
}
// Interactive spring (preferred for iOS)
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
isExpanded.toggle()
}
// Phase animator (multi-step)
PhaseAnimator([false, true]) { phase in
Image(systemName: "checkmark.circle.fill")
.scaleEffect(phase ? 1.2 : 0.8)
.opacity(phase ? 1 : 0.5)
}
RULE: iOS native apps MUST use spring animations for interactive elements. Bezier curves feel wrong on iOS.
RULE: SwiftUI withAnimation is the standard pattern. Never use UIView.animate in SwiftUI code.
MEANINGFUL_TRANSITIONS¶
SHARED_ELEMENT_TRANSITION¶
USE_WHEN: User taps an element that exists on both screens (card → detail, thumbnail → full image). EFFECT: The shared element morphs (position, size, shape) while the rest cross-fades. WHY: Maintains spatial context. User understands where they came from and can go back.
// Framer Motion — layout animation (shared element)
import { motion, LayoutGroup } from "framer-motion";
function CardGrid({ items, onSelect }) {
return (
<LayoutGroup>
{items.map((item) => (
<motion.div
key={item.id}
layoutId={`card-${item.id}`}
onClick={() => onSelect(item)}
className="rounded-lg bg-card p-4"
>
<motion.img layoutId={`img-${item.id}`} src={item.image} />
<motion.h3 layoutId={`title-${item.id}`}>{item.title}</motion.h3>
</motion.div>
))}
</LayoutGroup>
);
}
FADE_TRANSITION¶
USE_WHEN: No spatial relationship between pages. Or as reduced-motion fallback. DURATION: 150-200ms (fade out) + 150-200ms (fade in). Can overlap for cross-fade.
// Next.js page transition with Framer Motion
<AnimatePresence mode="wait">
<motion.div
key={router.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.2, ease: easing.easeOut } }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: easing.easeIn } }}
>
{children}
</motion.div>
</AnimatePresence>
SLIDE_TRANSITION¶
USE_WHEN: Sequential or spatial navigation (wizard steps, tab bar, horizontal scroll). DIRECTION: Slide LEFT when moving FORWARD in sequence. Slide RIGHT when moving BACKWARD.
// Direction-aware slide
const direction = nextIndex > currentIndex ? 1 : -1;
<motion.div
key={currentIndex}
initial={{ x: direction * 100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: direction * -100, opacity: 0 }}
transition={{ duration: 0.3, ease: easing.easeInOut }}
/>
SCALE_TRANSITION¶
USE_WHEN: Element appears from a point of origin (FAB menu, context menu, popover). PATTERN: Scale from 0.85 to 1.0 with fade. Transform-origin set to the trigger point.
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.2, ease: easing.easeOut }}
style={{ transformOrigin: "top right" }}
/>
MICRO_INTERACTIONS¶
BUTTON_FEEDBACK¶
// Framer Motion button with press feedback
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
transition={spring.snappy}
>
Submit
</motion.button>
/* CSS-only button feedback */
.button {
transition: transform var(--motion-duration-instant) var(--motion-ease-out);
}
.button:hover {
transform: scale(1.02);
}
.button:active {
transform: scale(0.97);
transition-duration: var(--motion-duration-instant);
}
RULE: Tap/press feedback must be instant (<100ms). Users expect immediate physical response. RULE: Scale changes should be subtle (0.95-1.05 range). Larger values look cartoonish.
TOGGLE_SWITCH¶
// Animated toggle with Framer Motion
function Toggle({ isOn, onToggle }) {
return (
<button
onClick={onToggle}
className={`w-12 h-6 rounded-full p-1 ${isOn ? "bg-primary" : "bg-muted"}`}
role="switch"
aria-checked={isOn}
>
<motion.div
className="w-4 h-4 rounded-full bg-white"
layout
transition={spring.snappy}
/>
</button>
);
}
LOADING_STATES¶
SKELETON_SCREENS: Preferred over spinners for content loading. Show the shape of the expected content.
// Skeleton with shimmer animation
function Skeleton({ className }) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-muted",
className
)}
/>
);
}
// Usage: matches the layout of the real content
<div className="space-y-3">
<Skeleton className="h-8 w-3/4" /> {/* Title */}
<Skeleton className="h-4 w-full" /> {/* Line 1 */}
<Skeleton className="h-4 w-5/6" /> {/* Line 2 */}
</div>
SPINNER: Use only when content shape is unknown or for actions (form submit, file upload). PROGRESS_BAR: Use when duration is known or estimable.
RULE: If loading takes <200ms, show nothing (no flash of skeleton). Use a delay before showing loading state. ANTI_PATTERN: Showing a spinner for 50ms then the content — the flash is worse than no indicator.
NOTIFICATION_TOAST¶
// Toast entrance: slide + fade from edge
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.25, ease: easing.easeOut }}
role="status"
aria-live="polite"
/>
FORM_VALIDATION¶
// Error message entrance — slide down + fade
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: easing.easeOut }}
role="alert"
className="text-sm text-destructive"
>
{error}
</motion.p>
)}
</AnimatePresence>
CHECKBOX_AND_RADIO¶
/* Checkbox check animation */
.checkbox-indicator {
transform: scale(0);
transition: transform var(--motion-duration-fast) var(--motion-ease-out);
}
.checkbox[data-state="checked"] .checkbox-indicator {
transform: scale(1);
}
PAGE_TRANSITIONS¶
STRATEGY¶
RULE: Page transitions in GE projects use one of three patterns. Pick per project, stay consistent.
| Pattern | When | Duration | Easing |
|---|---|---|---|
| Fade | Default, unrelated pages | 200-300ms | ease-out (in), ease-in (out) |
| Shared element | Card→detail, list→item | 300-400ms | emphasized ease-in-out |
| Directional slide | Sequential (wizard, tabs) | 250-350ms | ease-in-out |
RULE: Full-page transitions are OPTIONAL. If the app is fast enough (sub-200ms route change), no transition is better than a forced animation. ANTI_PATTERN: Adding transitions to compensate for slow navigation. Fix the performance instead.
NEXT_JS_INTEGRATION¶
// app/template.tsx — page transition wrapper
"use client";
import { motion } from "framer-motion";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0, 0, 0.2, 1] }}
>
{children}
</motion.div>
);
}
NOTE: template.tsx re-mounts on every navigation (unlike layout.tsx). This is the correct file for page transitions in Next.js App Router.
PERFORMANCE_BUDGET¶
THE_60FPS_TARGET¶
MATH: 60fps = 16.67ms per frame. Animation logic + paint + composite must fit within 16ms.
RULE: Only animate transform and opacity. These properties are composited on the GPU and skip layout/paint.
SAFE_TO_ANIMATE (compositor-only):
- transform: translate(), scale(), rotate()
- opacity
- filter (on GPU-composited layers)
EXPENSIVE_TO_ANIMATE (triggers layout or paint):
- width, height — triggers layout recalculation
- top, left, right, bottom — triggers layout
- margin, padding — triggers layout
- border-radius (changing) — triggers paint
- box-shadow (changing) — triggers paint
- background-color — triggers paint
/* GOOD: GPU-composited, no layout/paint */
.card-enter {
transform: translateY(20px);
opacity: 0;
transition: transform 200ms var(--motion-ease-out),
opacity 200ms var(--motion-ease-out);
}
.card-enter-active {
transform: translateY(0);
opacity: 1;
}
/* BAD: triggers layout on every frame */
.card-enter {
margin-top: 20px;
height: 0;
transition: margin-top 200ms, height 200ms;
}
WILL_CHANGE¶
/* Hint browser to promote element to own compositor layer BEFORE animation starts */
.element-about-to-animate {
will-change: transform, opacity;
}
/* REMOVE after animation completes — holding will-change wastes GPU memory */
.element-idle {
will-change: auto;
}
RULE: Apply will-change just before animation, remove after. Do NOT set globally.
ANTI_PATTERN: * { will-change: transform; } — promotes every element to GPU layer, exhausts memory.
RULE: Maximum 10-15 promoted layers per page. Each layer consumes GPU memory proportional to its pixel area.
FRAMER_MOTION_PERFORMANCE¶
// Use layout animations carefully — they measure DOM and can cause jank
// For lists with many items, disable layout animation:
<motion.div layout={false}>
{/* Static positioned content */}
</motion.div>
// For exit animations, use mode="popLayout" to avoid layout shift:
<AnimatePresence mode="popLayout">
{items.map(item => (
<motion.div
key={item.id}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
/>
))}
</AnimatePresence>
RULE: layout prop in Framer Motion reads the DOM (getBoundingClientRect). On lists with >20 items, this causes measurable jank.
RULE: Prefer CSS transitions for simple hover/focus states. Reserve Framer Motion for complex orchestrated animation.
SWIFTUI_PERFORMANCE¶
// Use .drawingGroup() to flatten complex animated views into a single GPU layer
ComplexAnimatedView()
.drawingGroup()
// Avoid animating GeometryReader — it triggers expensive layout passes
// Instead, use .matchedGeometryEffect for cross-view transitions
// Prefer .animation(.spring(), value: specificValue) over .animation(.spring())
// The latter animates ALL changes, the former only animates when specificValue changes
MEASURING_ANIMATION_PERFORMANCE¶
WEB:
- Chrome DevTools → Performance tab → record during animation → check for frames >16ms
- Chrome DevTools → Rendering → Paint Flashing (green = repaint, should be minimal)
- Chrome DevTools → Rendering → Layer Borders (orange = composited layers)
- PerformanceObserver API for programmatic measurement
IOS:
- Xcode → Debug → Color Blended Layers
- Instruments → Core Animation → monitor FPS and GPU utilization
- CADisplayLink for programmatic frame timing
BUDGET: - Main thread work per frame: <8ms (leave 8ms for system) - Compositor-only animations: unlimited (GPU handles them) - Simultaneous animated elements: <10 (above this, test on low-end devices)
INTEGRATION_WITH_DESIGN_SYSTEMS¶
TOKEN_EXPORT_PIPELINE¶
Figma (motion specs) → Style Dictionary → Platform tokens
├── CSS (custom properties)
├── TypeScript (Framer Motion config)
├── Swift (Animation extensions)
└── Kotlin (Compose specs)
TOOL: Style Dictionary (amazon-style-dictionary) transforms token JSON into platform code.
TOKEN_JSON_FORMAT¶
{
"motion": {
"duration": {
"instant": { "value": "50ms", "type": "duration" },
"fast": { "value": "100ms", "type": "duration" },
"normal": { "value": "200ms", "type": "duration" },
"moderate":{ "value": "300ms", "type": "duration" },
"slow": { "value": "400ms", "type": "duration" }
},
"easing": {
"ease-out": { "value": "cubic-bezier(0, 0, 0.2, 1)", "type": "cubicBezier" },
"ease-in": { "value": "cubic-bezier(0.4, 0, 1, 1)", "type": "cubicBezier" },
"ease-in-out": { "value": "cubic-bezier(0.4, 0, 0.2, 1)", "type": "cubicBezier" }
},
"stagger": {
"fast": { "value": "30ms", "type": "duration" },
"normal": { "value": "50ms", "type": "duration" }
}
}
}
CHECKLIST — MOTION_TOKEN_SYSTEM_SETUP¶
FOR_EVERY_NEW_CLIENT_PROJECT: - [ ] Define duration scale (5-7 steps) based on brand speed - [ ] Define easing presets (3-4 curves) based on brand personality - [ ] Define stagger presets (2-3 values) - [ ] Document spring config if brand uses playful/interactive motion - [ ] Export tokens to all target platforms - [ ] Create sample animations for each token combination - [ ] Validate with reduced-motion alternatives - [ ] Test on lowest-spec target device at 60fps
ANTI_PATTERNS¶
| Anti-Pattern | Problem | Fix |
|---|---|---|
Hardcoded durations (200ms) |
Inconsistency when brand evolves | Use var(--motion-duration-normal) |
| Mixing easing styles in one project | Disjointed, unprofessional feel | Pick 3-4 curves, tokenize, enforce |
| Framer Motion for hover effects | Bundle bloat, unnecessary JS for CSS job | Use CSS :hover transitions |
animate-pulse on interactive elements |
Implies loading, confuses users | Only use pulse on skeleton placeholders |
| Exit animation longer than enter | Feels like the app is fighting the user | Exit should be equal or shorter than enter |
| No loading delay | Flash of skeleton for fast loads | Add 200ms delay before showing loader |
layout on large lists |
DOM measurement causes jank | Limit to <20 items or disable |
| Spring with very low damping | Bounces forever, user cannot interact | Damping >10 for functional UI |
REFERENCES¶
- Material Design 3 Motion — material.io/design/motion
- Apple HIG Motion — developer.apple.com/design/human-interface-guidelines/motion
- Framer Motion Documentation — framer.com/motion
- Style Dictionary — amzn.github.io/style-dictionary
- Google Web Fundamentals: Animations — web.dev/animations-guide
- FLIP Technique (Paul Lewis) — aerotwist.com/blog/flip-your-animations
- High Performance Animations — web.dev/animations-overview