Skip to content

Reference · motion

Motion recipes (paste-ready)

motion/references/recipes.md378 linesupdated 16 Sept 2026

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 †

tsx
<button className="press …">Save</button>           // void utility: scale .97 on :active, --duration-fast, ease-out

Hand-rolled equivalent (e.g. a card link):

tsx
<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>
  • scale scales 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

tsx
<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):

css
@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):

css
@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):

css
@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 †

css
@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 ) †

css
@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; } }
}
tsx
<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 †

css
@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).
  • setPointerCapture on drag start; ignore extra touch points; damp over-drag (dy < 0 ? dy * 0.2 : dy).
  • Dismiss when dy > height * 0.25 or Math.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):

tsx
"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:

css
@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):

css
@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; }
}
tsx
<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:

tsx
<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:

tsx
"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.

css
@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 */
tsx
<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):

tsx
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>
css
@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 :

tsx
// 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>;
}
css
::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 †

css
@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

tsx
<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.