Here's the review.
| Before | After | Why |
|---|---|---|
.btn { transition: all 300ms; } |
transition: transform 160ms ease-out; plus .btn:active { transform: scale(0.97); } |
all animates properties you didn't intend to animate and can trigger layout and paint. 300ms is too slow for button feedback (aim for 100-160ms). The button also has no press state. |
.menu { transform: scale(0) } |
transform: scale(0.95); opacity: 0; and .menu.open { transform: scale(1); opacity: 1; } |
Nothing in the real world appears from nothing. scale(0) makes the menu pop out of a point. |
transform-origin: center on the profile dropdown |
transform-origin: top right (or the trigger's side), or var(--transform-origin) if you use Base UI or Radix |
A popover should grow from its trigger. Only modals stay centered. |
transition: transform 500ms ease-in |
transition: transform 180ms cubic-bezier(0.23, 1, 0.32, 1), opacity 180ms ease-out; |
ease-in starts slow, which is exactly when the user is watching. 500ms is far too long for a dropdown (150-250ms). Make the close faster than the open, around 120ms. |
.card:hover { transform: scale(1.05); } |
Wrap it in @media (hover: hover) and (pointer: fine), drop to about scale(1.02), and add transition: transform 200ms ease-out on .card |
Touch devices fire hover on tap, so the card sticks scaled. There is also no transition, so the hover snaps. 1.05 is heavy for a card, and it resizes the content and can cause neighbors to look cramped. |
.row { animation: slideIn 600ms ease-in-out forwards; } on 40 items at once |
animation: slideIn 250ms cubic-bezier(0.23, 1, 0.32, 1) both; with animation-delay: calc(var(--i) * 30ms), and cap the stagger (for example min(var(--i), 8)) |
Entering elements need ease-out, not ease-in-out. 600ms is too long. All 40 rows moving in unison reads as one block, so stagger them by 30-80ms. Don't let the stagger run for seconds (40 × 30ms is 1.2s), so cap it. Never block interaction while it plays. |
.row { opacity: 0; ... } as the base style |
Put the hidden state in the keyframe's from and use animation-fill-mode: both |
If the animation fails or is disabled, rows stay invisible forever. Also consider @starting-style or transitions if rows get added dynamically, since keyframes restart from zero when interrupted. |
<motion.div animate={{ x: open ? 0 : -300 }} /> |
animate={{ transform: open ? "translateX(0)" : "translateX(-100%)" }} with an explicit transition, such as a spring (duration: 0.3, bounce: 0) |
The x shorthand runs on the main thread and drops frames under load. A hardcoded -300 breaks if the panel width changes, so percentages are safer. Springs also keep velocity if the user toggles mid-motion. |
| Cmd+K palette with a 400ms fade and slide | No animation. Render it instantly. | A keyboard shortcut used hundreds of times a day should feel instant. Raycast does this. If you want some softness, use at most a 100ms opacity-only fade, and none on close. |
| No reduced-motion handling anywhere | @media (prefers-reduced-motion: reduce) keeps opacity fades and removes the slide, scale and translate motion. In Motion, use useReducedMotion(). |
Reduced motion means gentler, not zero. Keep the fades that aid comprehension and remove the movement. |
Priorities
- Remove the Cmd+K animation. It has the biggest impact on how the app feels.
- Fix the dropdown (
scale(0),ease-in, 500ms, center origin), since it hits every issue at once. - Stagger and shorten the list animation.
- Clean up
transition: all, the hover gating, and the Motionxprop.
Suggested CSS for the dropdown and rows
:root { --ease-out: cubic-bezier(0.23, 1, 0.32, 1); }
.menu {
opacity: 0;
transform: scale(0.95);
transform-origin: top right; /* match the profile button's position */
transition: transform 120ms var(--ease-out), opacity 120ms ease-out;
}
.menu.open {
opacity: 1;
transform: scale(1);
transition-duration: 180ms;
}
.row {
animation: slideIn 250ms var(--ease-out) both;
animation-delay: calc(min(var(--i), 8) * 40ms);
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
}
The menu's closed state needs pointer-events: none or visibility: hidden so it isn't clickable while invisible.
Test run: a pasted CSS and JSX snippet with common animation mistakes. The model used the required Before, After, Why table, flagged the Cmd+K palette animation and gave a priority list. Nothing was run or rendered; the suggested values come from the Skill's own tables.
What it does
Encodes a working designer-engineer's philosophy of UI polish. It asks the animation questions in order: should this animate at all (never on keyboard-driven actions used hundreds of times a day), what is the purpose, which easing, how fast. It specifies strong custom curves instead of the built-in CSS ones, keeps UI under about 300ms, and explains when to reach for springs. It covers component details (press feedback, never scaling from zero, origin-aware popovers, tooltip delay rules, transitions over keyframes), clip-path tricks, drag and gesture behavior, performance rules (animate transform and opacity only), reduced motion and touch-hover gating. Reviews must come back as a Before / After / Why table with a checklist.
Output
A review table, a priority list and suggested CSS or JSX with the reasoning behind each value.
Good for
Front-end developers and designers who want their interfaces to feel right, and teams who want a consistent bar for reviewing motion and component polish.
Low risk: pure instructions with no scripts, no network access and no file writes. It gives opinionated front-end advice; the numbers come from the author's own tables, so treat them as strong defaults, not laws. Test-run once on a pasted snippet: it returned the required table, flagged a command-palette animation and gave priorities. Nothing was run or rendered. When invoked with no question it replies only with a short greeting. By Emil Kowalski, MIT.