Skip to main content
TASIOMIND.DEV — OPERATIONAL▸▸▸FULL STACK DEVELOPER @ GWQ SERVICEPLUS AG▸▸▸FOUNDER — K8SGPT.AI▸▸▸OPEN SOURCE: ACTIVE▸▸▸DISTRIBUTED SYSTEMS / KUBERNETES / AI▸▸▸RUST + GO + PYTHON▸▸▸FIELD TESTED / STATUS — NOMINAL▸▸▸LOCATION: EUROPE/BERLIN▸▸▸TASIOMIND.DEV — OPERATIONAL▸▸▸FULL STACK DEVELOPER @ GWQ SERVICEPLUS AG▸▸▸FOUNDER — K8SGPT.AI▸▸▸OPEN SOURCE: ACTIVE▸▸▸DISTRIBUTED SYSTEMS / KUBERNETES / AI▸▸▸RUST + GO + PYTHON▸▸▸FIELD TESTED / STATUS — NOMINAL▸▸▸LOCATION: EUROPE/BERLIN▸▸▸
Engineering

Motion Design for Developers: From Disney Principles to Web Reality

A case study of building production motion systems — Disney's 12 principles translated to Framer Motion, reduced-motion accessibility, and the choreography patterns that make interfaces feel alive.

Aland Baban · August 2026 · 8 min read
[ contents ]

Why Developers Should Care About Motion

Most developers treat animation as decoration. It's not. Motion is information architecture — it tells users where things come from, where they go, and what happens when they interact.

The best motion systems are invisible. You don't notice them working; you notice when they're missing. The jarring pop-in. The instant state change with no transition. The scroll that stops dead instead of coasting.

This article documents the motion system powering this site — built on Framer Motion, guided by Disney's 12 principles, and hardened by accessibility requirements.

The Foundation: Disney's 12 Principles → Web Primitives

Disney's animators codified 12 principles in the 1930s. Here's how they map to modern web implementation:

PrincipleWeb TranslationImplementation
Squash & StretchScale transforms on interactionwhileTap={{ scale: 0.95 }}
AnticipationPre-motion hintsinitial={{ opacity: 0 }} → animate={{ opacity: 1 }} with delay
StagingFocus attention via motionLayout animations, AnimatePresence exit before enter
Straight Ahead vs Pose-to-PoseSpring vs keyframetransition={{ type: "spring" }} vs keyframes
Follow Through & OverlappingStaggered childrentransition={{ staggerChildren: 0.1 }}
Slow In & Slow OutEasing curvesease: [0.25, 0.46, 0.45, 0.94] (custom cubic-bezier)
ArcCurved pathspath in MotionPath or custom springs
Secondary ActionMicro-interactionsHover lifts, focus rings, loading skeletons
TimingDuration calibrated to distanceduration: Math.min(distance / 1000, 0.4)
ExaggerationEmphasis beyond realisticOvershoot springs: damping: 12, stiffness: 100
Solid Drawing3D transformsrotateX, rotateY, perspective in variants
AppealPersonality in motionBrand-specific easing, signature transitions

The key insight: You don't implement all 12. You pick 3-4 that define your motion personality and enforce them system-wide.

Try it live: This site has an interactive Motion Choreography Showcase where you can replay each archetype's exact timing and easing on a shared stage.

Our Motion Personality: "Precise but Warm"

This site uses four archetypes, each with a distinct motion signature:

1. Precision (Default) — Technical, tool-like

  • Easing: cubic-bezier(0.25, 0.46, 0.45, 0.94) — Material's "standard"
  • Duration: 150-250ms
  • No overshoot, no bounce
  • Used for: navigation, data tables, forms

2. Playful — Discovery, easter eggs

  • Easing: cubic-bezier(0.34, 1.56, 0.64, 1) — overshoot
  • Duration: 300-500ms
  • Stagger: 0.08s between children
  • Used for: terminal games, hover reveals, empty states

3. Calm — Reading, long-form content

  • Easing: cubic-bezier(0.4, 0, 0.2, 1) — deceleration only
  • Duration: 400-600ms
  • No stagger, simultaneous
  • Used for: blog posts, research articles, modal entry

4. Urgent — Alerts, errors, destructive actions

  • Easing: cubic-bezier(0.4, 0, 1, 1) — linear-ish
  • Duration: 80-120ms
  • Shake on error, pulse on warning
  • Used for: toasts, validation, delete confirmations

Implementation: The Variant System

code
// src/motion/variants.ts
export const variants = {
  precision: {
    initial: { opacity: 0, y: 8 },
    enter: { opacity: 1, y: 0, transition: { duration: 0.2, ease: easings.precision } },
    exit: { opacity: 0, y: -8, transition: { duration: 0.15, ease: easings.precision } },
  },
  playful: {
    initial: { opacity: 0, scale: 0.9, rotate: -2 },
    enter: { 
      opacity: 1, 
      scale: 1, 
      rotate: 0, 
      transition: { duration: 0.4, ease: easings.playful, staggerChildren: 0.08 } 
    },
    exit: { opacity: 0, scale: 0.95, transition: { duration: 0.2 } },
  },
  calm: {
    initial: { opacity: 0, y: 20 },
    enter: { opacity: 1, y: 0, transition: { duration: 0.5, ease: easings.calm } },
    exit: { opacity: 0, transition: { duration: 0.3 } },
  },
  urgent: {
    initial: { opacity: 0, x: -10 },
    enter: { opacity: 1, x: 0, transition: { duration: 0.1, ease: easings.urgent } },
    exit: { opacity: 0, x: 10, transition: { duration: 0.08 } },
  },
} as const;

Each page declares its personality:

code
// src/app/blog/[slug]/page.tsx
export default function BlogPost({ params }) {
  return (
    <motion.article 
      variants={variants.calm}
      initial="initial"
      animate="enter"
      exit="exit"
    >
      {/* content */}
    </motion.article>
  );
}

Reduced Motion: Not Optional

prefers-reduced-motion is an accessibility requirement, not a nice-to-have. Our implementation:

code
// src/hooks/useReducedMotion.ts
export function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mediaQuery.matches);
    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, []);
  
  return reduced;
}

In variants: Reduced motion gets instant transitions:

code
const reducedVariants = {
  initial: { opacity: 0 },
  enter: { opacity: 1, transition: { duration: 0 } },
  exit: { opacity: 0, transition: { duration: 0 } },
};

Critical: Test with reduced motion ON. If your UI breaks (content overlaps, focus lost, state unclear), your motion was doing structural work — fix the layout, not the animation.

Layout Animations: The Game Changer

Framer Motion's layout prop is the single highest-ROI motion feature. It automatically animates between layout changes — no keyframes needed.

code
// Reordering a list? Just add layout.
<motion.ul layout>
  {items.map(item => (
    <motion.li key={item.id} layout>{item.name}</motion.li>
  ))}
</motion.ul>

// Shared layout for page transitions
<motion.div layoutId="hero" className="hero-image" />
// ...on next page...
<motion.div layoutId="hero" className="hero-image-detail" />

What it handles automatically:

  • Reordering (FLIP technique under the hood)
  • Parent size changes
  • Position shifts from sibling insertion/removal
  • Shared element transitions across routes

Gotcha: layout requires explicit dimensions or Flexbox/Grid. Absolute positioning without size breaks it.

Scroll-Driven Animation: The Modern Way

CSS Scroll-driven Animations (supported in Chrome 115+, Firefox 110+, Safari 17+) replace IntersectionObserver scroll spy:

code
/* src/styles/scroll.css */
@keyframes reveal {
  from { opacity: 0; transform: translateY(40px); }
  to { opacity: 1; transform: translateY(0); }
}

.animate-on-scroll {
  animation: reveal linear;
  animation-timeline: view();
  animation-range: entry 25% cover 50%;
}
code
// React component using CSS-driven scroll animation
<section className="animate-on-scroll" style={{ 
  animationRange: 'entry 25% cover 50%' 
}}>
  <h2>This fades in as you scroll</h2>
</section>

Why CSS over JS: Runs on compositor thread, zero main-thread cost, works during scroll, no hydration mismatch.

Choreography Patterns

Staggered Entrance (The "Breathing" List)

code
<motion.ul
  initial="hidden"
  animate="visible"
  variants={{
    hidden: { opacity: 0 },
    visible: {
      opacity: 1,
      transition: { staggerChildren: 0.06, delayChildren: 0.1 }
    }
  }}
>
  {items.map(item => (
    <motion.li 
      key={item.id}
      variants={{
        hidden: { opacity: 0, x: -20 },
        visible: { opacity: 1, x: 0 }
      }}
    >
      {item.content}
    </motion.li>
  ))}
</motion.ul>

Parent-Child Coordination (The "Accordion")

code
const containerVariants = {
  closed: { height: 0 },
  open: { 
    height: 'auto',
    transition: { staggerChildren: 0.04, delayChildren: 0.1 }
  }
};

const itemVariants = {
  closed: { opacity: 0, y: 10 },
  open: { opacity: 1, y: 0 }
};

<motion.div variants={containerVariants} animate={isOpen ? 'open' : 'closed'}>
  {children.map(child => (
    <motion.div variants={itemVariants}>{child}</motion.div>
  ))}
</motion.div>

Exit Before Enter (The "Swap")

code
<AnimatePresence mode="wait">
  {currentView && (
    <motion.div
      key={currentView}
      initial="enter"
      animate="center"
      exit="exit"
      variants={pageVariants}
    />
  )}
</AnimatePresence>

mode="wait" ensures the exiting component finishes before the new one mounts — critical for layout stability.

Performance: The Budget

Motion budget per frame (60fps = 16.67ms):

OperationBudgetNotes
Layout calculation~2mslayout props, FLIP
Style recalculation~1msTransform/opacity only
Paint~3msPromotion to compositor
Composite~1msGPU upload
Total per animated element~7msMax 2-3 simultaneous

Rules we enforce:

  1. Animate only transform and opacity — these stay on compositor
  2. Max 3 simultaneous layout animations
  3. will-change: transform, opacity on animated elements (remove after)
  4. layout instead of manual keyframes for position changes
  5. CSS scroll animations over JS IntersectionObserver

The Terminal Easter Egg: Motion as Personality

The terminal at /terminal uses motion differently — it's retro motion:

  • Instant state changes (no transitions) — mimics 90s terminal
  • CRT scanlines — CSS animation, 60fps loop
  • Matrix rain — Canvas, requestAnimationFrame
  • Typewriter effectsetInterval per character
  • Cursor blink — CSS @keyframes blink

This breaks our motion system intentionally. The contrast makes the easter egg feel like a different "app" within the site.

What We'd Do Differently

  1. Extract motion tokens earlier — We hardcoded easings in components for too long. A motion-tokens.json consumed by both CSS and Framer Motion would prevent drift.

  2. Document the "why" per variant — New team members pick variants by vibe, not intent. Each variant needs a decision record.

  3. Automated reduced-motion testing — CI should run visual regression with prefers-reduced-motion: reduce and flag layout breaks.

  4. Motion linting — ESLint rule flagging animate on non-transform/opacity properties.

Resources


This motion system evolved over 3 years across 4 redesigns. The principles are stable; the implementation changes. Next: the View Transitions API integration for zero-JS page transitions.

Related: Building the Terminal Part 1 · View Transitions API (upcoming)