All recipes use void tokens and Tailwind 4.3 syntax. Put plain CSS in src/app/globals.css after the direction import, inside @layer components { … } so utilities can still override it. Base already zeroes --duration-base/slow/slower and caps animations under prefers-reduced-motion: reduce; recipes add a motion-specific static frame where needed.
Library-shaped effects are not here: marquee / logo loop, spotlight card, border beam / gradient border with @property, number ticker, magnetic, shiny text, aurora and hero spotlight live in the components skill (lean void translations with tiers). Recipes marked † adapt emilkowalski/skills (MIT, see CREDITS.md).
1. Button press †
<button className="press …">Save</button> // void utility: scale .97 on :active, --duration-fast, ease-outHand-rolled equivalent (e.g. a card link):
<a className="block rounded-xl border border-line-subtle p-6 transition-[scale,border-color] duration-(--duration-fast) ease-out hover:border-line-strong active:scale-99">…</a>scalescales children, which is what makes it read as a physical press.- 0.97 for buttons, 0.99 or none for surfaces wider than 400px. No press on text links or inputs.
2. Hover lift that isn't hover:scale-105
<a href="/changelog/42" className="group block rounded-xl focus-visible:outline-offset-4">
<div className="rounded-xl border border-line-subtle bg-surface p-6 transition-[translate,border-color] duration-(--duration-base) ease-out
pointer-fine:group-hover:-translate-y-0.5 group-hover:border-line-strong">
<h3 className="text-xl text-fg">Branch previews</h3>
<p className="mt-2 text-md text-fg-muted">…</p>
</div>
</a>- The inner element moves; the link box stays put, so the pointer never leaves the hit area mid-animation (no flicker).
- Max 2px of lift. Pair with one other change (border or shadow step), not both.
- Arrow nudge on CTAs:
.
3. Dropdown / popover from the trigger †
Radix (uses keyframes because Radix waits for animationend before unmounting):
@layer components {
.void-popover { transform-origin: var(--radix-popover-content-transform-origin, var(--radix-dropdown-menu-content-transform-origin, top)); }
.void-popover[data-state="open"] { animation: void-pop-in var(--duration-base) var(--ease-out); }
.void-popover[data-state="closed"] { animation: void-pop-out var(--duration-fast) var(--ease-out); }
}
@keyframes void-pop-in { from { opacity: 0; transform: scale(0.96); } }
@keyframes void-pop-out { to { opacity: 0; transform: scale(0.96); } }Base UI (transitions, interruptible):
@layer components {
.void-popup {
transform-origin: var(--transform-origin);
transition: opacity var(--duration-base) var(--ease-out), transform var(--duration-base) var(--ease-out);
}
.void-popup[data-starting-style], .void-popup[data-ending-style] { opacity: 0; transform: scale(0.96); }
.void-popup[data-ending-style] { transition-duration: var(--duration-fast); }
}Native popover (no library):
@layer components {
.void-native-popover {
opacity: 1; transform: none;
transition: opacity var(--duration-base) var(--ease-out), transform var(--duration-base) var(--ease-out),
overlay var(--duration-base) allow-discrete, display var(--duration-base) allow-discrete;
}
.void-native-popover:not(:popover-open) { opacity: 0; transform: scale(0.96); transition-duration: var(--duration-fast); }
@starting-style { .void-native-popover:popover-open { opacity: 0; transform: scale(0.96); } }
}Surface: rounded-xl border border-line bg-surface-raised p-1 shadow-2. Menu items: rounded-md px-2 py-1.5 text-sm data-highlighted:bg-surface-hover with no transition (keyboard-driven highlight is instant).
4. Tooltip †
@layer components {
.void-tooltip {
transform-origin: var(--transform-origin, var(--radix-tooltip-content-transform-origin));
transition: opacity 125ms var(--ease-out), transform 125ms var(--ease-out);
}
.void-tooltip[data-starting-style], .void-tooltip[data-ending-style] { opacity: 0; transform: scale(0.97); }
.void-tooltip[data-instant] { transition-duration: 0ms; } /* Base UI: neighbours after the first open instantly */
}Radix: delayDuration={500} on the provider, skipDelayDuration={300}. Tooltips are never the only place information lives.
5. Dialog (native ) †
@layer components {
dialog.void-dialog {
opacity: 0; transform: scale(0.96);
transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out),
overlay var(--duration-fast) allow-discrete, display var(--duration-fast) allow-discrete;
}
dialog.void-dialog[open] { opacity: 1; transform: none; transition-duration: var(--duration-slow); }
@starting-style { dialog.void-dialog[open] { opacity: 0; transform: scale(0.96); } }
dialog.void-dialog::backdrop {
background: color-mix(in oklch, var(--bg) 70%, transparent);
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out), overlay var(--duration-fast) allow-discrete, display var(--duration-fast) allow-discrete;
}
dialog.void-dialog[open]::backdrop { opacity: 1; transition-duration: var(--duration-slow); }
@starting-style { dialog.void-dialog[open]::backdrop { opacity: 0; } }
}<dialog ref={ref} className="void-dialog m-auto w-full max-w-md rounded-2xl border border-line bg-surface-raised p-6 text-fg shadow-3">…</dialog>
// open: ref.current?.showModal() close: ref.current?.close()Exit uses the closed-state duration (--duration-fast), enter the open-state one (--duration-slow): exits are faster automatically. Use Radix/Base UI Dialog when you need focus return and scroll lock in older Safari (a11y).
6. Drawer / sheet †
@layer components {
.void-drawer { transform: translateY(0); transition: transform 450ms var(--ease-drawer); }
.void-drawer[data-closed], .void-drawer[data-ending-style], .void-drawer[data-starting-style] { transform: translateY(100%); }
}Dragging (if needed):
- Set
element.style.transform = \translateY(${dy}px)`directly inpointermove`; never a CSS variable on the parent (recalculates every child). setPointerCaptureon drag start; ignore extra touch points; damp over-drag (dy < 0 ? dy * 0.2 : dy).- Dismiss when
dy > height * 0.25orMath.abs(dy) / elapsedMs > 0.11. - In a scrollable drawer, only start dragging when
scrollTop === 0, then ignore drags for 100ms after reaching the top. - Prefer Base UI Drawer (maintained) over re-implementing.
7. Toast
Use sonner and wire it to tokens (shadcn's wrapper references var(--popover), which doesn't exist in void):
"use client";
import { Toaster as Sonner } from "sonner";
import type { CSSProperties } from "react";
export function Toaster() {
return (
<Sonner
position="bottom-right"
style={{ "--normal-bg": "var(--surface-raised)", "--normal-text": "var(--fg)", "--normal-border": "var(--line)", "--border-radius": "var(--radius-xl)" } as CSSProperties}
toastOptions={{ classNames: { toast: "font-sans text-sm shadow-2", description: "text-fg-muted" } }}
/>
);
}Hand-rolled (no dependency), transitions not keyframes so rapid additions don't jump:
@layer components {
.void-toast {
transform: translateY(calc(var(--i, 0) * -10px)) scale(calc(1 - var(--i, 0) * 0.05));
opacity: 1;
transition: transform 350ms var(--ease-out), opacity 350ms var(--ease-out);
}
@starting-style { .void-toast { transform: translateY(100%); opacity: 0; } }
.void-toast[data-leaving] { transform: translateY(100%); opacity: 0; transition-duration: var(--duration-slow); }
}Toasts only for results of user actions; pause timers while document.hidden; role="status" region (a11y).
8. Accordion
Zero JS, (animated in Chromium 131+, instant elsewhere):
@layer components {
@media (prefers-reduced-motion: no-preference) {
.void-accordion { interpolate-size: allow-keywords; }
.void-accordion::details-content {
block-size: 0; overflow: clip;
transition: block-size var(--duration-base) var(--ease-out), content-visibility var(--duration-base) allow-discrete;
}
.void-accordion[open]::details-content { block-size: auto; }
}
.void-accordion > summary { list-style: none; cursor: pointer; }
.void-accordion > summary::-webkit-details-marker { display: none; }
.void-accordion .void-chevron { transition: rotate var(--duration-base) var(--ease-out); }
.void-accordion[open] .void-chevron { rotate: 180deg; }
}<details className="void-accordion border-b border-line-subtle py-4" name="faq">
<summary className="flex items-center justify-between gap-4 text-md text-fg">
How is usage billed?
<svg aria-hidden="true" className="void-chevron size-4 text-fg-muted" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="m4 6 4 4 4-4" /></svg>
</summary>
<p className="pt-3 text-md text-fg-muted">Per build minute, rounded up, invoiced monthly.</p>
</details>name="faq" makes it exclusive (one open at a time). Controlled React fallback for all engines:
<div className={cn("grid transition-[grid-template-rows] duration-(--duration-base) ease-out", open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
<div className="overflow-hidden">{children}</div>
</div>grid-template-rows is a layout animation, accepted only for user-initiated accordions; keep it ≤ 250ms.
9. Tab indicator
One indicator, moved with translate + scale (compositor), positioned from the active tab:
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
export function Tabs({ tabs }: { tabs: { id: string; label: string }[] }) {
const [active, setActive] = useState(0);
const [instant, setInstant] = useState(false);
const list = useRef<HTMLDivElement>(null);
const bar = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const place = () => {
const tab = list.current?.querySelectorAll<HTMLElement>('[role="tab"]')[active];
if (!tab || !bar.current) return;
bar.current.style.translate = `${tab.offsetLeft}px 0`;
bar.current.style.scale = `${tab.offsetWidth / 100} 1`; // bar is 100px wide, origin left
};
place();
const ro = new ResizeObserver(place);
if (list.current) ro.observe(list.current);
return () => ro.disconnect();
}, [active]);
return (
<div ref={list} role="tablist" className="relative flex gap-1 border-b border-line-subtle"
onKeyDown={(e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
setInstant(true); // keyboard: no animation
setActive((i) => (i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length);
}}>
{tabs.map((t, i) => (
<button key={t.id} role="tab" aria-selected={i === active} tabIndex={i === active ? 0 : -1}
onClick={() => { setInstant(false); setActive(i); }}
className={cn("px-3 py-2 text-sm transition-colors duration-(--duration-fast)", i === active ? "text-fg" : "text-fg-muted hover:text-fg")}>
{t.label}
</button>
))}
<span ref={bar} aria-hidden="true"
className={cn("absolute bottom-0 left-0 h-0.5 w-[100px] origin-left bg-fg", !instant && "transition-[translate,scale] duration-(--duration-base) ease-in-out")} />
</div>
);
}Pill-shaped active background with a perfect text-color change (after Emil Kowalski): render a second copy of the tab list styled as active (bg-fg text-bg), stack it on top with pointer-events-none aria-hidden, and animate its clip-path: inset(0 R 0 L round var(--radius-md)) to the active tab's box with --duration-base ease-in-out. Text and background change as one element, so they never drift out of sync.
10. Text reveals
Hero lockup on load (LCP-safe): H1 moves but never fades; supporting lines fade + rise with a stagger.
@layer components {
@media (prefers-reduced-motion: no-preference) {
.void-rise { animation: void-rise var(--duration-slower) var(--ease-out-expo) both; animation-delay: calc(var(--i, 0) * 60ms); }
.void-settle { animation: void-settle var(--duration-slower) var(--ease-out-expo) both; }
}
}
@keyframes void-rise { from { opacity: 0; translate: 0 0.75rem; } }
@keyframes void-settle { from { translate: 0 0.3em; } } /* no opacity: the H1 is visible at first paint */<h1 className="void-settle text-display-lg">…</h1>
<p className="void-rise [--i:1] mt-6 text-lg text-fg-muted">…</p>
<div className="void-rise [--i:2] mt-10 flex gap-6">…</div>Masked line rise on scroll (hellohello-style section statements, not the hero). Author the line breaks (lines can't be measured on the server):
const lines = ["We design memorable", "experiences for brands", "that refuse to blend in."];
<p className="void-lines text-display-sm text-fg">
{lines.map((line, i) => (
<span key={i} className="block overflow-clip pb-[0.08em]">
<span className="void-line block" style={{ "--i": i } as React.CSSProperties}>{line}</span>
</span>
))}
</p>@layer components {
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.void-lines { view-timeline-name: --void-lines; }
.void-line {
animation: void-line-rise linear both;
animation-timeline: --void-lines;
animation-range: entry calc(10% + var(--i) * 8%) cover calc(30% + var(--i) * 8%);
}
}
}
}
@keyframes void-line-rise { from { translate: 0 105%; } }Unsupported browsers (Firefox) and reduced motion render the finished statement. Per-word reveals with @starting-style: components (RevealText). Don't split per character (one node per glyph).
Stagger a group entrance (cards appearing once) †: .void-rise with [--i:0]…[--i:5]; items beyond 6 share --i:5.
11. Page transitions (View Transitions)
Next 16 App Router with React 19.2's :
// src/app/template.tsx — crossfade content between routes; header/footer stay outside
import { ViewTransition } from "react"; // if your React types lack it: /// <reference types="react/canary" />
export default function Template({ children }: { children: React.ReactNode }) {
return <ViewTransition default="void-page">{children}</ViewTransition>;
}::view-transition-old(.void-page) { animation: void-vt-out var(--duration-fast) var(--ease-out) both; }
::view-transition-new(.void-page) { animation: void-vt-in var(--duration-slow) var(--ease-out) both; }
@keyframes void-vt-out { to { opacity: 0; } }
@keyframes void-vt-in { from { opacity: 0; translate: 0 0.5rem; } }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}- Shared element (thumbnail → detail): wrap both in
post-${slug}`}>`. ≤ 10 named elements per transition; names unique on the page. - Directional transitions:
(Next 16.2+) and style by type. - Page-level ≤ 400ms. It is enhancement: navigation must work identically without it. Without React integration, use
document.startViewTransition(speed/references/rendering-smoothness.md§4).
12. Theme switch without a transition flash
The template already does this (src/lib/theme.ts head script + src/components/theme-toggle.tsx): set the theme class in a blocking script before paint; on toggle, inject *{transition:none!important}, flip the class, force a reflow, remove the style next frame. Icons swap with dark:hidden/dark:block, not React state. Optional circular reveal: document.startViewTransition(flip) + clip-path on ::view-transition-new(root), skipped under reduced motion.
13. Hold to confirm †
@layer components {
.void-hold { position: relative; overflow: clip; }
.void-hold .void-hold-fill { position: absolute; inset: 0; background: var(--danger); clip-path: inset(0 100% 0 0); transition: clip-path 200ms var(--ease-out); }
.void-hold:active .void-hold-fill { clip-path: inset(0 0 0 0); transition: clip-path 1500ms linear; }
}Fire the action on animationend/a 1500ms timer started on pointerdown and cleared on pointerup/pointerleave. Slow where the user decides, fast where the system responds. Provide a keyboard path (confirm dialog).
14. Masking an imperfect crossfade †
When two states visibly overlap (icon swap, label change), blur the seam: transition: filter 150ms ease, opacity 150ms ease with filter: blur(2px); opacity: 0.7 at the midpoint state. Keep blur ≤ 4px on small elements; never on large areas (Safari cost).
15. Skeleton shimmer
<div className="h-4 w-40 rounded-sm bg-surface-hover motion-safe:animate-pulse" />Static fill under reduced motion (motion-safe:); skeleton height equals the final content height; show only after 150–300ms of loading.