Subchapter 1.1
RECIPES.mdMarkdown8 KBView on GitHub
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.
Any pressable element. Instant feedback that the interface heard the user.
.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.
Scales out of its trigger, not out of thin air.
.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.
Same shape as a popover, faster, plus the detail most implementations miss.
.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.
The one popover that stays centered.
.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 {
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 {
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.@starting-style isn’t available, fall back to the mount flag: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.
.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.
For a list or grid the user sees occasionally — not for a list they scroll past all day.
.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.
For destructive actions where a plain click is too easy to fire by accident.
.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.
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:
.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.
Marketing surfaces only. Don’t do this to functional UI a user visits daily.
.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.
The gesture recipe. Springs, not durations, because the user can reverse mid-motion.
// 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();
}// 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:
if (isDragging) return on new touch points, or switching fingers mid-drag makes the element jump.Settle with a spring so an interrupted drag keeps its velocity:
{ type: "spring", duration: 0.5, bounce: 0.2 }When two states overlap visibly during a transition and no amount of easing or duration tuning fixes it, blur the seam:
.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.
When the motion needs JS control but not a dependency, WAAPI gives you CSS-grade performance:
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.