Skip to content

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