Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Tasteful, subtle web UI animation following Emil Kowalski / animations.dev principles. Use when adding or reviewing interface motion — hover and press feedback, entrances and exits, modals, toasts, menus, loading and skeleton states, staggered reveals, page or view transitions — so motion stays refined and purposeful, not decorative. Covers CSS, Web Animations, and React timing/easing. Pair with the motion skill for motion/react API specifics.
.claude/skills/asymmetric-al-anim/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 155% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 176% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 279% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 143% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 242% | 0% |
Name: anim Purpose: Tasteful, subtle web animations following Emil Kowalski's philosophy and animations.dev principles. Use this skill when adding motion to interfaces — hover states, page transitions, micro-interactions, loading states, or any UI animation — so motion stays refined and purposeful, not decorative noise.
Applies when: Adding or reviewing UI motion (CSS, Web APIs, or React); hover and press feedback; entrances/exits; modals, toasts, menus; loading and skeleton patterns; staggered reveals; page or view transitions.
Do not use when: Motion would hurt clarity or accessibility; the task is only motion/react plumbing — pair with the motion skill for API specifics. Skip motion for validation errors, critical errors, actively read content, and high-frequency live updates.
prefers-reduced-motion or useReducedMotion); avoid layout-affecting properties and animating on every re-render.Animation should be invisible. When done right, users don't notice animation — they notice that the interface feels _good_. The moment someone says "nice animation," you've probably overdone it.
> "The best animations are the ones you don't notice." — Emil Kowalski
transition={{ type: "spring", stiffness: 400, damping: 25 }} (tune as needed); in CSS, use cubic-bezier() or linear() curves that approximate a spring.auto or computed values without measuring first.opacity: 0 + translateY(8px) → opacity: 1 + translateY(0).opacity: 0, y: 8 → opacity: 1, y: 0. The classic for a reason.prefers-reduced-motion. Not optional. Wrap motion in @media (prefers-reduced-motion: no-preference) or check the query in JS.will-change only when needed, remove after. Apply before animation starts, remove after it ends. Never leave it on permanently.scroll-timeline or Intersection Observer sparingly.css.element { transition: transform 200ms ease-out, opacity 200ms ease-out; } /* Hover: instant on, fade off */ .element:hover { transform: translateY(-2px); transition-duration: 0ms; /* instant on */ } .element:not(:hover) { transition-duration: 150ms; /* ease off */ }
css@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .entering { animation: fadeInUp 250ms ease-out forwards; }
css/* Approximated spring curve */ :root { --spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); --spring-smooth: cubic-bezier(0.22, 1, 0.36, 1); --spring-snappy: cubic-bezier(0.16, 1, 0.3, 1); }
css.item { animation: fadeInUp 200ms ease-out backwards; } .item:nth-child(1) { animation-delay: 0ms; } .item:nth-child(2) { animation-delay: 40ms; } .item:nth-child(3) { animation-delay: 80ms; } .item:nth-child(4) { animation-delay: 120ms; } .item:nth-child(5) { animation-delay: 160ms; }
css@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }
tsx<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 4 }} transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }} />
tsx<motion.div animate={{ scale: 1 }} whileTap={{ scale: 0.97 }} transition={{ type: "spring", stiffness: 400, damping: 25 }} />
tsx<motion.ul initial="hidden" animate="visible" variants={{ visible: { transition: { staggerChildren: 0.04 } }, }} > {items.map((item) => ( <motion.li key={item.id} variants={{ hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0 }, }} /> ))} </motion.ul>
tsx<AnimatePresence mode="wait"> <motion.div key={currentView} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} /> </AnimatePresence>
The repo motion contract has two synchronized halves:
packages/ui/styles/globals.css (:root) — the baseline for Tailwind, Base UI surfaces, and global CSS.packages/lib/motion-presets.ts — the intended pair for motion/react and shared helpers; prefer these over raw literals in new or refactored client code.When updating one half, update the other so consumers stay in lockstep.
packages/ui/styles/globals.css :root)css/* Easing — strong cubic-bezier variants, not the weak built-ins */ --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1); --ease-in-soft: cubic-bezier(0.4, 0, 1, 1); --ease-in-out-soft: cubic-bezier(0.77, 0, 0.175, 1); --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS curve */ /* Duration tiers — product UI stays under 300ms unless ceremonial */ --duration-press: 120ms; /* button/tile press feedback */ --duration-micro: 150ms; /* hover, color change */ --duration-standard: 220ms; /* tooltip, popover, dropdown, select */ --duration-modal: 220ms; /* dialog open/close */ --duration-drawer: 320ms; /* sheet / vaul drawer */ --duration-route: 240ms; /* matches asym-vt-route-* */ --duration-shared: 280ms; /* matches asym-vt-share-* */ /* Stagger — keep tight (30–80ms) */ --stagger-tight: 45ms; --stagger-medium: 60ms; /* Transform tokens — never go below 0.95 (cartoon territory) */ --scale-press: 0.98; --scale-hover-subtle: 1.02; --scale-entrance: 0.96;
Authoritative values live in packages/ui/styles/globals.css; keep the fenced copy above in sync when tokens change.
| Utility | What it does | Touch-safe? | Pair with | | --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------- | | .press-feedback | :active scale to var(--scale-press) | Yes (:active fires on tap) | Default on every <Button> already | | .hover-lift | :hover translateY(-2px) | Yes (@media (hover: hover) and (pointer: fine)) | Cards, tiles | | .hover-scale-subtle | :hover scale(var(--scale-hover-subtle)) | Yes (@media (hover: hover) and (pointer: fine)) | Buttons, badges, marketing CTAs | | .spinner-essential | infinite spin exempt from the reduced-motion kill (essential status feedback) | Yes | Loader2 loading indicators only |
The three utilities share one transition-property declaration so you can stack them on the same element without one overriding another's transition list.
@asym/lib/motion-presets)EASE_OUT_SOFT, EASE_IN_SOFT, EASE_IN_OUT_SOFT, EASE_DRAWER, DURATION_PRESS, DURATION_MICRO, DURATION_STANDARD, DURATION_ROUTE, DURATION_SHARED, DURATION_DRAWER, DURATION_SLOW, STAGGER_TIGHT, STAGGER_MEDIUM, SCALE_HOVER_SUBTLE, SCALE_TAP_SUBTLE, SCALE_ENTRANCE. Semantic parity with the CSS --ease-*, --duration-* (where present), --stagger-*, and --scale-* tokens in globals.css — same numeric intent, not always the same name (e.g. DURATION_SLOW is extra-tier TS for marketing/hero; SCALE_TAP_SUBTLE matches --scale-press).
Pre-built Transition objects: transitionStandard, transitionSlow, transitionExitQuick, springTap (gestures only).
Reduced-motion-aware helpers:
propsHeroEntrance(reduceMotion, delay?, y?) — fade + small rise.propsFadeRiseInView(reduceMotion, options?) — fade + rise on scroll-in.propsScaleFadeInView(reduceMotion, options?) — fade + scale from SCALE_ENTRANCE.@asym/lib/motionRe-exports motion, AnimatePresence, LayoutGroup, useReducedMotion, plus MotionProvider (a LazyMotion features={domAnimation} wrapper). Use these in any client component that already imports from motion/react.
These rules are enforced by tooling/eslint-config/base.mjs (motion/react import restriction) and tests/unit/motion-contract.test.ts (banned class patterns), plus code review. Runtime contract: packages/ui/styles/globals.css and packages/lib/motion-presets.ts (update both together). Written contract (this file + docs/ai/rules/frontend.md Motion rules): how to apply them; keep all three in sync when policy changes.
⌘K, ⌘B, or other keyboard shortcuts. Command palette is intentionally instant — see packages/ui/components/shadcn/command.tsx.tabular-nums and no animation.packages/missionary/components/tasks/task-row.tsx is the canonical example).disabled:pointer-events-none disabled:opacity-50 on Button).hover:scale-*, hover:-translate-*, hover:shadow-* lift effect must be wrapped in @media (hover: hover) and (pointer: fine)..hover-lift or .hover-scale-subtle (both already gated). For ad-hoc group-hover: patterns where the parent owns the trigger, use the Tailwind arbitrary variant: [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[var(--scale-hover-subtle)].Button base (which now includes .press-feedback). Native <button> elements that don't use the Button primitive should add .press-feedback directly.active:scale-[0.98] inline — it duplicates the contract.transform-origin: var(--transform-origin) (the Base UI positioner variable; origin-(--transform-origin) in Tailwind). Already correct in shared primitives — keep it.Dialog, full-screen overlays) keep transform-origin: center.RouteMainViewTransitionBoundary, applied at the app shell (e.g. donor layout.tsx, apps/missionary/components/app-shell.tsx, apps/admin/app/mc-shell.tsx — not necessarily the root layout.tsx in every app).<ViewTransition> is keyed by pathname: enter/exit can only fire on an unmount/mount pair, and a persistent layout boundary never remounts on its own. Consequence when the flag is ON (NEXT_PUBLIC_VIEW_TRANSITIONS_ENABLED=true): the route-content subtree remounts per navigation (template.tsx semantics — client state below the shell resets, route content starts scrolled to top). With the flag OFF nothing remounts and nothing animates.animate-in classes and motion initial props with useWithinViewTransitionRouteLayer() (!withinRouteVt && "animate-in fade-in duration-300" / initial={withinRouteVt ? false : {...}} — the PageShell pattern is the template).AnimatePresence mode="popLayout" (e.g. apps/admin/app/(app)/feed/org-updates/page-client.tsx) gate the first mount only via <AnimatePresence initial={!withinRouteVt}> — a per-item initial ternary would also kill the add/remove animation the list needs.globals.css must use the class selector form ::view-transition-old(.asym-vt-route-exit) (leading dot). React applies enter/exit/share prop values as view-transition-class; an undotted ident targets a view-transition _name_ and silently matches nothing (guarded by tests/unit/motion-contract.test.ts).motion.div layout on the swapping region.motion/react tweens vs springsmotion/react tweens for orchestrated entrances and exits (page mounts, hero strips, lists).TooltipProvider defaults: delay={300}, timeout={0} (see packages/ui/components/shadcn/tooltip.tsx). With Base UI, timeout is the window for _skipping the open delay_ when moving between triggers; 0 disables that skip window — do not read it as "warm follow-up" behavior. For always-instant tooltips, set delay={0} on a subtree provider (e.g. nav sidebar, rich-text toolbar) instead of inferring it from timeout alone.<TooltipProvider delay={0}> is a deliberate exception (collapsed-icon sidebar tooltips should be instant).prefers-reduced-motion: reduce baseline lives only in packages/ui/styles/globals.css. Apps must not redeclare it.motion/react code should use useReducedMotion() and return transition: { duration: 0 }, initial: false, or skip motion when reduced motion is on. Pattern examples: MotionPreset, RippleButton, AppIcon, task-row, task-stats, feed-post's FloatingEmoji.globals.css under @media (prefers-reduced-motion: reduce)).packages/ui/components/shadcn/page-shell.tsx (motion + tokens + view-transition awareness in one file).packages/ui/components/shadcn/button.tsx (press-feedback on the base; hover-scale-subtle for maia variants; no transition: all).apps/admin/features/mission-control/components/tiles/tile-card.tsx (uses hover-lift only, no per-effect translate stack).packages/missionary/components/tasks/task-row.tsx (one stagger entrance, no per-row motion.layout, no per-element springs, hover lift via hover-lift)._"Animation is not about moving things. It's about not making users wait."_ — Emil Kowalski
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-04 | fail→pass | 20,076 | 21,783 | +9% | 1 | 1 | 0% | 4,085 | 10,434 | +155% | 0 | 0 | — |
case-05 | fail→pass | 16,852 | 16,435 | -2% | 1 | 1 | 0% | 3,305 | 9,131 | +176% | 0 | 0 | — |
case-01 | fail→pass | 13,346 | 13,428 | +1% | 1 | 1 | 0% | 2,210 | 8,365 | +279% | 0 | 0 | — |
case-02 | fail→pass | 18,189 | 8,139 | -55% | 1 | 1 | 0% | 2,971 | 7,229 | +143% | 0 | 0 | — |
case-03 | fail→pass | 14,859 | 9,861 | -34% | 1 | 1 | 0% | 2,247 | 7,695 | +242% | 0 | 0 | — |
case-06 | fail→pass | 20,260 | 14,568 | -28% | 1 | 1 | 0% | 3,334 | 8,803 | +164% | 0 | 0 | — |
case-07 | pass→pass | 14,540 | 4,871 | -66% | 1 | 1 | 0% | 2,273 | 6,970 | +207% | 0 | 0 | — |
case-08 | pass→pass | 11,923 | 8,076 | -32% | 1 | 1 | 0% | 2,194 | 7,561 | +245% | 0 | 0 | — |
case-09 | pass→pass | 14,398 | 11,938 | -17% | 1 | 1 | 0% | 2,446 | 8,327 | +240% | 0 | 0 | — |
case-10 | pass→pass | 10,604 | 8,874 | -16% | 1 | 1 | 0% | 1,711 | 7,721 | +351% | 0 | 0 | — |
case-11 | fail→pass | 16,381 | 10,173 | -38% | 1 | 1 | 0% | 2,582 | 7,953 | +208% | 0 | 0 | — |
case-12 | fail→pass | 14,817 | 9,442 | -36% | 1 | 1 | 0% | 2,241 | 7,492 | +234% | 0 | 0 | — |
case-13 | pass→pass | 16,329 | 10,824 | -34% | 1 | 1 | 0% | 2,578 | 8,043 | +212% | 0 | 0 | — |
case-14 | fail→pass | 16,445 | 9,181 | -44% | 1 | 1 | 0% | 2,500 | 7,709 | +208% | 0 | 0 | — |
case-15 | fail→pass | 11,325 | 5,524 | -51% | 1 | 1 | 0% | 1,719 | 6,937 | +304% | 0 | 0 | — |
case-16 | pass→pass | 14,188 | 9,748 | -31% | 1 | 1 | 0% | 1,932 | 7,621 | +294% | 0 | 0 | — |
case-17 | pass→pass | 6,400 | 3,462 | -46% | 1 | 1 | 0% | 1,044 | 6,690 | +541% | 0 | 0 | — |
case-18 | pass→pass | 11,126 | 7,334 | -34% | 1 | 1 | 0% | 1,745 | 7,339 | +321% | 0 | 0 | — |
case-19 | pass→pass | 14,328 | 8,342 | -42% | 1 | 1 | 0% | 2,033 | 7,431 | +266% | 0 | 0 | — |
case-20 | pass→pass | 17,469 | 12,603 | -28% | 1 | 1 | 0% | 2,524 | 8,141 | +223% | 0 | 0 | — |
case-21 | pass→pass | 14,429 | 9,312 | -35% | 1 | 1 | 0% | 2,140 | 7,588 | +255% | 0 | 0 | — |
case-22 | fail→pass | 12,317 | 6,226 | -49% | 1 | 1 | 0% | 1,928 | 7,128 | +270% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +50 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.