← tasteMarkdown

Motion

Condensed from: emilkowalski/skills (https://github.com/emilkowalski/skills): animate, review-animations, improve-animations, find-animation-opportunities, animation-performance, animation-vocabulary, emil-design-eng

1. Gate: should it animate at all?

How often seen Rule
100+/day (shortcuts, ⌘K toggle, tab switch) No animation. Instant.
Tens/day (hover, list nav, row select) Near-imperceptible (<150ms, tiny delta) or nothing
Occasional (modal, drawer, toast, popover) Standard animation
Rare (onboarding, success, empty state, celebration) Delight budget lives here

2. Duration

Element Duration
Press feedback 100-160ms
Tooltip, small popover 125-200ms
Dropdown, select, menu 150-250ms
Modal 200-300ms
Drawer, sheet 300-500ms or spring
Stagger step 30-80ms per item
Marketing, explanatory Longer is fine

UI stays under 300ms unless it is a drawer or marketing. A 180ms dropdown feels faster than a 400ms one.

3. Easing

:root {
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);     /* enter, exit, default */
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* moving/morphing on screen */
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);  /* iOS-like sheet */
}

4. Springs

Use when a pointer/finger drives it, it can be interrupted or reversed, it should feel alive, or it is decorative pointer-tracking.

Use Motion config Apple terms
Default settle { type: "spring", bounce: 0, duration: 0.4 } damping 1.0, response 0.4
After a flick/throw { type: "spring", bounce: 0.2, duration: 0.4 } damping 0.8
Sheet, drawer { type: "spring", bounce: 0.2, duration: 0.3 } + release velocity damping 0.8, response 0.3
Physics form { stiffness: 100, damping: 10, mass: 1 }

Bounce 0.1-0.3 max and only when the gesture carried momentum. A menu that faded in never overshoots. Pointer-follow: useSpring(v, { stiffness: 100, damping: 10 }), not raw mapping.

5. Properties and physicality

6. Enter/exit, interruption, stagger

7. Reduced motion and hover gating (ship with the animation)

@media (prefers-reduced-motion: reduce) {
  .panel { transition: opacity 200ms ease; transform: none; }
}
@media (hover: hover) and (pointer: fine) {
  .card:hover { transform: translateY(-2px); }
}

Reduced means fewer and gentler, not zero: keep opacity/color, drop translate, scale, parallax, overshoot. React: const reduce = useReducedMotion().

8. Perceived performance

9. Vocabulary (prompt with the exact word)

Origin-aware animation · Scale in vs Pop in (overshoot) · Reveal (clip-path/mask) · Crossfade vs Morph vs Shared element transition vs Layout animation · Direction-aware transition · Stagger · Orchestration · Rubber-banding · Momentum · Velocity handoff · Interruptible animation · Swipe to dismiss · Hold to confirm · Text morph · Number ticker + tabular numbers · Skeleton/shimmer · Perceptual duration · Asymmetric easing · Anticipation · Follow-through.

10. Review checklist

Block on: animation on a keyboard/100+/day action · ease-in on UI · scale(0) · transition: all · UI >300ms without reason · centered origin on a trigger-anchored popover · keyframes on toasts/toggles · layout-property animation · Motion shorthands on busy pages · parent CSS var driving child transforms · ungated :hover motion · no reduced-motion · symmetric press/release timing · everything entering at once. Fix order: delete → reduce → easing → origin → interruptible → GPU → asymmetry → polish → a11y. Feel-check at 2-5× duration, frame by frame, on a real device, again next day.

11. Recipes

Button press

.btn { transition: transform 160ms var(--ease-out); }
.btn:active { transform: scale(0.97); }

Tailwind: transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]

Dropdown from its trigger (Base UI)

.menu {
  transform-origin: var(--transform-origin);
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}
.menu[data-starting-style], .menu[data-ending-style] { opacity: 0; transform: scale(0.95); }

Tailwind v4: origin-(--transform-origin) transition duration-200 data-[starting-style]:scale-95 data-[starting-style]:opacity-0 data-[ending-style]:scale-95 data-[ending-style]:opacity-0

Toast enter (prefer Sonner over hand-rolling)

.toast {
  transition: transform 400ms ease, opacity 400ms ease;
  @starting-style { opacity: 0; transform: translateY(100%); }
}

Transitions, not keyframes, so a new toast retargets the stack mid-flight. Exit through the same edge.

Drawer with flick-to-dismiss

.drawer { transition: transform 500ms var(--ease-drawer); }
.drawer[data-closed] { transform: translateY(100%); }
el.style.transform = `translateY(${dy}px)`;           // during drag, on the element itself
const v = Math.abs(dy) / (Date.now() - startTime);   // px/ms
if (dy > THRESHOLD || v > 0.11) close(); else snapBack();

Pointer capture on start, ignore extra touches, rubber-band past the top edge.

Skeleton shimmer done right

.skel { position: relative; overflow: hidden; background: var(--gray-3); border-radius: inherit; }
.skel::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, rgb(255 255 255 / .35), transparent);
  transform: translateX(-100%);
  animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) { .skel::after { animation: none; } }

Shimmer moves via transform (composite only), not background-position (repaints). Same box sizes as the loaded content so nothing shifts.

Number ticker

import NumberFlow from "@number-flow/react";
<NumberFlow value={total} format={{ style: "currency", currency: "USD" }} />

Always font-variant-numeric: tabular-nums (Tailwind tabular-nums). Never re-render text per frame through state.

Height auto (accordion)

<AnimatePresence initial={false}>
  {open && (
    <motion.div
      initial={{ height: 0, opacity: 0 }}
      animate={{ height: "auto", opacity: 1 }}
      exit={{ height: 0, opacity: 0 }}
      transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
      style={{ overflow: "hidden" }}
    />
  )}
</AnimatePresence>

CSS-only: display: grid; grid-template-rows: 0fr → 1fr with transition: grid-template-rows 200ms var(--ease-out) and min-height: 0 on the child, or use the primitive's measured var (--radix-accordion-content-height). Keep it short: height costs layout every frame.

Hold to confirm

.fill { clip-path: inset(0 100% 0 0); transition: clip-path 200ms var(--ease-out); }
.hold:active .fill { clip-path: inset(0 0 0 0); transition: clip-path 2s linear; }

Read by your agent through get_guide("motion") in the taste MCP.