Web Animations skill for Claude Code
Transform and opacity, interruptible, reduced-motion safe. Claude picks the lightest tool that does the job — a CSS transition, @starting-style for dialogs, the View Transitions API for route changes — before adding Motion or GSAP. It animates only transform and opacity, keeps UI durations short, makes every effect respect prefers-reduced-motion, and proves smoothness with a throttled DevTools Performance recording.
- name
web-animations - size238 lines · ≈2,250 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Implements smooth, accessible web animation with CSS transitions and keyframes, @starting-style entry effects, the View Transitions API (same-document and cross-document), scroll-driven animations, the Web Animations API, Motion for React, and GSAP — animating only compositor-friendly properties, honoring prefers-reduced-motion, and verifying 60fps in DevTools. Use when adding transitions, page or route transitions, modal and list enter/exit effects, scroll effects, or when an animation janks or ignores reduced-motion settings. Try asking
- Add a smooth page transition between the product list and detail page
- This accordion animation stutters on mobile — fix it
- Animate the modal in and out without a library, and respect reduced motion
Use it when
- Adding enter and exit effects to dialogs, menus, toasts or lists
- Building page or shared-element transitions
- An animation drops frames or causes layout shifts
- Animations ignore the user's reduced-motion setting
Skip it when
- You are deciding what should move and the motion language itself — use ui-motion
- The animation is a rendered video or motion graphic, not live UI
How to check the result
A Performance recording at 4x CPU throttle shows no long frames or per-frame layout and paint, and emulating prefers-reduced-motion removes or softens all movement.
The common mistake it prevents
Animating height, top or box-shadow, which forces layout or paint every frame and stutters on mid-range phones even when it looks fine on a laptop.
An animation is done when it runs on the compositor (transform, opacity), finishes in
a duration the user does not wait on, can be interrupted without jumping, degrades to
an instant or fade change under prefers-reduced-motion, and shows no dropped frames in
a Performance recording on a throttled CPU.
Pick the tool
| Need | Use |
|---|---|
| Hover, focus, state changes on one element | CSS transition |
| Looping or multi-step decorative motion | CSS @keyframes |
Element entering from display: none (dialog, popover, toast) | @starting-style + transition-behavior: allow-discrete |
| Page/route change, list reorder, shared-element morph | View Transitions API |
| Progress tied to scroll position | CSS scroll-driven animations (animation-timeline), with a fallback |
| Imperative control, dynamic values, promises | Web Animations API (element.animate) |
| React layout animations, exit animations, gestures | Motion (motion/react) |
| Complex timelines, scroll storytelling, SVG/text effects | GSAP (+ ScrollTrigger) |
Prefer the platform first; add a library only for exit animations in React, layout
(FLIP) animations, or complex sequencing. Purposeful choice of what to animate and
durations/easing as a design system belongs to ui-motion.
Performance rules (60fps = 16.7 ms per frame; 8.3 ms at 120 Hz)
- Animate
transformandopacity.filterandclip-pathare usually acceptable; measure first. - Never animate
width,height,top,left,margin,padding, orbox-shadowon large or many elements; they trigger layout or paint every frame. Usetransform: scale/translate, a pseudo-element’s opacity for shadows, or FLIP. - Transition named properties, never
transition: all— it animates unexpected properties and triggers layout. will-change: transformonly on elements about to animate, removed afterwards; every promoted layer costs GPU memory.- Do not read layout (
offsetHeight,getBoundingClientRect) and write styles in the same loop; batch reads then writes.
Durations and easing defaults
| Interaction | Duration | Easing |
|---|---|---|
| Hover/press feedback | 100–150 ms | ease-out |
| Small element enter (tooltip, menu) | 150–200 ms | cubic-bezier(0.2, 0, 0, 1) |
| Exit | ~70% of enter duration | ease-in |
| Modal/drawer | 200–300 ms | decelerate on enter |
| Page transition | 250–400 ms | — |
Anything over 500 ms for UI feedback feels slow; never block input during an animation.
CSS patterns
.button { transition: background-color 120ms ease-out, transform 120ms ease-out; }
.button:active { transform: scale(0.97); }
/* Dialog / popover enter + exit including display and top-layer */
dialog {
opacity: 0;
transform: translateY(8px);
transition: opacity 200ms, transform 200ms, overlay 200ms allow-discrete, display 200ms allow-discrete;
}
dialog[open] { opacity: 1; transform: none; }
@starting-style {
dialog[open] { opacity: 0; transform: translateY(8px); }
}
/* Expand to auto height without measuring: grid row trick (works everywhere) */
.accordion-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 250ms ease; }
.accordion-panel[data-open="true"] { grid-template-rows: 1fr; }
.accordion-panel > .inner { overflow: hidden; }
interpolate-size: allow-keywords animates to height: auto directly but is not in all
browsers; treat it as progressive enhancement.
Reduced motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
That global reset is a safety net. Better: write motion inside
@media (prefers-reduced-motion: no-preference) { ... } so the default is still, and
replace large movement with a short opacity fade rather than removing feedback
entirely. Parallax, zooms, auto-playing carousels, and scroll-jacking must stop
completely. Anything moving for more than 5 s needs a pause control (WCAG 2.2.2).
JS check: matchMedia("(prefers-reduced-motion: reduce)").matches, and listen for
change events.
View Transitions
Same-document (SPA state change):
function update(mutate) {
if (!document.startViewTransition || matchMedia("(prefers-reduced-motion: reduce)").matches) {
mutate();
return;
}
document.startViewTransition(() => mutate()); // mutate may return a promise
}
.product-thumb { view-transition-name: var(--vt-name); } /* set unique name per item, e.g. style="--vt-name: p-42" */
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 250ms; }
::view-transition-group(*) { animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }
Cross-document (MPA, same origin), no JavaScript:
@view-transition { navigation: auto; }
Rules:
view-transition-namemust be unique among rendered elements at capture time; duplicates abort the transition.- During a transition the page is non-interactive and rendered as snapshots — keep transitions short.
- Feature-detect and fall back to an instant update; support varies by browser for cross-document transitions.
- Frameworks: Astro (
<ClientRouter />or the CSS rule), SvelteKit (onNavigate+startViewTransition), Next.js and React have experimental integrations — check the installed version’s docs before using them.
Scroll-driven animations
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
}
@keyframes reveal { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }
Content must be fully visible without the animation (the @supports wrapper ensures
unsupported browsers show it). Use IntersectionObserver + a class toggle as the JS
fallback, not scroll listeners.
Web Animations API
const anim = el.animate(
[{ transform: "translateX(0)" }, { transform: "translateX(240px)" }],
{ duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "forwards" },
);
await anim.finished;
anim.commitStyles(); // persist final state into inline style
anim.cancel(); // release the fill so it does not override later CSS
Motion (React)
import { motion, AnimatePresence, MotionConfig } from "motion/react";
<MotionConfig reducedMotion="user">
<AnimatePresence initial={false}>
{items.map((item) => (
<motion.li
key={item.id}
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
/>
))}
</AnimatePresence>
</MotionConfig>
- Package is
motion(formerlyframer-motion); import frommotion/react. - Exit animations require
AnimatePresenceand stablekeys on direct children. reducedMotion="user"disables transform/layout animations when the OS asks.
GSAP
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
gsap.from(".feature", { y: 40, opacity: 0, stagger: 0.08, scrollTrigger: { trigger: ".features", start: "top 80%" } });
return () => {}; // cleanup runs automatically when the query stops matching
});
In React use useGSAP() from @gsap/react (scoped, auto-cleanup) instead of raw
useEffect. Kill ScrollTriggers on route change in SPAs or they fire on the wrong page.
GSAP and its plugins (ScrollTrigger, SplitText, etc.) are free to use, including
commercially, since 3.13.
Gotchas
- Animating an element that is
display: noneat start does nothing without@starting-styleor a frame delay. transformon a parent creates a containing block:position: fixedchildren stop being viewport-fixed.- Animating
opacityto 0 leaves the element focusable and clickable; also setvisibility: hiddenorinertat the end. - Long
staggersequences delay content; cap total stagger around 300 ms. - Infinite animations on many elements burn battery; pause offscreen ones.
Verification
- DevTools Performance: record the interaction with 4x CPU throttling. No long frames (red bars) during the animation; no purple Layout or green Paint blocks per frame.
- Rendering panel: enable Paint flashing and Layer borders; animated elements should not repaint every frame.
- Toggle “Emulate CSS prefers-reduced-motion: reduce” in the Rendering panel; motion stops or becomes a fade, and nothing is lost.
- Interrupt mid-animation (click again, navigate back): no jumps or stuck states.
- Test in Safari and Firefox for View Transitions and scroll-driven features, or confirm the fallback renders.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.