---
title: "Motion recipes (paste-ready) (motion)"
description: "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…"
canonical: https://void-design.vercel.app/docs/motion/recipes
lastModified: 2026-09-16
---

# Motion recipes (paste-ready)

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: `<svg className="transition-[translate] duration-(--duration-fast) ease-out group-hover:translate-x-0.5" />`.

## 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 `<dialog>`) †

```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 in `pointermove`; 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, `<details>`** (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 `<ViewTransition>`:

```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 `<ViewTransition name={\`post-${slug}\`}>`. ≤ 10 named elements per transition; names unique on the page.
- Directional transitions: `<Link href="/next" transitionTypes={["forward"]}>` (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 `<head>` 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.
