chore: add shared and frontend skills
This commit is contained in:
324
.agents/skills/animate/RECIPES.md
Normal file
324
.agents/skills/animate/RECIPES.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Animation Recipes
|
||||
|
||||
Ready-to-build implementations for the cases that come up most. Start from the recipe, then adapt — don't rebuild from scratch.
|
||||
|
||||
Curves are the `--ease-out`, `--ease-in-out`, and `--ease-drawer` tokens defined in SKILL.md.
|
||||
|
||||
---
|
||||
|
||||
## Button press
|
||||
|
||||
Any pressable element. Instant feedback that the interface heard the user.
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms var(--ease-out);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
`scale()` scales children too — the label and icons come along, which is what makes it read as a physical press.
|
||||
|
||||
No hover gating needed here: `:active` is a real press on touch. Gate any `:hover` styling separately.
|
||||
|
||||
---
|
||||
|
||||
## Dropdown, popover, menu, select
|
||||
|
||||
Scales out of its trigger, not out of thin air.
|
||||
|
||||
```css
|
||||
.popover {
|
||||
transform-origin: var(--transform-origin); /* Base UI supplies this */
|
||||
transition:
|
||||
opacity 200ms var(--ease-out),
|
||||
transform 200ms var(--ease-out);
|
||||
}
|
||||
|
||||
.popover[data-starting-style],
|
||||
.popover[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
```
|
||||
|
||||
The `transform-origin` is the whole point — the panel should look like it came out of the thing you clicked.
|
||||
|
||||
---
|
||||
|
||||
## Tooltip
|
||||
|
||||
Same shape as a popover, faster, plus the detail most implementations miss.
|
||||
|
||||
```css
|
||||
.tooltip {
|
||||
transform-origin: var(--transform-origin);
|
||||
transition:
|
||||
transform 125ms var(--ease-out),
|
||||
opacity 125ms var(--ease-out);
|
||||
}
|
||||
|
||||
.tooltip[data-starting-style],
|
||||
.tooltip[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Once one tooltip is open, neighbours open instantly */
|
||||
.tooltip[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
```
|
||||
|
||||
The initial delay prevents accidental activation. After that, skipping both the delay and the animation makes the whole toolbar feel faster.
|
||||
|
||||
---
|
||||
|
||||
## Modal
|
||||
|
||||
The one popover that stays centered.
|
||||
|
||||
```css
|
||||
.modal {
|
||||
transform-origin: center; /* exempt — not anchored to a trigger */
|
||||
transition:
|
||||
opacity 250ms var(--ease-out),
|
||||
transform 250ms var(--ease-out);
|
||||
}
|
||||
|
||||
.modal[data-starting-style],
|
||||
.modal[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
transition: opacity 250ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
Animate the backdrop's opacity alongside it so they read as one surface.
|
||||
|
||||
---
|
||||
|
||||
## Drawer / sheet
|
||||
|
||||
```css
|
||||
.drawer {
|
||||
transform: translateY(0);
|
||||
transition: transform 500ms var(--ease-drawer);
|
||||
}
|
||||
|
||||
.drawer[data-closed] {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
```
|
||||
|
||||
This is how Vaul hides a drawer before animating it in.
|
||||
|
||||
Add drag and it becomes a gesture problem — see **Drag to dismiss** below.
|
||||
|
||||
---
|
||||
|
||||
## Toast
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 400ms ease,
|
||||
transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `ease` rather than `ease-out`, slightly slower than typical UI: Sonner reads as elegant partly because its motion is tuned to the component's personality rather than to the generic UI budget.
|
||||
- If `@starting-style` isn't available, fall back to the mount flag:
|
||||
|
||||
```jsx
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
// <div data-mounted={mounted}>
|
||||
```
|
||||
|
||||
When toasts stack and the list reflows, the opacity change has to work against the height change. There's no formula for that pair — adjust until it feels right, then check it again the next day.
|
||||
|
||||
---
|
||||
|
||||
## Accordion / collapse
|
||||
|
||||
```css
|
||||
.content {
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height 200ms var(--ease-out),
|
||||
opacity 200ms var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
Keep it short — this is one of the few animations that costs layout on every frame, so a long duration is expensive as well as sluggish. Measure the content height in JS (or use a headless primitive that supplies it) rather than animating to `auto`.
|
||||
|
||||
---
|
||||
|
||||
## Stagger a group entrance
|
||||
|
||||
For a list or grid the user sees occasionally — not for a list they scroll past all day.
|
||||
|
||||
```css
|
||||
.item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: fadeIn 300ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.item:nth-child(2) { animation-delay: 50ms; }
|
||||
.item:nth-child(3) { animation-delay: 100ms; }
|
||||
.item:nth-child(4) { animation-delay: 150ms; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stagger is decorative — it must never block interaction while it plays.
|
||||
|
||||
---
|
||||
|
||||
## Hold to confirm
|
||||
|
||||
For destructive actions where a plain click is too easy to fire by accident.
|
||||
|
||||
```css
|
||||
.overlay {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms var(--ease-out); /* release: snappy */
|
||||
}
|
||||
|
||||
.button:active .overlay {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear; /* press: slow and deliberate */
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
`linear` is correct here — the fill is a progress indicator, and progress shouldn't ease.
|
||||
|
||||
---
|
||||
|
||||
## Tab indicator with a color transition
|
||||
|
||||
Timing individual color transitions across a tab list never quite lands. Clip instead.
|
||||
|
||||
Duplicate the tab list. Style the copy as the active state — different background, different text color. Clip the copy so only the active tab shows, and animate the clip on change:
|
||||
|
||||
```css
|
||||
.tabs-active-copy {
|
||||
clip-path: inset(0 60% 0 20%); /* driven by the active tab's position */
|
||||
transition: clip-path 250ms var(--ease-in-out);
|
||||
}
|
||||
```
|
||||
|
||||
The text and background change together, in perfect sync, because they're one element being revealed rather than two colors being interpolated.
|
||||
|
||||
---
|
||||
|
||||
## Scroll reveal
|
||||
|
||||
Marketing surfaces only. Don't do this to functional UI a user visits daily.
|
||||
|
||||
```css
|
||||
.reveal {
|
||||
clip-path: inset(0 0 100% 0);
|
||||
transition: clip-path 600ms var(--ease-in-out);
|
||||
}
|
||||
|
||||
.reveal[data-visible] {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
```
|
||||
|
||||
Trigger with `IntersectionObserver`, or Motion's `useInView` with `{ once: true, margin: "-100px" }`. Fire it once — re-animating on every scroll-by is an interface fighting its reader.
|
||||
|
||||
---
|
||||
|
||||
## Drag to dismiss
|
||||
|
||||
The gesture recipe. Springs, not durations, because the user can reverse mid-motion.
|
||||
|
||||
```js
|
||||
// Dismiss on a flick, not just on distance
|
||||
const timeTaken = Date.now() - dragStartTime.current;
|
||||
const velocity = Math.abs(swipeAmount) / timeTaken;
|
||||
|
||||
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
|
||||
dismiss();
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Set transform on the dragged element directly.
|
||||
// Driving it through a CSS variable on the parent recalcs styles for every child.
|
||||
element.style.transform = `translateY(${distance}px)`;
|
||||
```
|
||||
|
||||
Four details that separate a good drag from a bad one:
|
||||
|
||||
- **Pointer capture** once the drag starts, so it continues when the pointer leaves the element's bounds.
|
||||
- **Multi-touch protection** — `if (isDragging) return` on new touch points, or switching fingers mid-drag makes the element jump.
|
||||
- **Damping past boundaries** — dragging beyond a natural edge moves the element less the further it goes. Real things slow before they stop.
|
||||
- **Friction, not a wall** — allow the over-drag with rising resistance rather than refusing it.
|
||||
|
||||
Settle with a spring so an interrupted drag keeps its velocity:
|
||||
|
||||
```js
|
||||
{ type: "spring", duration: 0.5, bounce: 0.2 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Masking a crossfade that won't settle
|
||||
|
||||
When two states overlap visibly during a transition and no amount of easing or duration tuning fixes it, blur the seam:
|
||||
|
||||
```css
|
||||
.content {
|
||||
transition:
|
||||
filter 200ms ease,
|
||||
opacity 200ms ease;
|
||||
}
|
||||
|
||||
.content.transitioning {
|
||||
filter: blur(2px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
Without blur the eye reads two distinct objects swapping. Blur blends them into one perceived transformation. Keep it under 20px — heavy blur is expensive, especially in Safari.
|
||||
|
||||
---
|
||||
|
||||
## Programmatic, without a library
|
||||
|
||||
When the motion needs JS control but not a dependency, WAAPI gives you CSS-grade performance:
|
||||
|
||||
```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)' }
|
||||
);
|
||||
```
|
||||
|
||||
Hardware-accelerated, interruptible, no bundle cost.
|
||||
Reference in New Issue
Block a user