Files
stack/packages/mosaic/framework/skills/ui-animation/examples.md
T
fargo 1a822493ba format: apply repo prettier (3.8.1) to the folded skills tree
963 markdown files reformatted with the repository's pinned prettier so
pnpm format:check covers the folded tree like every other repo file.

The formatter's embedded-language pass also normalized code fences
(TS semicolons, closed HTML tags in examples, lowercased CSS hex colors,
one renumbered list that skipped an index). Alphanumeric token deltas vs
the fold commit were audited file-by-file; all are formatter-equivalent
markup normalizations plus the four sanitized skills.
2026-08-19 14:37:17 -05:00

5.1 KiB

UI Animation Examples

Snippets and tips for the core rules in SKILL.md.

Table of contents

Enter and exit

/* Toast.module.css */
.toast {
  transform: translate3d(0, 6px, 0);
  opacity: 0;
  transition:
    transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.toast[data-open='true'] {
  transform: translate3d(0, 0, 0);
  opacity: 1;
}

/* Disable transitions during theme switch */
[data-theme-switching='true'] * {
  transition: none !important;
}
// app/components/Panel.tsx
'use client';
import { motion, useReducedMotion } from 'framer-motion';

export function Panel() {
  const reduceMotion = useReducedMotion();

  return (
    <motion.div
      whileHover={reduceMotion ? undefined : { scale: 1.02 }}
      whileTap={reduceMotion ? undefined : { scale: 0.98 }}
      transition={reduceMotion ? { duration: 0 } : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
    />
  );
}

Spatial rules and stagger

/* Menu.module.css */
.menu {
  transform-origin: top right;
  transform: scale(0.88);
  opacity: 0;
  transition:
    transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.menu[data-open='true'] {
  transform: scale(1);
  opacity: 1;
}

.list > * {
  animation: fade-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.list > *:nth-child(2) {
  animation-delay: 50ms;
}
.list > *:nth-child(3) {
  animation-delay: 100ms;
}
const listVariants = {
  show: { transition: { staggerChildren: 0.05 } },
};

Drawer (move easing)

.drawer {
  transition: transform 240ms cubic-bezier(0.25, 1, 0.5, 1);
}
<motion.aside
  initial={{ transform: 'translate3d(100%, 0, 0)' }}
  animate={{ transform: 'translate3d(0, 0, 0)' }}
  exit={{ transform: 'translate3d(100%, 0, 0)' }}
  transition={{ duration: 0.24, ease: [0.25, 1, 0.5, 1] }}
/>

Hover transitions

/* Link.module.css */
@media (hover: hover) and (pointer: fine) {
  .link {
    transition:
      color 200ms ease,
      opacity 200ms ease;
  }
  .link:hover {
    opacity: 0.8;
  }
}

Reduced motion

@media (prefers-reduced-motion: reduce) {
  .menu,
  .toast {
    transform: none;
    transition: none;
  }
}
'use client';
import { motion, useReducedMotion } from 'framer-motion';

export function AnimatedCard() {
  const reduceMotion = useReducedMotion();
  return (
    <motion.div
      animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
      initial={reduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.98 }}
    />
  );
}

Origin-aware animations

.popover[data-side='top'] {
  transform-origin: bottom center;
}
.popover[data-side='bottom'] {
  transform-origin: top center;
}
.popover[data-side='left'] {
  transform-origin: center right;
}
.popover[data-side='right'] {
  transform-origin: center left;
}

Performance recipes

Pause looping animations off-screen

// app/hooks/usePauseOffscreen.ts
'use client';
import { useEffect, useRef } from 'react';

export function usePauseOffscreen<T extends HTMLElement>() {
  const ref = useRef<T | null>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      el.style.animationPlayState = entry.isIntersecting ? 'running' : 'paused';
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return ref;
}

Toggle will-change during animation

.animating {
  will-change: transform, opacity;
}

Spring defaults (framer-motion)

<motion.div
  animate={{ transform: 'translate3d(0, 0, 0)' }}
  transition={{ type: 'spring', stiffness: 500, damping: 40 }}
/>

Practical tips

Record your animations

When something feels off, record the animation and play it back frame by frame.

Fix shaky 1px shifts

Elements can shift by 1px at the start/end of CSS transforms due to GPU/CPU handoff. Apply will-change: transform during the animation (not permanently) to keep compositing on the GPU.

Scale buttons on press

button:active {
  transform: scale(0.97);
  opacity: 0.9;
}

Avoid animating from scale(0)

.element {
  transform: scale(0.95);
  opacity: 0;
}
.element.visible {
  transform: scale(1);
  opacity: 1;
}

Skip animation on subsequent tooltips

.tooltip {
  transition:
    transform 125ms ease-out,
    opacity 125ms ease-out;
  transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
  opacity: 0;
  transform: scale(0.97);
}
.tooltip[data-instant] {
  transition-duration: 0ms;
}

Fix hover flicker

Apply the hover effect on a parent, animate the child:

.box:hover .box-inner {
  transform: translateY(-20%);
}
.box-inner {
  transition: transform 200ms ease;
}