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.
271 lines
5.1 KiB
Markdown
271 lines
5.1 KiB
Markdown
# UI Animation Examples
|
|
|
|
Snippets and tips for the core rules in `SKILL.md`.
|
|
|
|
## Table of contents
|
|
|
|
- [Enter and exit](#enter-and-exit)
|
|
- [Spatial rules and stagger](#spatial-rules-and-stagger)
|
|
- [Drawer (move easing)](#drawer-move-easing)
|
|
- [Hover transitions](#hover-transitions)
|
|
- [Reduced motion](#reduced-motion)
|
|
- [Origin-aware animations](#origin-aware-animations)
|
|
- [Performance recipes](#performance-recipes)
|
|
- [Practical tips](#practical-tips)
|
|
|
|
## Enter and exit
|
|
|
|
```css
|
|
/* 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;
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```css
|
|
/* 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;
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
const listVariants = {
|
|
show: { transition: { staggerChildren: 0.05 } },
|
|
};
|
|
```
|
|
|
|
## Drawer (move easing)
|
|
|
|
```css
|
|
.drawer {
|
|
transition: transform 240ms cubic-bezier(0.25, 1, 0.5, 1);
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
<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
|
|
|
|
```css
|
|
/* Link.module.css */
|
|
@media (hover: hover) and (pointer: fine) {
|
|
.link {
|
|
transition:
|
|
color 200ms ease,
|
|
opacity 200ms ease;
|
|
}
|
|
.link:hover {
|
|
opacity: 0.8;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Reduced motion
|
|
|
|
```css
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.menu,
|
|
.toast {
|
|
transform: none;
|
|
transition: none;
|
|
}
|
|
}
|
|
```
|
|
|
|
```tsx
|
|
'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
|
|
|
|
```css
|
|
.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
|
|
|
|
```ts
|
|
// 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
|
|
|
|
```css
|
|
.animating {
|
|
will-change: transform, opacity;
|
|
}
|
|
```
|
|
|
|
### Spring defaults (framer-motion)
|
|
|
|
```tsx
|
|
<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
|
|
|
|
```css
|
|
button:active {
|
|
transform: scale(0.97);
|
|
opacity: 0.9;
|
|
}
|
|
```
|
|
|
|
### Avoid animating from scale(0)
|
|
|
|
```css
|
|
.element {
|
|
transform: scale(0.95);
|
|
opacity: 0;
|
|
}
|
|
.element.visible {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
```
|
|
|
|
### Skip animation on subsequent tooltips
|
|
|
|
```css
|
|
.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:
|
|
|
|
```css
|
|
.box:hover .box-inner {
|
|
transform: translateY(-20%);
|
|
}
|
|
.box-inner {
|
|
transition: transform 200ms ease;
|
|
}
|
|
```
|