From ui-excellence
Use when designing or reviewing UI animations, transitions, and motion interactions. Triggers for animation decisions, performance optimization, accessibility compliance, and gesture handling.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ui-excellence:animation-motionThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Taste is trained, not innate. Animation excellence compounds through study and practice—most users never consciously notice individual polish, yet the collection creates stunning results. Beauty is leverage: aesthetic excellence is competitive advantage. This skill applies Emil Kowalski's battle-tested animation decision framework to ship production-grade motion.
Taste is trained, not innate. Animation excellence compounds through study and practice—most users never consciously notice individual polish, yet the collection creates stunning results. Beauty is leverage: aesthetic excellence is competitive advantage. This skill applies Emil Kowalski's battle-tested animation decision framework to ship production-grade motion.
Use when:
Do NOT use for:
Frequency is the primary gate:
| Frequency | Example | Decision |
|---|---|---|
| 100+ times/day | Keyboard shortcuts, frequently toggled UI | ❌ Never animate |
| Tens of times/day | Hover effects, toggle states | ⚠️ Remove or drastically reduce |
| Occasional (few per session) | Modals, toasts, notifications | ✅ Standard animation acceptable |
| Rare | Onboarding, celebrations, first-run | ✅ Can add delight |
| Keyboard-initiated | Any action from keyboard (return, space, etc.) | ❌ Never animate |
Golden rule: If users interact with it frequently, every millisecond of animation compounds into frustration.
Valid animation purposes:
Invalid reasons:
Default easing selection:
| Scenario | Easing | Rationale |
|---|---|---|
| Element entering/exiting | ease-out | Fast start = responsive; slows to natural stop |
| Moving/morphing on screen | ease-in-out | Symmetric motion feels balanced |
| Hover/color change | ease | General purpose, smooth |
| Constant motion | linear | Clock-like consistency |
CRITICAL: Use custom easing curves, not CSS defaults:
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}
Never use ease-in for UI animations — delays initial movement, feels sluggish and unresponsive.
Duration guidelines:
| Component | Duration Range | Rationale |
|---|---|---|
| Button press feedback | 100–160ms | Instant perceived feedback |
| Tooltips/small popovers | 125–200ms | Quick appearance |
| Dropdowns/selects | 150–250ms | Slightly longer for scope change |
| Modals/drawers | 200–500ms | Larger movement space |
| Marketing/explanatory | Flexible | Can extend for teaching |
Rule of thumb: UI animations under 300ms. Anything longer feels like loading.
Springs simulate physics naturally. Duration emerges from physics parameters, not fixed time. Use for:
Apple-style config:
{
type: "spring",
duration: 0.5,
bounce: 0.2 // 0.1-0.3 recommended; keep subtle
}
Traditional config (Framer Motion):
{
type: "spring",
mass: 1,
stiffness: 100,
damping: 10
}
Key insight: Springs maintain velocity when interrupted. CSS animations restart from zero (rigid).
/* Base */
button {
transition: transform 160ms var(--ease-out);
}
/* Active state — subtle press feedback */
button:active {
transform: scale(0.97);
}
Why 0.97, not 0.95? Subtle feedback feels refined; aggressive scale looks cheap.
/* ❌ BAD — jarring entrance, feels cheap */
@keyframes popIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
/* ✅ GOOD — smooth, natural entrance */
@keyframes popIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Popover respects where it originated from */
[data-radix-popover-content] {
transform-origin: var(--radix-popover-content-transform-origin);
animation: popoverEnter 200ms var(--ease-out);
}
@keyframes popoverEnter {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
Modals keep center origin — always transform-origin: center center.
/* First appearance: delay to confirm intent */
[role="tooltip"] {
transition: opacity 200ms var(--ease-out);
transition-delay: 300ms;
}
/* Subsequent hovers: instant (user already hovering element) */
[role="tooltip"][data-instant] {
transition-delay: 0ms;
}
/* During fade transitions, slight blur hides interpolation artifacts */
[data-transitioning] {
filter: blur(2px);
transition: filter 200ms var(--ease-out);
}
[data-transitioning][data-done] {
filter: blur(0px);
}
Keep blur under 20px; large blur reads as visual glitch.
/* Define "before" state without JavaScript */
dialog {
opacity: 1;
translate: 0 0;
animation: dialogEnter 300ms var(--ease-out);
}
@starting-style {
dialog {
opacity: 0;
translate: 0 -20px;
}
}
/* Percentage relative to ELEMENT SIZE, not viewport */
[data-drawer] {
transform: translateY(100%); /* Moves down by element's own height */
transition: transform 300ms var(--ease-drawer);
}
[data-drawer][data-open] {
transform: translateY(0);
}
/* Parent scale affects children proportionally */
.parent {
transform: scale(0.95); /* All descendants scale 0.95x */
}
.card {
transform-style: preserve-3d;
transform: rotateX(15deg) rotateY(-10deg);
}
.card-face {
transform: translateZ(20px); /* Lifts face forward in 3D space */
}
Use for rectangular masks:
/* Reveal from top down */
.reveal {
clip-path: inset(0 0 100% 0);
animation: revealDown 400ms var(--ease-out) forwards;
}
@keyframes revealDown {
to { clip-path: inset(0 0 0 0); }
}
Use cases:
// User swipes dismissible element
element.addEventListener('pointerup', (event) => {
const velocity = calculateVelocity(event);
if (Math.abs(velocity) > DISMISS_THRESHOLD) {
// Use spring to carry momentum
element.animate([
{ transform: 'translateY(0)' },
{ transform: 'translateY(100%)' }
], {
duration: 300,
easing: 'cubic-bezier(0.32, 0.72, 0, 1)'
});
}
});
// Damping at boundaries instead of walls
let velocity = getVelocity();
if (element.y > MAX_Y) {
velocity *= 0.95; // Friction dampens momentum
}
element.addEventListener('pointerdown', (event) => {
element.setPointerCapture(event.pointerId);
// All subsequent pointer events target this element, even off-screen
});
element.addEventListener('pointermove', (event) => {
if (event.isPrimary) {
// Only respond to primary pointer (not secondary touch)
updateDrag(event.clientX, event.clientY);
}
});
/* ✅ GOOD — GPU accelerated, no repaints */
.button {
transition: transform 160ms var(--ease-out), opacity 160ms var(--ease-out);
}
/* ❌ BAD — triggers reflow on every frame */
.button {
transition: width 160ms, height 160ms, padding 160ms;
}
:root {
--animation-duration: 300ms;
--animation-easing: var(--ease-out);
}
.modal {
animation: modalEnter var(--animation-duration) var(--animation-easing);
}
.modal-overlay {
animation: overlayFade var(--animation-duration) var(--animation-easing);
}
element.animate([
{ transform: 'translateY(-100px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
], {
duration: 300,
easing: 'cubic-bezier(0.23, 1, 0.32, 1)',
fill: 'forwards'
});
Verify on real devices, always. Desktop Chrome may hide jank that's visible on iPhone.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Not optional. Users with vestibular disorders experience nausea from motion.
/* Only show hover effects on devices that support hover */
@media (hover: hover) {
button:hover {
opacity: 0.8;
}
}
Prevents ghost states on touch devices.
Animate multiple elements in sequence, not simultaneously:
.item {
opacity: 0;
animation: itemEnter 300ms var(--ease-out) forwards;
}
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }
Effect: Creates visual rhythm, guides attention, feels intentional.
| Aspect | Rule |
|---|---|
| Button press | 100-160ms, scale(0.97), ease-out |
| Modal entrance | 200-500ms, origin-aware, ease-out |
| Dropdown open | 150-250ms, ease-out |
| Hover state | Remove or keep <100ms; avoid on keyboard shortcuts |
| Easing default | Custom cubic-bezier, never CSS ease-in |
| Performance | transform + opacity only |
| Accessibility | Always respect prefers-reduced-motion |
| Spring bounce | 0.1-0.3, keep subtle |
| Scale entrance | Never scale(0), use scale(0.95) + opacity |
| Popover origin | Use transform-origin: var(--radix-popover-content-transform-origin) |
When reviewing UI code with animations, verify:
ease, ease-in, or ease-out defaultsUse this markdown table when presenting before/after animation changes:
| Before | After | Why |
|---|---|---|
| Button grows on hover | Button darkens on hover | Hover is >10x/day; animation removed |
| Modal scale(0) entrance | Modal scale(0.95) entrance | Smoother, less jarring visual |
| 500ms dropdown open | 200ms dropdown open | Felt laggy; 200ms is perceptually instant |
// ❌ BAD — submitted 100x/day, animation compounds to ~3 min/day lost
buttonSubmit.addEventListener('click', () => {
showLoadingSpinner(); // 300ms animation
});
// ✅ GOOD — instant feedback, no animation
buttonSubmit.disabled = true;
buttonSubmit.textContent = 'Loading...';
/* ❌ BAD — delays initial movement, feels unresponsive */
.modal {
transition: opacity 300ms ease-in;
}
/* ✅ GOOD — fast start, natural deceleration */
.modal {
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* ❌ BAD — impossible to adjust globally */
.modal { animation: modalEnter 350ms ease-out; }
.overlay { animation: overlayFade 300ms ease-out; }
.button { animation: buttonSlide 250ms ease-out; }
/* ✅ GOOD — adjust once, applies everywhere */
:root {
--duration-modal: 300ms;
--duration-feedback: 150ms;
}
.modal { animation: modalEnter var(--duration-modal) var(--ease-out); }
/* ❌ BAD — ignores accessibility preference */
.animation { animation: spin 1s linear infinite; }
/* ✅ GOOD — respects user preference */
.animation { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.animation { animation: none; }
}
// ❌ BAD
element.style.transform = 'scale(0)';
setTimeout(() => {
element.style.transform = 'scale(1)';
}, 0);
// ✅ GOOD
element.style.transform = 'scale(0.95)';
element.style.opacity = '0';
setTimeout(() => {
element.style.transform = 'scale(1)';
element.style.opacity = '1';
}, 0);
Taste is trained. Study animations in Apple Mail, Linear, Stripe, Arc Browser. Screenshot the good ones. Copy the feeling (not the code). Practice writing micro-interactions. Review with fresh eyes. Ship, measure, iterate.
Every 100ms compounded across millions of interactions shapes user perception. Excellence is leverage.
npx claudepluginhub fernando-bertholdo/4-successful-ai-life --plugin ui-excellenceCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.