Files
singular-particular-space/skills/controlled-ux-designer/MOTION-SPEC.md
JL Kruger 5422131782 Initial commit — Singular Particular Space v1
Homepage (site/index.html): integration-v14 promoted, Writings section
integrated with 33 pieces clustered by type (stories/essays/miscellany),
Writings welcome lightbox, content frame at 98% opacity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 12:09:22 +02:00

11 KiB

Motion Specification Template

Detailed animation specifications for consistent motion design across projects.

Easing Curves

Standard Easings

Ease-out (Entrances)

cubic-bezier(0.0, 0.0, 0.2, 1)

Use for: Elements entering view, expanding, appearing

Ease-in (Exits)

cubic-bezier(0.4, 0.0, 1, 1)

Use for: Elements leaving view, collapsing, disappearing

Ease-in-out (Transitions)

cubic-bezier(0.4, 0.0, 0.2, 1)

Use for: State changes, transformations, element swaps

Linear (Continuous)

linear

Use for: Loading spinners, continuous animations, marquee scrolls

Custom Easings

Spring (Bouncy)

cubic-bezier(0.68, -0.55, 0.265, 1.55)

Use for: Playful interactions, game-like UIs, attention-grabbing

Sharp (Quick snap)

cubic-bezier(0.4, 0.0, 0.6, 1)

Use for: Mechanical interactions, precise movements

Duration Tables

By Interaction Type

Interaction Duration Easing Example
Button press 100ms ease-out Background color change
Hover state 150ms ease-out Underline appearing
Checkbox toggle 150ms ease-out Checkmark animation
Tooltip appear 200ms ease-out Tooltip fade in
Tab switch 250ms ease-in-out Content swap
Accordion expand 300ms ease-out Height animation
Modal open 300ms ease-out Fade + scale up
Modal close 250ms ease-in Fade + scale down
Page transition 400ms ease-in-out Route change
Sheet slide-in 300ms ease-out Bottom sheet
Toast notification 300ms ease-out Slide in from top

By Element Weight

Element Weight Duration Example
Lightweight (< 100px) 150ms Icons, badges, chips
Standard (100-500px) 300ms Cards, panels, list items
Weighty (> 500px) 500ms Modals, full-page transitions

State-Specific Animations

Hover States

Button Hover:

// Tailwind
<button className="
  bg-blue-600 hover:bg-blue-700
  transition-colors duration-150 ease-out
">
  Hover Me
</button>

// Framer Motion
<motion.button
  whileHover={{ scale: 1.02 }}
  transition={{ duration: 0.15, ease: "easeOut" }}
>
  Hover Me
</motion.button>

Link Hover:

<a className="
  underline-offset-4
  hover:underline
  transition-all duration-200 ease-out
">
  Link Text
</a>

Card Hover:

<div className="
  transition-all duration-200 ease-out
  hover:shadow-lg
  hover:scale-[1.02]
">
  Card Content
</div>

Focus States

Keyboard Focus:

<button className="
  focus:outline-none
  focus:ring-4 focus:ring-blue-500
  focus:ring-offset-2
  transition-all duration-200 ease-out
">
  Focus Me
</button>

Input Focus:

<input className="
  border-2 border-slate-300
  focus:border-blue-500
  focus:ring-4 focus:ring-blue-200
  transition-all duration-200 ease-out
" />

Active/Pressed States

Button Press:

<motion.button
  whileTap={{ scale: 0.98 }}
  transition={{ duration: 0.1, ease: "easeIn" }}
>
  Press Me
</motion.button>

// CSS alternative
<button className="
  active:scale-98
  transition-transform duration-100 ease-in
">
  Press Me
</button>

Disabled States

Disabled Button:

<button
  disabled
  className="
    bg-slate-400 text-slate-600
    opacity-50 cursor-not-allowed
    pointer-events-none
  "
>
  Disabled
</button>

Loading States

Loading Spinner:

<div className="
  w-8 h-8 border-4 border-slate-300
  border-t-blue-600 rounded-full
  animate-spin
">
</div>

// CSS
@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.spinner {
  animation: spin 1s linear infinite;
}

Skeleton Loader:

<div className="animate-pulse space-y-4">
  <div className="h-4 bg-slate-200 rounded w-3/4"></div>
  <div className="h-4 bg-slate-200 rounded w-1/2"></div>
</div>

// CSS
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

.animate-pulse {
  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

Success Feedback

Checkmark Animation:

<motion.div
  initial={{ opacity: 0, scale: 0.5 }}
  animate={{ opacity: 1, scale: 1 }}
  transition={{ duration: 0.3, ease: "easeOut" }}
>
  <CheckCircle className="text-green-600" size={48} />
</motion.div>

Toast Notification:

<motion.div
  initial={{ y: -100, opacity: 0 }}
  animate={{ y: 0, opacity: 1 }}
  exit={{ y: -100, opacity: 0 }}
  transition={{ duration: 0.3, ease: "easeOut" }}
  className="bg-green-600 text-white p-4 rounded-lg"
>
  Success! Changes saved.
</motion.div>

Error Feedback

Shake Animation:

<motion.div
  animate={{ x: [0, -4, 4, -4, 4, 0] }}
  transition={{ duration: 0.3, ease: "easeInOut" }}
  className="border-2 border-red-500"
>
  <input type="text" />
</motion.div>

// CSS alternative
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  20%, 60% { transform: translateX(-4px); }
  40%, 80% { transform: translateX(4px); }
}

.shake {
  animation: shake 0.3s ease-in-out;
}

Error Message Slide-in:

<motion.div
  initial={{ height: 0, opacity: 0 }}
  animate={{ height: "auto", opacity: 1 }}
  exit={{ height: 0, opacity: 0 }}
  transition={{ duration: 0.2, ease: "easeOut" }}
  className="text-red-600 text-sm"
>
  Please enter a valid email address
</motion.div>

Warning Feedback

Pulse Animation:

<motion.div
  animate={{ scale: [1, 1.05, 1] }}
  transition={{
    duration: 0.6,
    ease: "easeInOut",
    repeat: Infinity
  }}
  className="border-2 border-amber-500"
>
  Warning Content
</motion.div>

Form Validation

Field Validation (On Blur):

// Validate on blur, not during typing
<input
  onBlur={(e) => {
    const isValid = validateEmail(e.target.value);
    setError(!isValid);
  }}
  className={`
    border-2 transition-all duration-200 ease-out
    ${error
      ? 'border-red-500 focus:ring-red-200'
      : 'border-slate-300 focus:ring-blue-200'
    }
  `}
/>

{error && (
  <motion.p
    initial={{ opacity: 0, y: -10 }}
    animate={{ opacity: 1, y: 0 }}
    className="text-red-600 text-sm mt-1"
  >
    Please enter a valid email
  </motion.p>
)}

Common Animation Patterns

Fade In

// Framer Motion
<motion.div
  initial={{ opacity: 0 }}
  animate={{ opacity: 1 }}
  transition={{ duration: 0.3, ease: "easeOut" }}
>
  Content
</motion.div>

// CSS
.fade-in {
  animation: fadeIn 0.3s ease-out;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

Slide Up

// Framer Motion
<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.3, ease: "easeOut" }}
>
  Content
</motion.div>

// CSS
.slide-up {
  animation: slideUp 0.3s ease-out;
}

@keyframes slideUp {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Scale + Fade (Modal)

// Framer Motion
<motion.div
  initial={{ opacity: 0, scale: 0.95 }}
  animate={{ opacity: 1, scale: 1 }}
  exit={{ opacity: 0, scale: 0.95 }}
  transition={{ duration: 0.3, ease: "easeOut" }}
>
  Modal content
</motion.div>

Stagger Children

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

Performance Checklist

  • Only animate transform and opacity
  • Avoid animating width, height, top, left, margin, padding
  • Test on mobile devices (target 60fps)
  • Use will-change only for complex animations
  • Implement prefers-reduced-motion media query
  • Keep animation duration under 500ms for UI interactions
  • Use CSS animations for simple transitions (better performance)
  • Use JS animation libraries for complex, choreographed sequences

Accessibility

/* Disable or reduce animations for users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Implementation in React:

import { useReducedMotion } from 'framer-motion';

function MyComponent() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        duration: shouldReduceMotion ? 0.01 : 0.3,
        ease: "easeOut"
      }}
    >
      Content
    </motion.div>
  );
}

Testing Animations

  1. Test at 60fps on target devices
  2. Test with slow network (does page still feel responsive?)
  3. Test with reduced motion preferences enabled
  4. Verify animations don't block critical user actions
  5. Check that animations add value (remove if purely decorative)
  6. Test on low-end devices (not just your development machine)
  7. Measure performance with Chrome DevTools Performance tab
  8. Check for layout thrashing (avoid reading and writing to DOM in same frame)

Animation & Gestalt Principles

Proximity

Animated elements that are near each other should move together to reinforce grouping:

// Animate card and its children together
<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
>
  <h3>Title</h3>
  <p>Content</p>
  <button>Action</button>
</motion.div>

Similarity

Similar elements should have similar animation characteristics:

// All buttons use same hover animation
const buttonAnimation = {
  whileHover: { scale: 1.02 },
  transition: { duration: 0.15, ease: "easeOut" }
};

<motion.button {...buttonAnimation}>Button 1</motion.button>
<motion.button {...buttonAnimation}>Button 2</motion.button>

Continuity

Movement should follow natural, smooth paths:

// Smooth curve, not jumpy angles
<motion.div
  animate={{ x: [0, 50, 100], y: [0, -25, 0] }}
  transition={{ duration: 1, ease: "easeInOut" }}
/>

Figure-Ground

Important elements animate while backgrounds stay stable:

// Background fades out, modal animates in
<>
  <motion.div
    initial={{ opacity: 0 }}
    animate={{ opacity: 0.5 }}
    className="fixed inset-0 bg-black"
  />
  <motion.div
    initial={{ opacity: 0, scale: 0.95 }}
    animate={{ opacity: 1, scale: 1 }}
    className="fixed inset-0 flex items-center justify-center"
  >
    Modal Content
  </motion.div>
</>

Resources