chore: format with prettier [skip ci]
This commit is contained in:
parent
4c8f9db279
commit
90c2543f5e
|
|
@ -39,12 +39,12 @@ People select tools based on the overall experience, not just functionality. Goo
|
|||
|
||||
When reviewing UI code, you MUST use a markdown table with Before/After columns. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this:
|
||||
|
||||
| Before | After | Why |
|
||||
| --- | --- | --- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
|
||||
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
|
||||
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
|
||||
| Before | After | Why |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
|
||||
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
|
||||
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
|
||||
| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers should scale from their trigger (not modals — modals stay centered) |
|
||||
|
||||
Wrong format (never do this):
|
||||
|
|
@ -95,15 +95,15 @@ If the purpose is just "it looks cool" and the user will see it often, don't ani
|
|||
### 3. What easing should it use?
|
||||
|
||||
Is the element entering or exiting?
|
||||
Yes → ease-out (starts fast, feels responsive)
|
||||
No →
|
||||
Is it moving/morphing on screen?
|
||||
Yes → ease-in-out (natural acceleration/deceleration)
|
||||
Is it a hover/color change?
|
||||
Yes → ease
|
||||
Is it constant motion (marquee, progress bar)?
|
||||
Yes → linear
|
||||
Default → ease-out
|
||||
Yes → ease-out (starts fast, feels responsive)
|
||||
No →
|
||||
Is it moving/morphing on screen?
|
||||
Yes → ease-in-out (natural acceleration/deceleration)
|
||||
Is it a hover/color change?
|
||||
Yes → ease
|
||||
Is it constant motion (marquee, progress bar)?
|
||||
Yes → linear
|
||||
Default → ease-out
|
||||
|
||||
**Critical: use custom easing curves.** The built-in CSS easings are too weak. They lack the punch that makes animations feel intentional.
|
||||
|
||||
|
|
@ -160,15 +160,15 @@ Springs feel more natural than duration-based animations because they simulate r
|
|||
Tying visual changes directly to mouse position feels artificial because it lacks motion. Use `useSpring` from Motion (formerly Framer Motion) to interpolate value changes with spring-like behavior instead of updating immediately.
|
||||
|
||||
```jsx
|
||||
import { useSpring } from 'framer-motion';
|
||||
import { useSpring } from "framer-motion";
|
||||
|
||||
// Without spring: feels artificial, instant
|
||||
const rotation = mouseX * 0.1;
|
||||
|
||||
// With spring: feels natural, has momentum
|
||||
const springRotation = useSpring(mouseX * 0.1, {
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -202,11 +202,11 @@ Add `transform: scale(0.97)` on `:active`. This gives instant feedback, making t
|
|||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms ease-out;
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -221,13 +221,13 @@ Start from `scale(0.9)` or higher, combined with opacity. Even a barely-visible
|
|||
```css
|
||||
/* Bad */
|
||||
.entering {
|
||||
transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Good */
|
||||
.entering {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -238,12 +238,12 @@ Popovers should scale in from their trigger, not from center. The default `trans
|
|||
```css
|
||||
/* Radix UI */
|
||||
.popover {
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
}
|
||||
|
||||
/* Base UI */
|
||||
.popover {
|
||||
transform-origin: var(--transform-origin);
|
||||
transform-origin: var(--transform-origin);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -255,19 +255,21 @@ Tooltips should delay before appearing to prevent accidental activation. But onc
|
|||
|
||||
```css
|
||||
.tooltip {
|
||||
transition: transform 125ms ease-out, opacity 125ms ease-out;
|
||||
transform-origin: var(--transform-origin);
|
||||
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);
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Skip animation on subsequent tooltips */
|
||||
.tooltip[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -278,17 +280,17 @@ CSS transitions can be interrupted and retargeted mid-animation. Keyframes resta
|
|||
```css
|
||||
/* Interruptible - good for UI */
|
||||
.toast {
|
||||
transition: transform 400ms ease;
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
/* Not interruptible - avoid for dynamic UI */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -302,20 +304,22 @@ Combine blur with scale-on-press (`scale(0.97)`) for a polished button state tra
|
|||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms ease-out;
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button-content {
|
||||
transition: filter 200ms ease, opacity 200ms ease;
|
||||
transition:
|
||||
filter 200ms ease,
|
||||
opacity 200ms ease;
|
||||
}
|
||||
|
||||
.button-content.transitioning {
|
||||
filter: blur(2px);
|
||||
opacity: 0.7;
|
||||
filter: blur(2px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -327,14 +331,16 @@ The modern CSS way to animate element entry without JavaScript:
|
|||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 400ms ease,
|
||||
transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -343,7 +349,7 @@ This replaces the common React pattern of using `useEffect` to set `mounted: tru
|
|||
```jsx
|
||||
// Legacy pattern (still works everywhere)
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
// <div data-mounted={mounted}>
|
||||
```
|
||||
|
|
@ -357,12 +363,12 @@ Percentage values in `translate()` are relative to the element's own size. Use `
|
|||
```css
|
||||
/* Works regardless of drawer height */
|
||||
.drawer-hidden {
|
||||
transform: translateY(100%);
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
/* Works regardless of toast height */
|
||||
.toast-enter {
|
||||
transform: translateY(-100%);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -378,16 +384,16 @@ Unlike `width`/`height`, `scale()` also scales an element's children. When scali
|
|||
|
||||
```css
|
||||
.wrapper {
|
||||
transform-style: preserve-3d;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg);
|
||||
}
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -406,22 +412,22 @@ Every element has an anchor point from which transforms execute. The default is
|
|||
```css
|
||||
/* Fully hidden from right */
|
||||
.hidden {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
|
||||
/* Fully visible */
|
||||
.visible {
|
||||
clip-path: inset(0 0 0 0);
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
/* Reveal from left to right */
|
||||
.overlay {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms ease-out;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms ease-out;
|
||||
}
|
||||
.button:active .overlay {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -452,7 +458,7 @@ const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
|
|||
const velocity = Math.abs(swipeAmount) / timeTaken;
|
||||
|
||||
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
|
||||
dismiss();
|
||||
dismiss();
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -470,8 +476,8 @@ Ignore additional touch points after the initial drag begins. Without this, swit
|
|||
|
||||
```js
|
||||
function onPress() {
|
||||
if (isDragging) return;
|
||||
// Start drag...
|
||||
if (isDragging) return;
|
||||
// Start drag...
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -491,7 +497,7 @@ Changing a CSS variable on a parent recalculates styles for all children. In a d
|
|||
|
||||
```js
|
||||
// Bad: triggers recalc on all children
|
||||
element.style.setProperty('--swipe-amount', `${distance}px`);
|
||||
element.style.setProperty("--swipe-amount", `${distance}px`);
|
||||
|
||||
// Good: only affects this element
|
||||
element.style.transform = `translateY(${distance}px)`;
|
||||
|
|
@ -520,10 +526,10 @@ CSS animations run off the main thread. When the browser is busy loading a new p
|
|||
The Web Animations API gives you JavaScript control with CSS performance. Hardware-accelerated, interruptible, and no library needed.
|
||||
|
||||
```js
|
||||
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], {
|
||||
duration: 1000,
|
||||
fill: 'forwards',
|
||||
easing: 'cubic-bezier(0.77, 0, 0.175, 1)',
|
||||
element.animate([{ clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0 0)" }], {
|
||||
duration: 1000,
|
||||
fill: "forwards",
|
||||
easing: "cubic-bezier(0.77, 0, 0.175, 1)",
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -535,25 +541,25 @@ Animations can cause motion sickness. Reduced motion means fewer and gentler ani
|
|||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.element {
|
||||
animation: fade 0.2s ease;
|
||||
/* No transform-based motion */
|
||||
}
|
||||
.element {
|
||||
animation: fade 0.2s ease;
|
||||
/* No transform-based motion */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const closedX = shouldReduceMotion ? 0 : '-100%';
|
||||
const closedX = shouldReduceMotion ? 0 : "-100%";
|
||||
```
|
||||
|
||||
### Touch device hover states
|
||||
|
||||
```css
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.element:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.element:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -596,12 +602,12 @@ Pressing should be slow when it needs to be deliberate (hold-to-delete: 2s linea
|
|||
```css
|
||||
/* Release: fast */
|
||||
.overlay {
|
||||
transition: clip-path 200ms ease-out;
|
||||
transition: clip-path 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Press: slow and deliberate */
|
||||
.button:active .overlay {
|
||||
transition: clip-path 2s linear;
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -611,29 +617,29 @@ When multiple elements enter together, stagger their appearance. Each element an
|
|||
|
||||
```css
|
||||
.item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
.item:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.item:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.item:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.item:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -664,16 +670,16 @@ For touch interactions (drawers, swipe gestures), test on physical devices. Conn
|
|||
|
||||
When reviewing UI code, check for:
|
||||
|
||||
| Issue | Fix |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` |
|
||||
| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` |
|
||||
| `ease-in` on UI element | Switch to `ease-out` or custom curve |
|
||||
| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) |
|
||||
| Animation on keyboard action | Remove animation entirely |
|
||||
| Duration > 300ms on UI element | Reduce to 150-250ms |
|
||||
| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` |
|
||||
| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility |
|
||||
| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration |
|
||||
| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) |
|
||||
| Elements all appear at once | Add stagger delay (30-80ms between items) |
|
||||
| Issue | Fix |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` |
|
||||
| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` |
|
||||
| `ease-in` on UI element | Switch to `ease-out` or custom curve |
|
||||
| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) |
|
||||
| Animation on keyboard action | Remove animation entirely |
|
||||
| Duration > 300ms on UI element | Reduce to 150-250ms |
|
||||
| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` |
|
||||
| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility |
|
||||
| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration |
|
||||
| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) |
|
||||
| Elements all appear at once | Add stagger delay (30-80ms between items) |
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ A specialized review skill. It does ONE thing: review animation and motion code
|
|||
|
||||
You are a senior motion-design reviewer with a brutal eye for craft. Your bias is toward **motion that feels right**, not motion that merely runs. A transition that "works" but feels sluggish, lands from the wrong origin, fires too often, or drops frames is a regression, not a pass. Default to flagging. Approval is earned, not assumed.
|
||||
|
||||
The substantive bar comes from Emil Kowalski's animation philosophy (animations.dev). The review *method* — non-negotiable standards, escalation triggers, a remedial hierarchy, tiered output, and explicit approval criteria — is adapted from aggressive code-quality review.
|
||||
The substantive bar comes from Emil Kowalski's animation philosophy (animations.dev). The review _method_ — non-negotiable standards, escalation triggers, a remedial hierarchy, tiered output, and explicit approval criteria — is adapted from aggressive code-quality review.
|
||||
|
||||
For the full rule catalog (easing curves, duration tables, spring config, gestures, clip-path, performance, a11y), see [STANDARDS.md](STANDARDS.md). Load it whenever a finding needs a precise value or citation.
|
||||
|
||||
|
|
@ -81,12 +81,12 @@ Two parts, in this order.
|
|||
|
||||
A single markdown table. One row per issue. Never a "Before:/After:" list.
|
||||
|
||||
| Before | After | Why |
|
||||
| --- | --- | --- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing appears from nothing — `scale(0)` looks like it came from nowhere |
|
||||
| `ease-in` on dropdown | `ease-out` + custom curve | `ease-in` delays the moment the user watches most; feels sluggish |
|
||||
| `transform-origin: center` on popover | `var(--radix-popover-content-transform-origin)` | Popovers scale from their trigger, not center (modals are exempt) |
|
||||
| Before | After | Why |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing appears from nothing — `scale(0)` looks like it came from nowhere |
|
||||
| `ease-in` on dropdown | `ease-out` + custom curve | `ease-in` delays the moment the user watches most; feels sluggish |
|
||||
| `transform-origin: center` on popover | `var(--radix-popover-content-transform-origin)` | Popovers scale from their trigger, not center (modals are exempt) |
|
||||
|
||||
### Part 2 — Verdict (REQUIRED)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ The precise values, curves, and rules behind the review. Cite these in findings
|
|||
|
||||
## Should it animate? (frequency table)
|
||||
|
||||
| Frequency | Decision |
|
||||
| --- | --- |
|
||||
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
|
||||
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
|
||||
| Occasional (modals, drawers, toasts) | Standard animation |
|
||||
| Rare / first-time (onboarding, feedback, celebrations) | Can add delight |
|
||||
| Frequency | Decision |
|
||||
| ----------------------------------------------------------- | ---------------------------- |
|
||||
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
|
||||
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
|
||||
| Occasional (modals, drawers, toasts) | Standard animation |
|
||||
| Rare / first-time (onboarding, feedback, celebrations) | Can add delight |
|
||||
|
||||
**Never animate keyboard-initiated actions** — they repeat hundreds of times daily; animation makes them feel slow and disconnected. (Raycast has no open/close animation — correct for something used hundreds of times a day.)
|
||||
|
||||
|
|
@ -18,33 +18,34 @@ Valid purposes for motion: spatial consistency, state indication, explanation, f
|
|||
## Easing
|
||||
|
||||
Decision order:
|
||||
|
||||
- Entering or exiting → **`ease-out`** (starts fast, feels responsive)
|
||||
- Moving / morphing on screen → **`ease-in-out`**
|
||||
- Hover / color change → **`ease`**
|
||||
- Constant motion (marquee, progress) → **`linear`**
|
||||
- Default → **`ease-out`**
|
||||
|
||||
**Never `ease-in` on UI.** It starts slow, delaying the exact moment the user is watching. `ease-out` at 200ms *feels* faster than `ease-in` at 200ms.
|
||||
**Never `ease-in` on UI.** It starts slow, delaying the exact moment the user is watching. `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms.
|
||||
|
||||
Built-in CSS easings are too weak. Use strong custom curves:
|
||||
|
||||
```css
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* strong ease-in-out for on-screen movement */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer curve (Ionic) */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* strong ease-in-out for on-screen movement */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer curve (Ionic) */
|
||||
```
|
||||
|
||||
Find curves at [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) — don't hand-roll from scratch.
|
||||
|
||||
## Duration
|
||||
|
||||
| Element | Duration |
|
||||
| --- | --- |
|
||||
| Button press feedback | 100–160ms |
|
||||
| Tooltips, small popovers | 125–200ms |
|
||||
| Dropdowns, selects | 150–250ms |
|
||||
| Modals, drawers | 200–500ms |
|
||||
| Marketing / explanatory | Can be longer |
|
||||
| Element | Duration |
|
||||
| ------------------------ | ------------- |
|
||||
| Button press feedback | 100–160ms |
|
||||
| Tooltips, small popovers | 125–200ms |
|
||||
| Dropdowns, selects | 150–250ms |
|
||||
| Modals, drawers | 200–500ms |
|
||||
| Marketing / explanatory | Can be longer |
|
||||
|
||||
**Rule: UI animations stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. Faster spinners make load feel faster (same actual time). Instant tooltips after the first (skip delay + animation) make a toolbar feel faster.
|
||||
|
||||
|
|
@ -53,8 +54,12 @@ Find curves at [easing.dev](https://easing.dev/) or [easings.co](https://easings
|
|||
- **Never `scale(0)`.** Start from `scale(0.9–0.97)` + `opacity: 0`. Nothing in the real world appears from nothing.
|
||||
- **Origin-aware popovers.** Scale from the trigger, not center:
|
||||
```css
|
||||
.popover { transform-origin: var(--radix-popover-content-transform-origin); } /* Radix */
|
||||
.popover { transform-origin: var(--transform-origin); } /* Base UI */
|
||||
.popover {
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
} /* Radix */
|
||||
.popover {
|
||||
transform-origin: var(--transform-origin);
|
||||
} /* Base UI */
|
||||
```
|
||||
**Modals are exempt** — they appear centered in the viewport, keep `transform-origin: center`.
|
||||
- **Button press feedback.** `transform: scale(0.97)` on `:active`, `transition: transform 160ms ease-out`. Subtle (0.95–0.98). Applies to any pressable element.
|
||||
|
|
@ -81,19 +86,34 @@ CSS **transitions** can be interrupted and retargeted mid-animation; **keyframes
|
|||
|
||||
```css
|
||||
/* Interruptible — good for dynamic UI */
|
||||
.toast { transition: transform 400ms ease; }
|
||||
.toast {
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
/* Not interruptible — avoid for dynamic UI */
|
||||
@keyframes slideIn { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `@starting-style` for entry without JS:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1; transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
@starting-style { opacity: 0; transform: translateY(100%); }
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 400ms ease,
|
||||
transform 400ms ease;
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -104,8 +124,12 @@ Legacy fallback: `useEffect(() => setMounted(true), [])` + `data-mounted` attrib
|
|||
Slow where the user is deciding, fast where the system responds.
|
||||
|
||||
```css
|
||||
.overlay { transition: clip-path 200ms ease-out; } /* release: fast */
|
||||
.button:active .overlay { transition: clip-path 2s linear; } /* press: slow, deliberate */
|
||||
.overlay {
|
||||
transition: clip-path 200ms ease-out;
|
||||
} /* release: fast */
|
||||
.button:active .overlay {
|
||||
transition: clip-path 2s linear;
|
||||
} /* press: slow, deliberate */
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
|
@ -113,8 +137,8 @@ Slow where the user is deciding, fast where the system responds.
|
|||
- **Only animate `transform` and `opacity`** — they skip layout/paint and run on the GPU. `padding`/`margin`/`height`/`width`/`top`/`left` trigger all three rendering steps.
|
||||
- **Don't drive child transforms via a CSS variable on the parent** — it recalcs styles for all children. Set `transform` directly on the element.
|
||||
```js
|
||||
element.style.setProperty('--swipe-amount', `${d}px`); // bad: recalc on all children
|
||||
element.style.transform = `translateY(${d}px)`; // good: only this element
|
||||
element.style.setProperty("--swipe-amount", `${d}px`); // bad: recalc on all children
|
||||
element.style.transform = `translateY(${d}px)`; // good: only this element
|
||||
```
|
||||
- **Framer Motion shorthands are NOT hardware-accelerated.** `x`/`y`/`scale` run on the main thread via rAF and drop frames under load. Use the full transform string:
|
||||
```jsx
|
||||
|
|
@ -124,8 +148,11 @@ Slow where the user is deciding, fast where the system responds.
|
|||
- **CSS animations beat JS under load** — they run off the main thread; rAF-based animations stutter while the browser loads/scripts/paints. Use CSS for predetermined motion, JS for dynamic/interruptible.
|
||||
- **WAAPI** gives JS control with CSS performance (hardware-accelerated, interruptible, no library):
|
||||
```js
|
||||
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }],
|
||||
{ duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' });
|
||||
element.animate([{ clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0 0)" }], {
|
||||
duration: 1000,
|
||||
fill: "forwards",
|
||||
easing: "cubic-bezier(0.77, 0, 0.175, 1)",
|
||||
});
|
||||
```
|
||||
|
||||
## Transforms & clip-path
|
||||
|
|
@ -152,26 +179,43 @@ When a crossfade shows two overlapping states despite tuning easing/duration, ad
|
|||
Stagger group entrances; 30–80ms between items. Longer delays feel slow. Stagger is decorative — never block interaction while it plays.
|
||||
|
||||
```css
|
||||
.item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; }
|
||||
.item:nth-child(2) { animation-delay: 50ms; }
|
||||
.item:nth-child(3) { animation-delay: 100ms; }
|
||||
@keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }
|
||||
.item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
}
|
||||
.item:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.item:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.element { animation: fade 0.2s ease; } /* keep opacity/color, drop transform-based motion */
|
||||
.element {
|
||||
animation: fade 0.2s ease;
|
||||
} /* keep opacity/color, drop transform-based motion */
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.element:hover { transform: scale(1.05); } /* gate hover motion — touch fires false hovers on tap */
|
||||
.element:hover {
|
||||
transform: scale(1.05);
|
||||
} /* gate hover motion — touch fires false hovers on tap */
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
const reduce = useReducedMotion();
|
||||
const closedX = reduce ? 0 : '-100%';
|
||||
const closedX = reduce ? 0 : "-100%";
|
||||
```
|
||||
|
||||
Reduced motion means fewer and gentler animations, not zero — keep transitions that aid comprehension, remove movement/position changes.
|
||||
|
|
|
|||
|
|
@ -58,12 +58,7 @@ export function LandingAgentsBar() {
|
|||
className="group flex h-[82px] w-[112px] shrink-0 flex-col items-center justify-end gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="agent-logo-tile">
|
||||
<img
|
||||
src={agent.src}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
className="agent-logo-image"
|
||||
/>
|
||||
<img src={agent.src} alt="" referrerPolicy="no-referrer" className="agent-logo-image" />
|
||||
</div>
|
||||
<div className="max-w-full truncate font-mono text-[12px] leading-none tracking-[0.04em] text-[color:var(--fg-dim)]">
|
||||
{agent.name}
|
||||
|
|
|
|||
|
|
@ -198,9 +198,7 @@ export function LandingFeatures() {
|
|||
<div className="landing-eyebrow mb-4">What's inside</div>
|
||||
<h2 className="landing-heading">
|
||||
Run the agent you already use.
|
||||
<span className="landing-heading-muted block">
|
||||
AO wraps the workflow around it.
|
||||
</span>
|
||||
<span className="landing-heading-muted block">AO wraps the workflow around it.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
|
|
@ -384,7 +382,6 @@ function AgentHarnessDemo({
|
|||
<TerminalLine success text="activity hooks installed, session visible" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -423,9 +420,7 @@ function AgentSelectLabel({
|
|||
function AgentLogo({ agent, className }: { agent: AgentHarness; className: string }) {
|
||||
if (!agent.logo) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} agent-logo-frame text-xs font-bold text-[color:var(--fg-muted)]`}
|
||||
>
|
||||
<div className={`${className} agent-logo-frame text-xs font-bold text-[color:var(--fg-muted)]`}>
|
||||
{agent.name.slice(0, 1)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -604,7 +599,6 @@ function WorkspaceIsolationDemo({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -773,7 +767,6 @@ function FeedbackRoutingDemo({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -817,7 +810,6 @@ function DaemonControlDemo() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<aside className="bg-[#050506] p-4">
|
||||
|
|
|
|||
|
|
@ -556,10 +556,7 @@ export function LandingHero() {
|
|||
/>
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-5 sm:px-8 lg:px-12 xl:px-16">
|
||||
<div className="mx-auto text-center">
|
||||
<h1
|
||||
data-testid="hero-headline"
|
||||
className="landing-hero-heading mx-auto font-sans"
|
||||
>
|
||||
<h1 data-testid="hero-headline" className="landing-hero-heading mx-auto font-sans">
|
||||
<span className="block">Stop babysitting coding agents.</span>
|
||||
<span className="mt-2 block italic">
|
||||
Start merging <span className="font-[620] text-[#93b4f8]">real work.</span>
|
||||
|
|
|
|||
|
|
@ -198,10 +198,7 @@ export function LandingSocialProof() {
|
|||
<div className="lg:col-span-7">
|
||||
<div className="landing-eyebrow mb-4">In the wild</div>
|
||||
<h2 className="landing-heading">
|
||||
People are already{" "}
|
||||
<span className="landing-heading-muted">
|
||||
building around it.
|
||||
</span>
|
||||
People are already <span className="landing-heading-muted">building around it.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ export function LandingVideo() {
|
|||
<div className="container-page">
|
||||
<div className="landing-section-header mx-auto max-w-[1180px] text-left">
|
||||
<div className="landing-eyebrow mb-4">Demo</div>
|
||||
<h2 className="landing-heading">
|
||||
See it in action
|
||||
</h2>
|
||||
<h2 className="landing-heading">See it in action</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[1180px]">
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"emil-design-eng": {
|
||||
"source": "emilkowalski/skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/emil-design-eng/SKILL.md",
|
||||
"computedHash": "8bdf9e4e6de7a4969147bf4828a4ad2c5aacd9fba4b690b250a85e0467ca387d"
|
||||
},
|
||||
"review-animations": {
|
||||
"source": "emilkowalski/skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/review-animations/SKILL.md",
|
||||
"computedHash": "ca04aaa0a815f05e0910ded8fedbfbdfe0acdec8f52dfbb88277890070d134d4"
|
||||
}
|
||||
}
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"emil-design-eng": {
|
||||
"source": "emilkowalski/skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/emil-design-eng/SKILL.md",
|
||||
"computedHash": "8bdf9e4e6de7a4969147bf4828a4ad2c5aacd9fba4b690b250a85e0467ca387d"
|
||||
},
|
||||
"review-animations": {
|
||||
"source": "emilkowalski/skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/review-animations/SKILL.md",
|
||||
"computedHash": "ca04aaa0a815f05e0910ded8fedbfbdfe0acdec8f52dfbb88277890070d134d4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue