Retour aux skills
jakubantalik/transitions.devContrôle réussi

SKILL DETAIL

transitions-dev

jakubantalik/transitions.dev/transitions-dev

Transitions.dev is a collection of 32 portable CSS transitions, each namespaced under `t-*` selectors with semantic CSS custom properties. These transitions are drop-in: paste the snippet, wire the documented HTML hooks, and you're done. No framework dependencies, no demo-specific markup, and every snippet ships a `prefers-reduced-motion` guard. The collection covers a wide range of UI scenarios, including notification badges, dropdowns, modals, panel reveals, page transitions, card resizes, number pop-ins, text swaps, icon swaps, success checks, avatar group hovers, error state shakes, search/input clear, skeleton loaders, shimmer text, sliding tabs, tooltips, staggered text reveals, card hover tilt, plus-to-menu morph, accordions, toasts, like buttons, learn-more hovers, checkbox checks, spinning counters, toggles, AI thinking states, reasoning streams, streaming text, matrix dot loaders, and banner stacking. Additionally, the skill exposes commands `transitions reveal`, `transitions review`, `transitions apply`, and `transitions refine` to list all transitions, audit a project, install the best-fit transition, and replace hardcoded durations with motion tokens.

Installations · 119Voir la source

Installation

npx skills add https://github.com/jakubantalik/transitions.dev --skill transitions-dev

Fichiers du skill

SKILL.md

Dernière synchronisation · 29 août 2026

_root.css
/* transitions-dev — copy this :root block into your project once.
   Every transition snippet reads from these semantic names. */
:root {
  /* ── Motion tokens — shared scale ──────────────────────────────
     Reference these with var(--…) anywhere in your project. The
     transitions below ship literal values so each snippet works on
     its own; `transitions refine` maps hardcoded values back to
     these tokens. */
  /* Durations */
  --duration-stagger: 40ms;  /* per-item stagger offset */
  --duration-micro: 80ms;  /* tooltip/path delay, shake segment, large stagger */
  --duration-quick: 150ms;  /* modal/dropdown close, text swap, tooltip appear */
  --duration-fast: 250ms;  /* icon swap, dropdown/modal open, tabs sliding, page slide */
  --duration-medium: 350ms;  /* panel close, toast close */
  --duration-slow: 400ms;  /* panel open, skeleton content reveal, input clear */
  --duration-very-slow: 500ms;  /* emphasis moments, badge appear, text reveal, success check */
  /* Easings */
  --ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);  /* modal/dropdown/panel open + close, page slide, resize, position change */
  --ease-in-out: ease-in-out;  /* icon swap, text swap, text reveal, skeleton reveal */
  --ease-out: ease-out;  /* tooltip open / close */
  --ease-linear: linear;  /* shimmer, skeleton pulse, spinner */
  --ease-bounce: cubic-bezier(0.34, 1.36, 0.64, 1);  /* badge pop open */
  --ease-bounce-strong: cubic-bezier(0.34, 3.85, 0.64, 1);  /* bouncy hover-out (avatar return) */
  /* Distances */
  --distance-micro: 4px;  /* text swap */
  --distance-small: 6px;  /* error shake (small segment) */
  --distance-base: 8px;  /* badge diagonal reveal, page slide, error shake (large segment) */
  --distance-medium: 12px;  /* text reveal */
  --distance-large: 30px;  /* check badge appear */
  /* Scales */
  --scale-large: 0.96;  /* modal open / close */
  --scale-medium: 0.97;  /* dropdown open */
  --scale-small: 0.98;  /* tooltip open */
  --scale-tiny: 0.99;  /* dropdown close */
  /* Blur */
  --blur-small: 2px;  /* panel reveal, icon swap, text swap, skeleton reveal, number pop-in */
  --blur-medium: 3px;  /* page slide, text reveal */
  --blur-large: 8px;  /* success check open */

  /* Card resize */
  --resize-dur: 300ms;
  --resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Number pop-in */
  --digit-dur: 500ms;
  --digit-distance: 8px;
  --digit-stagger: 70ms;
  --digit-blur: 2px;
  --digit-ease: cubic-bezier(0.34, 1.45, 0.64, 1);
  --digit-dir-x: 0;
  --digit-dir-y: 1;
  /* Notification badge */
  --badge-slide-dur: 260ms;
  --badge-pop-dur: 500ms;
  --badge-pop-close-dur: 180ms;
  --badge-fade-dur: 400ms;
  --badge-fade-close-dur: 180ms;
  --badge-blur: 2px;
  --badge-offset-x: -8.2px;
  --badge-offset-y: 12.4px;
  --badge-slide-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --badge-pop-ease: cubic-bezier(0.34, 1.36, 0.64, 1);
  --badge-close-ease: cubic-bezier(0.4, 0, 0.2, 1);
  /* Text states swap */
  --text-swap-dur: 150ms;
  --text-swap-translate-y: 4px;
  --text-swap-blur: 2px;
  --text-swap-ease: ease-in-out;
  /* Menu dropdown */
  --dropdown-open-dur: 250ms;
  --dropdown-close-dur: 150ms;
  --dropdown-pre-scale: 0.97;
  --dropdown-closing-scale: 0.99;
  --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Modal open / close */
  --modal-open-dur: 250ms;
  --modal-close-dur: 150ms;
  --modal-scale: 0.96;
  --modal-scale-close: 0.96;
  --modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Panel reveal */
  --panel-open-dur: 400ms;
  --panel-close-dur: 350ms;
  --panel-translate-y: 100px;
  --panel-blur: 2px;
  --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Page side-by-side */
  --page-slide-dur: 250ms;
  --page-fade-dur: 250ms;
  --page-slide-distance: 8px;
  --page-blur: 3px;
  --page-stagger: 0ms;
  --page-exit-enabled: 1;
  --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Icon swap */
  --icon-swap-dur: 250ms;
  --icon-swap-blur: 2px;
  --icon-swap-start-scale: 0.25;
  --icon-swap-ease: ease-in-out;
  /* Success check */
  --check-opacity-dur: 500ms;
  --check-rotate-dur: 500ms;
  --check-rotate-from: 80deg;
  --check-bob-dur: 500ms;
  --check-y-amount: 40px;
  --check-blur-dur: 500ms;
  --check-blur-from: 10px;
  --check-path-dur: 500ms;
  --check-path-delay: 80ms;
  --check-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-opacity: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-rotate: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-bob: cubic-bezier(0.34, 1.35, 0.64, 1);
  --check-ease-path: cubic-bezier(0.22, 1, 0.36, 1);
  /* Avatar group hover */
  --avatar-lift: -4px;
  --avatar-dur: 320ms;
  --avatar-scale: 1.05;
  --avatar-falloff: 0.45;
  --avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
  --avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
  /* Error state shake */
  --shake-distance: 6px;
  --shake-overshoot: 4px;
  --shake-dur-a: 80ms;
  --shake-dur-b: 60ms;
  --shake-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --revert-hold: 3000ms;
  --revert-dur: 280ms;
  /* Input clear with dissolve */
  --clear-dur: 1000ms;
  --clear-out-dur: 400ms;
  --clear-in-dur: 400ms;
  --clear-out-fly: 12px;
  --clear-in-fly: 12px;
  --clear-out-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --clear-in-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --clear-blur: 2px;
  --glow-delay: 50ms;
  --glow-peak-at: 0.15;
  --glow-opacity: 0.42;
  --glow-spread: 1.5;
  /* Skeleton loader and reveal */
  --pulse-dur: 1000ms;
  --pulse-count: 1;
  --pulse-min: 0.5;
  --reveal-dur: 400ms;
  --reveal-blur: 2px;
  --reveal-ease: ease-in-out;
  /* Shimmer text */
  --shimmer-dur: 2000ms;
  --shimmer-base: #7c7c7c;
  --shimmer-highlight: #0d0d0d;
  --shimmer-band: 400%;
  --shimmer-ease: linear;
  /* Tabs sliding */
  --tabs-dur: 250ms;
  --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tabs-text-muted: rgba(15, 15, 15, 0.8);
  --tabs-text-active: #0f0f0f;
  --tabs-bar-bg: #f1f1f1;
  --tabs-pill-bg: #ffffff;
  /* Tooltip open/close */
  --tt-in-dur: 150ms;
  --tt-out-dur: 50ms;
  --tt-scale: 0.98;
  --tt-delay: 80ms;
  --tt-in-ease: ease-out;
  --tt-out-ease: ease-out;
  --tt-bg: #ffffff;
  --tt-fg: #2f2f2f;
  /* Texts reveal */
  --stagger-dur: 500ms;
  --stagger-distance: 12px;
  --stagger-stagger: 40ms;
  --stagger-blur: 3px;
  --stagger-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Card hover tilt */
  --tilt-perspective: 1000px;
  --tilt-return: 1000ms;
  --tilt-return-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tilt-follow: 400ms;
  --tilt-follow-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tilt-glare-opacity: 0.32;
  --tilt-glare-fade: 300ms;
  --tilt-glare-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Plus to menu morph */
  --morph-open-dur: 350ms;
  --morph-close-dur: 250ms;
  --morph-ease: cubic-bezier(0.34, 1.25, 0.64, 1);
  --morph-close-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --morph-r-closed: 40px;
  --morph-r-open: 20px;
  --morph-fade-dur: 200ms;
  --morph-slide: 40px;
  --morph-rotate: 45deg;
  --morph-scale: 0.97;
  --morph-blur: 2px;
  /* Accordion expand */
  --acc-expand: 250ms;
  --acc-collapse: 250ms;
  --acc-chevron: 250ms;
  --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Toast open / close */
  --toast-open: 350ms;
  --toast-close: 250ms;
  --toast-distance: 16px;
  --toast-blur: 2px;
  --toast-scale: 0.97;
  --toast-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Like button */
  --like-color: #f40051;
  --like-fill: 150ms;
  --like-pop: 350ms;
  --like-pop-ease: cubic-bezier(0.34, 1.96, 0.64, 1);
  --like-particle-dur: 600ms;
  --like-particle-dist: 20px;
  --like-particle-size: 2.5px;
  --like-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Learn more hover */
  --learn-shift: 2px;
  --learn-spread: 8deg;
  --learn-in: 350ms;
  --learn-out: 350ms;
  --learn-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Checkbox check */
  --check-box: 150ms;
  --check-draw: 350ms;
  --check-delay: 0ms;
  --check-uncheck: 150ms;
  --check-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Spinning counter */
  --reel-dur: 1400ms;
  --reel-cell: 30px;
  --reel-spin-blur: 3px;
  --reel-stagger: 90ms;
  --reel-ease: cubic-bezier(0.16, 1, 0.3, 1);
  /* Toggle */
  --toggle-dur: 350ms;
  --toggle-travel: 14.66px;
  --toggle-ov1: 1px;
  --toggle-ov2: 0px;
  --toggle-track: 0ms;
  --toggle-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
  /* Thinking states */
  --think-hold: 2000ms;
  --think-swap: 150ms;
  --think-gap: 50ms;
  --think-distance: 8px;
  --think-blur: 2px;
  --think-shimmer: 2000ms;
  --think-base: #7c7c7c;
  --think-highlight: #0d0d0d;
  --think-ease: ease-in-out;
  /* Reasoning stream */
  --reason-hold: 840ms;
  --reason-step: 500ms;
  --reason-lines: 2;
  --reason-fade: 28px;
  --reason-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Streaming text */
  --stream-gap: 60ms;
  --stream-fade: 350ms;
  --stream-blur: 1px;
  --stream-ease: cubic-bezier(0.22, 1, 0.36, 1);
  /* Matrix dot loader */
  --matrix-cycle: 1200ms;
  --matrix-base: #d9d9d9;
  --matrix-active: #85858f;
  --matrix-ease: ease-in-out;
  /* Banner stacking */
  --stack-open: 350ms;
  --stack-close: 250ms;
  --stack-rise: 80px;
  --stack-blur: 2px;
  --stack-scale: 0.97;
  --stack-peek: 12px;
  --stack-spread-gap: 8px;
  --stack-depth-scale: 0.06;
  --stack-depth-fade: 0.4;
  --stack-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
01-card-resize.md
# Card resize

## When to use

Tweening a container's width or height when its layout state changes (compact ↔ expanded card, collapsing panel, list row toggling extra detail). Pure CSS — no JS required beyond the class toggle that drives the size change.

## HTML usage

```html
<div class="t-resize">…</div>
```

Put `.t-resize` on any element and change its width/height
(directly, or via a state class such as `.is-small`). The
transition will tween the two sizes.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--resize-dur` | `300ms` | sourced from `--p4-dur` |
| `--resize-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p4-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --resize-dur: 300ms;
  --resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-resize {
  transition:
    width  var(--resize-dur) var(--resize-ease),
    height var(--resize-dur) var(--resize-ease);
  will-change: width, height;
}

@media (prefers-reduced-motion: reduce) {
  .t-resize { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

02-number-pop-in.md
# Number pop-in

## When to use

Counters, prices, balances, or any number that updates and should re-enter from a direction with blur. Each character animates independently and the last two digits stagger so decimals feel alive without looking chaotic.

## HTML usage

```html
<span class="t-digit-group is-animating">
  <span class="t-digit">1</span>
  <span class="t-digit">2</span>
  <span class="t-digit" data-stagger="1">.</span>
  <span class="t-digit" data-stagger="2">3</span>
</span>
```

Replay:
  - Remove `.is-animating`, re-render digits (or swap text),
    force a reflow, then re-add `.is-animating`.
  - Use data-stagger="1", "2", … to delay individual
    digits by `n * var(--digit-stagger)`.

Direction:
  --digit-dir-x / --digit-dir-y are unit-less multipliers
  (e.g. 1, -1, 0) applied to --digit-distance.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--digit-dur` | `500ms` | sourced from `--p9-dur` |
| `--digit-distance` | `8px` | sourced from `--p9-distance` |
| `--digit-stagger` | `70ms` | sourced from `--p9-stagger` |
| `--digit-blur` | `2px` | sourced from `--p9-blur` |
| `--digit-ease` | `cubic-bezier(0.34, 1.45, 0.64, 1)` | sourced from `--p9-ease` |
| `--digit-dir-x` | `0` | sourced from `--p9-dir-x` |
| `--digit-dir-y` | `1` | sourced from `--p9-dir-y` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --digit-dur: 500ms;
  --digit-distance: 8px;
  --digit-stagger: 70ms;
  --digit-blur: 2px;
  --digit-ease: cubic-bezier(0.34, 1.45, 0.64, 1);
  --digit-dir-x: 0;
  --digit-dir-y: 1;
}
```

## CSS

```css
@keyframes t-digit-pop-in {
  0%   {
    transform: translate(
      calc(var(--digit-distance) * var(--digit-dir-x)),
      calc(var(--digit-distance) * var(--digit-dir-y))
    );
    opacity: 0;
    filter: blur(var(--digit-blur));
  }
  100% { transform: translate(0, 0); opacity: 1; filter: blur(0); }
}

.t-digit-group {
  display: inline-flex;
  align-items: baseline;
}
.t-digit {
  display: inline-block;
  will-change: transform, opacity, filter;
}
.t-digit-group.is-animating .t-digit {
  animation: t-digit-pop-in var(--digit-dur) var(--digit-ease) both;
}
.t-digit-group.is-animating .t-digit[data-stagger="1"] {
  animation-delay: var(--digit-stagger);
}
.t-digit-group.is-animating .t-digit[data-stagger="2"] {
  animation-delay: calc(var(--digit-stagger) * 2);
}

@media (prefers-reduced-motion: reduce) {
  .t-digit-group .t-digit { animation: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Replay the digit pop-in: remove .is-animating, swap the digit spans,
// force a reflow, then re-add .is-animating. Mark the last two digits
// with data-stagger="1" / "2" so they ride in 1× / 2× --digit-stagger
// behind the leading digits.
const group = document.querySelector(".t-digit-group");

function setDigits(str) {
  group.classList.remove("is-animating");
  group.replaceChildren();
  const chars = str.split("");
  chars.forEach((ch, i) => {
    const span = document.createElement("span");
    span.className = "t-digit";
    span.textContent = ch;
    if (i === chars.length - 2) span.dataset.stagger = "1";
    else if (i === chars.length - 1) span.dataset.stagger = "2";
    group.appendChild(span);
  });
  void group.offsetHeight; // force reflow
  group.classList.add("is-animating");
}
```

03-notification-badge.md
# Notification badge

## When to use

A small badge appearing on top of a trigger (bell, inbox, button). Slides in diagonally and pops the dot independently of the trigger so the trigger itself never moves.

## HTML usage

```html
<!-- Place .t-badge inside your trigger (bell icon, button, etc.). -->
<!-- The trigger must be position: relative so .t-badge can anchor to it. -->
<button class="your-trigger" style="position: relative">
  <!-- your icon / trigger contents -->
  <span class="t-badge" data-open="false">
    <span class="t-badge-dot">1</span>
  </span>
</button>
```

State: toggle data-open="true" / "false" on .t-badge.
Only the badge slides + pops — the trigger itself stays put.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--badge-slide-dur` | `260ms` | sourced from `--p1-pos-open-dur` |
| `--badge-pop-dur` | `500ms` | sourced from `--p1-scale-open-dur` |
| `--badge-pop-close-dur` | `180ms` | sourced from `--p1-scale-close-dur` |
| `--badge-fade-dur` | `400ms` | sourced from `--p1-opacity-open-dur` |
| `--badge-fade-close-dur` | `180ms` | sourced from `--p1-opacity-close-dur` |
| `--badge-blur` | `2px` | sourced from `--p1-blur` |
| `--badge-offset-x` | `-8.2px` | sourced from `--p1-distance-x` |
| `--badge-offset-y` | `12.4px` | sourced from `--p1-distance-y` |
| `--badge-slide-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p1-ease-pos-open` |
| `--badge-pop-ease` | `cubic-bezier(0.34, 1.36, 0.64, 1)` | sourced from `--p1-ease-scale-open` |
| `--badge-close-ease` | `cubic-bezier(0.4, 0, 0.2, 1)` | sourced from `--p1-ease-close` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --badge-slide-dur: 260ms;
  --badge-pop-dur: 500ms;
  --badge-pop-close-dur: 180ms;
  --badge-fade-dur: 400ms;
  --badge-fade-close-dur: 180ms;
  --badge-blur: 2px;
  --badge-offset-x: -8.2px;
  --badge-offset-y: 12.4px;
  --badge-slide-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --badge-pop-ease: cubic-bezier(0.34, 1.36, 0.64, 1);
  --badge-close-ease: cubic-bezier(0.4, 0, 0.2, 1);
}
```

## CSS

```css
@keyframes t-badge-slide-in {
  from { transform: translate(var(--badge-offset-x), var(--badge-offset-y)); }
  to   { transform: translate(0, 0); }
}

/* .t-badge is the absolutely-positioned wrapper for the dot.
   Adjust top/right (or left/bottom) to anchor it on your trigger. */
.t-badge {
  position: absolute;
  top: -6px;
  right: -8px;
  pointer-events: none;
  will-change: transform;
}
.t-badge[data-open="true"] {
  animation: t-badge-slide-in var(--badge-slide-dur) var(--badge-slide-ease);
}

.t-badge-dot {
  display: block;
  transform-origin: center;
  transform: scale(1);
  opacity: 1;
  filter: blur(0);
  transition:
    transform var(--badge-pop-dur)  var(--badge-pop-ease),
    opacity   var(--badge-fade-dur) var(--badge-pop-ease),
    filter    var(--badge-pop-dur)  var(--badge-pop-ease);
  will-change: transform, opacity, filter;
}
.t-badge[data-open="false"] .t-badge-dot {
  transform: scale(0);
  opacity: 0;
  filter: blur(var(--badge-blur));
  transition:
    transform var(--badge-pop-close-dur)  var(--badge-close-ease),
    opacity   var(--badge-fade-close-dur) var(--badge-close-ease),
    filter    var(--badge-pop-close-dur)  var(--badge-close-ease);
}

@media (prefers-reduced-motion: reduce) {
  .t-badge, .t-badge-dot { animation: none !important; transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

04-text-states-swap.md
# Text states swap

## When to use

Swapping the text of a status indicator in place — "Processing…" → "Done", "Save" → "Saved". The old text exits up with blur, the new text enters from below.

## HTML usage

```html
<span class="t-text-swap">Processing…</span>
```

Driven by JS (three-phase sequence):
  1. Add `.is-exit`  -> old text slides up + blurs + fades.
  2. After --text-swap-dur: change textContent, then add
     `.is-enter-start` (jumps to below, no transition).
  3. Force reflow, remove `.is-enter-start` so the new text
     animates back to 0 with the default transition.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--text-swap-dur` | `150ms` | sourced from `--p6-dur` |
| `--text-swap-translate-y` | `4px` | sourced from `--p6-translate-y` |
| `--text-swap-blur` | `2px` | sourced from `--p6-blur` |
| `--text-swap-ease` | `ease-in-out` | sourced from `--p6-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --text-swap-dur: 150ms;
  --text-swap-translate-y: 4px;
  --text-swap-blur: 2px;
  --text-swap-ease: ease-in-out;
}
```

## CSS

```css
.t-text-swap {
  display: inline-block;
  transform: translateY(0);
  filter: blur(0);
  opacity: 1;
  transition:
    transform var(--text-swap-dur) var(--text-swap-ease),
    filter    var(--text-swap-dur) var(--text-swap-ease),
    opacity   var(--text-swap-dur) var(--text-swap-ease);
  will-change: transform, filter, opacity;
}
.t-text-swap.is-exit {
  transform: translateY(calc(var(--text-swap-translate-y) * -1));
  filter: blur(var(--text-swap-blur));
  opacity: 0;
}
.t-text-swap.is-enter-start {
  transform: translateY(var(--text-swap-translate-y));
  filter: blur(var(--text-swap-blur));
  opacity: 0;
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .t-text-swap { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Three-phase text swap:
//   1. Add .is-exit              — old text exits up with blur.
//   2. After --text-swap-dur, swap textContent and add .is-enter-start
//      (jumps to "below, no transition"), force a reflow.
//   3. Remove .is-enter-start    — new text animates back to rest.
const el = document.querySelector(".t-text-swap");
const dur = parseFloat(
  getComputedStyle(document.documentElement).getPropertyValue("--text-swap-dur")
) || 200;

function swapText(next) {
  el.classList.add("is-exit");
  setTimeout(() => {
    el.textContent = next;
    el.classList.remove("is-exit");
    el.classList.add("is-enter-start");
    void el.offsetHeight; // force reflow so the next change transitions
    el.classList.remove("is-enter-start");
  }, dur);
}
```

05-menu-dropdown.md
# Menu dropdown

## When to use

Contextual menus, dropdowns, popovers — anything that opens from a trigger and should visually grow from that trigger's position. Origin-aware via `data-origin` (top-left, top-center, top-right, bottom-*).

## HTML usage

```html
<div class="t-dropdown" data-origin="top-center">
  <!-- your menu contents -->
</div>
```

State:
  - Add `.is-open` to show.
  - On close, swap `.is-open` for `.is-closing`, then remove
    `.is-closing` after --dropdown-close-dur.

data-origin values: top-left | top-center | top-right |
                    bottom-left | bottom-center | bottom-right.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--dropdown-open-dur` | `250ms` | sourced from `--p2-open-dur` |
| `--dropdown-close-dur` | `150ms` | sourced from `--p2-close-dur` |
| `--dropdown-pre-scale` | `0.97` | sourced from `--p2-pre-scale` |
| `--dropdown-closing-scale` | `0.99` | sourced from `--p2-closing-scale` |
| `--dropdown-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p2-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --dropdown-open-dur: 250ms;
  --dropdown-close-dur: 150ms;
  --dropdown-pre-scale: 0.97;
  --dropdown-closing-scale: 0.99;
  --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-dropdown {
  transform-origin: top left;
  transform: scale(var(--dropdown-pre-scale));
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--dropdown-open-dur) var(--dropdown-ease),
    opacity   var(--dropdown-open-dur) var(--dropdown-ease);
  will-change: transform, opacity;
}
.t-dropdown[data-origin="top-right"]     { transform-origin: top right; }
.t-dropdown[data-origin="top-center"]    { transform-origin: top center; }
.t-dropdown[data-origin="bottom-left"]   { transform-origin: bottom left; }
.t-dropdown[data-origin="bottom-center"] { transform-origin: bottom center; }
.t-dropdown[data-origin="bottom-right"]  { transform-origin: bottom right; }

.t-dropdown.is-open {
  transform: scale(1);
  opacity: 1;
  pointer-events: auto;
}
.t-dropdown.is-closing {
  transform: scale(var(--dropdown-closing-scale));
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--dropdown-close-dur) var(--dropdown-ease),
    opacity   var(--dropdown-close-dur) var(--dropdown-ease);
}

@media (prefers-reduced-motion: reduce) {
  .t-dropdown { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Toggle .is-open / .is-closing with a setTimeout cleanup so the closing
// scale animates before the element resets to its pre-open rest state.
const dropdown = document.querySelector(".t-dropdown");
const closeMs = parseFloat(
  getComputedStyle(document.documentElement).getPropertyValue("--dropdown-close-dur")
) || 150;

function openDropdown() {
  dropdown.classList.remove("is-closing");
  dropdown.classList.add("is-open");
}
function closeDropdown() {
  dropdown.classList.remove("is-open");
  dropdown.classList.add("is-closing");
  setTimeout(() => dropdown.classList.remove("is-closing"), closeMs);
}
```

06-modal.md
# Modal open / close

## When to use

Modal dialogs and full-overlay surfaces that scale up from center. Use when the surface is conceptually "on top of" the page rather than anchored to a trigger.

## HTML usage

```html
<div class="t-modal" role="dialog">…</div>
```

State:
  - Add `.is-open` to open (scales up from --modal-scale).
  - On close, swap `.is-open` for `.is-closing`, then remove
    `.is-closing` after --modal-close-dur.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--modal-open-dur` | `250ms` | sourced from `--p7-open-dur` |
| `--modal-close-dur` | `150ms` | sourced from `--p7-close-dur` |
| `--modal-scale` | `0.96` | sourced from `--p7-scale` |
| `--modal-scale-close` | `0.96` | sourced from `--p7-scale-close` |
| `--modal-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p7-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --modal-open-dur: 250ms;
  --modal-close-dur: 150ms;
  --modal-scale: 0.96;
  --modal-scale-close: 0.96;
  --modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-modal {
  transform-origin: center;
  transform: scale(var(--modal-scale));
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--modal-open-dur) var(--modal-ease),
    opacity   var(--modal-open-dur) var(--modal-ease);
  will-change: transform, opacity;
}
.t-modal.is-open {
  transform: scale(1);
  opacity: 1;
  pointer-events: auto;
}
.t-modal.is-closing {
  transform: scale(var(--modal-scale-close));
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--modal-close-dur) var(--modal-ease),
    opacity   var(--modal-close-dur) var(--modal-ease);
}

@media (prefers-reduced-motion: reduce) {
  .t-modal { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Same close-then-cleanup pattern as the dropdown — modals scale from
// --modal-scale up to 1, then on close dip to --modal-scale-close.
const modal = document.querySelector(".t-modal");
const closeMs = parseFloat(
  getComputedStyle(document.documentElement).getPropertyValue("--modal-close-dur")
) || 150;

function openModal() {
  modal.classList.remove("is-closing");
  modal.classList.add("is-open");
}
function closeModal() {
  modal.classList.remove("is-open");
  modal.classList.add("is-closing");
  setTimeout(() => modal.classList.remove("is-closing"), closeMs);
}
```

07-panel-reveal.md
# Panel reveal

## When to use

A panel that slides into view inside an existing container — e.g. detail panel inside a card, expanding section. Combines a short translate, opacity, and a 2px cross-blur so a half-height travel still reads as a full open.

## HTML usage

```html
<div class="t-panel-slide" data-open="false">
  <!-- your panel contents -->
</div>
```

The panel slides on the Y axis, fades opacity 0 ↔ 1,
and cross-blurs --panel-blur ↔ 0, all on the same
duration / ease so a shorter travel (e.g. 50% of the
panel height) still reads as a full open / close.
Wrap it in your own container with `overflow: hidden`
if you want the closed state fully clipped. Set
--panel-translate-y to the travel distance (e.g. half
the panel's own height).

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--panel-open-dur` | `400ms` | sourced from `--p3-open-dur` |
| `--panel-close-dur` | `350ms` | sourced from `--p3-close-dur` |
| `--panel-translate-y` | `100px` | sourced from `--p3-translate-y` |
| `--panel-blur` | `2px` | sourced from `--p3-blur` |
| `--panel-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p3-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --panel-open-dur: 400ms;
  --panel-close-dur: 350ms;
  --panel-translate-y: 100px;
  --panel-blur: 2px;
  --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-panel-slide {
  transform: translateY(var(--panel-translate-y));
  opacity: 0;
  filter: blur(var(--panel-blur));
  pointer-events: none;
  transition:
    transform var(--panel-close-dur) var(--panel-ease),
    opacity   var(--panel-close-dur) var(--panel-ease),
    filter    var(--panel-close-dur) var(--panel-ease);
  will-change: transform, opacity, filter;
}
.t-panel-slide[data-open="true"] {
  transform: translateY(0);
  opacity: 1;
  filter: blur(0);
  pointer-events: auto;
  transition:
    transform var(--panel-open-dur) var(--panel-ease),
    opacity   var(--panel-open-dur) var(--panel-ease),
    filter    var(--panel-open-dur) var(--panel-ease);
}

@media (prefers-reduced-motion: reduce) {
  .t-panel-slide { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

08-page-side-by-side.md
# Page side-by-side

## When to use

Sliding between two full pages or screens that live side-by-side: list ↔ detail, step 1 ↔ step 2 in a wizard. Page 1 exits left, page 2 exits right.

## HTML usage

```html
<div class="t-page-slide" data-page="1">
  <section class="t-page" data-page-id="1">…</section>
  <section class="t-page" data-page-id="2">…</section>
</div>
```

State: set data-page="1" or "2" on .t-page-slide.
Page 1 exits to the left, page 2 exits to the right.
--page-exit-enabled (0/1) disables the outgoing slide.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--page-slide-dur` | `250ms` | sourced from `--p8-slide-dur` |
| `--page-fade-dur` | `250ms` | sourced from `--p8-fade-dur` |
| `--page-slide-distance` | `8px` | sourced from `--p8-distance` |
| `--page-blur` | `3px` | sourced from `--p8-blur` |
| `--page-stagger` | `0ms` | sourced from `--p8-stagger` |
| `--page-exit-enabled` | `1` | sourced from `--p8-exit-enabled` |
| `--page-slide-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p8-slide-ease` |
| `--page-fade-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p8-fade-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --page-slide-dur: 250ms;
  --page-fade-dur: 250ms;
  --page-slide-distance: 8px;
  --page-blur: 3px;
  --page-stagger: 0ms;
  --page-exit-enabled: 1;
  --page-slide-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --page-fade-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-page-slide {
  position: relative;
}
.t-page-slide .t-page[data-page-id="1"] {
  --t-page-from-x: calc(var(--page-slide-distance) * -1);
}
.t-page-slide .t-page[data-page-id="2"] {
  --t-page-from-x: var(--page-slide-distance);
}
.t-page-slide .t-page {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  transform: translateX(calc(var(--t-page-from-x, 0px) * var(--page-exit-enabled)));
  filter: blur(calc(var(--page-blur) * var(--page-exit-enabled)));
  transition:
    opacity   var(--page-fade-dur)  var(--page-fade-ease),
    transform var(--page-slide-dur) var(--page-slide-ease),
    filter    var(--page-slide-dur) var(--page-slide-ease);
  will-change: opacity, transform, filter;
}
.t-page-slide[data-page="1"] .t-page[data-page-id="1"],
.t-page-slide[data-page="2"] .t-page[data-page-id="2"] {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(0);
  filter: blur(0);
  transition-delay: var(--page-stagger);
}

@media (prefers-reduced-motion: reduce) {
  .t-page-slide .t-page { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Flip data-page on the container — the CSS handles the rest.
// Set --page-exit-enabled: 0 on the container if you want pages to
// fade without sliding (useful on first paint).
const slider = document.querySelector(".t-page-slide");
function showPage(n) {
  slider.setAttribute("data-page", String(n));
}
```

09-icon-swap.md
# Icon swap

## When to use

Cross-fading two icons in the same slot — hamburger ↔ close, sun ↔ moon, play ↔ pause, expand ↔ collapse. Both icons stay in the DOM stacked in the same grid cell.

## HTML usage

```html
<div class="t-icon-swap" data-state="a">
  <span class="t-icon" data-icon="a">…</span>
  <span class="t-icon" data-icon="b">…</span>
</div>
```

State: set data-state="a" or "b" on .t-icon-swap.
The matching .t-icon fades in; the other fades out with blur
and scale.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--icon-swap-dur` | `250ms` | sourced from `--p5-dur` |
| `--icon-swap-blur` | `2px` | sourced from `--p5-blur` |
| `--icon-swap-start-scale` | `0.25` | sourced from `--p5-start-scale` |
| `--icon-swap-ease` | `ease-in-out` | sourced from `--p5-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --icon-swap-dur: 250ms;
  --icon-swap-blur: 2px;
  --icon-swap-start-scale: 0.25;
  --icon-swap-ease: ease-in-out;
}
```

## CSS

```css
.t-icon-swap {
  position: relative;
  display: inline-grid;
}
.t-icon-swap .t-icon {
  grid-area: 1 / 1;
  transition:
    opacity   var(--icon-swap-dur) var(--icon-swap-ease),
    filter    var(--icon-swap-dur) var(--icon-swap-ease),
    transform var(--icon-swap-dur) var(--icon-swap-ease);
  will-change: opacity, filter, transform;
}
.t-icon-swap[data-state="a"] .t-icon[data-icon="a"],
.t-icon-swap[data-state="b"] .t-icon[data-icon="b"] {
  opacity: 1;
  filter: blur(0);
  transform: scale(1);
}
.t-icon-swap[data-state="a"] .t-icon[data-icon="b"],
.t-icon-swap[data-state="b"] .t-icon[data-icon="a"] {
  opacity: 0;
  filter: blur(var(--icon-swap-blur));
  transform: scale(var(--icon-swap-start-scale));
}

@media (prefers-reduced-motion: reduce) {
  .t-icon-swap .t-icon { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

10-success-check.md
# Success check

## When to use

Confirming a completed action — payment processed, file uploaded, message sent, form saved. The icon fades in, rotates upright, settles with a Y-bob, and (for SVG icons) draws its path stroke. Use whenever a status changes from "pending / unknown" to "success" and you want the moment to feel earned rather than instantaneous.

The snippet covers the **appear transition only** — bring your own hide behavior (e.g. unmount, opacity:0, or a custom exit). This is intentional: success states are usually persistent, and a soft fade-out is rarely worth the extra DOM/JS surface.

## HTML usage

```html
<!-- Wrap your icon (SVG, image, anything) in .t-success-check.
     The wrapper drives fade + rotate + blur + Y-bob; if your
     icon is an SVG <path>, it gets the stroke-draw animation.
     Bring your own icon size / colors. -->
<span class="t-success-check" data-state="out" aria-hidden="true">
  <svg viewBox="0 0 48 48" fill="none">
    <!-- your icon path(s) here -->
  </svg>
</span>
```

Trigger:
  - Cold load is data-state="out" (opacity 0; no animation).
  - Show: set data-state="in" (fade + rotate + blur + Y-bob
    + path draw run in parallel).

Snippet covers the appear transition only — bring your own
hide behavior (e.g. unmount, opacity:0, or a custom exit).

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--check-opacity-dur` | `500ms` | sourced from `--p10-opacity-dur` |
| `--check-rotate-dur` | `500ms` | sourced from `--p10-rotate-dur` |
| `--check-rotate-from` | `80deg` | sourced from `--p10-rotate-from` |
| `--check-bob-dur` | `500ms` | sourced from `--p10-bob-dur` |
| `--check-y-amount` | `40px` | sourced from `--p10-y-amount` |
| `--check-blur-dur` | `500ms` | sourced from `--p10-blur-dur` |
| `--check-blur-from` | `10px` | sourced from `--p10-blur-from` |
| `--check-path-dur` | `500ms` | sourced from `--p10-path-dur` |
| `--check-path-delay` | `80ms` | sourced from `--p10-path-delay` |
| `--check-ease-out` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p10-ease-out` |
| `--check-ease-opacity` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p10-ease-opacity` |
| `--check-ease-rotate` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p10-ease-rotate` |
| `--check-ease-bob` | `cubic-bezier(0.34, 1.35, 0.64, 1)` | sourced from `--p10-ease-bob` |
| `--check-ease-path` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p10-ease-path` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --check-opacity-dur: 500ms;
  --check-rotate-dur: 500ms;
  --check-rotate-from: 80deg;
  --check-bob-dur: 500ms;
  --check-y-amount: 40px;
  --check-blur-dur: 500ms;
  --check-blur-from: 10px;
  --check-path-dur: 500ms;
  --check-path-delay: 80ms;
  --check-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-opacity: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-rotate: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-bob: cubic-bezier(0.34, 1.35, 0.64, 1);
  --check-ease-path: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
/* Wrapper drives the appear animation; it doesn't own any
   sizing or color so you can drop in any icon. */
.t-success-check {
  display: inline-block;
  transform-origin: center;
  opacity: 0;
  will-change: transform, opacity, filter;
}
/* overflow: visible keeps the stroke from clipping while it
   draws; display: block kills the inline whitespace under SVGs. */
.t-success-check svg { display: block; overflow: visible; }
/* Stroke-draw setup. Replace 20 with the result of
   path.getTotalLength() for your path; round caps mean any
   sub-pixel overshoot is invisible. */
.t-success-check svg path {
  stroke-dasharray: 20;
  stroke-dashoffset: 20;
}

.t-success-check[data-state="in"] {
  animation:
    t-check-fade   var(--check-opacity-dur) var(--check-ease-opacity) forwards,
    t-check-rotate var(--check-rotate-dur)  var(--check-ease-rotate)  forwards,
    t-check-blur   var(--check-blur-dur)    var(--check-ease-out)     forwards,
    t-check-bob    var(--check-bob-dur)     var(--check-ease-bob)     forwards;
}
.t-success-check[data-state="in"] svg path {
  animation: t-check-draw var(--check-path-dur) var(--check-ease-path) var(--check-path-delay, 0ms) forwards;
}

@keyframes t-check-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes t-check-rotate {
  from { transform: rotate(var(--check-rotate-from)); }
  to   { transform: rotate(0deg); }
}
@keyframes t-check-blur {
  from { filter: blur(var(--check-blur-from)); }
  to   { filter: blur(0); }
}
@keyframes t-check-bob {
  from { translate: 0 var(--check-y-amount); }
  to   { translate: 0 0; }
}
@keyframes t-check-draw { to { stroke-dashoffset: 0; } }

@media (prefers-reduced-motion: reduce) {
  .t-success-check { animation: none !important; opacity: 1; }
  .t-success-check svg path { animation: none !important; stroke-dashoffset: 0 !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Cold-load → "out" (no animation). On show, flip to "in".
// Replay-on-retrigger: reset to "out", force a reflow, then flip
// back to "in" so the keyframes restart from offset 0.
const check = document.querySelector(".t-success-check");

function showCheck() {
  check.setAttribute("data-state", "out");
  void check.offsetWidth; // force reflow so keyframes restart
  check.setAttribute("data-state", "in");
}

// If the icon is mounted unconditionally and only shown after some
// event (e.g. await save()), the simpler form is enough:
//   check.setAttribute("data-state", "in");
// The reflow trick only matters when you replay the appear from
// an already-visible state.
```

### Calibrating `stroke-dasharray` for your path

The CSS hardcodes `stroke-dasharray: 20` as a placeholder. For a clean draw, replace 20 with the actual length of **your** path (in user units), measured once with `path.getTotalLength()`. Two ways to do it:

1. **Static (recommended)** — measure the path in the browser console once, then paste the rounded-up integer into the CSS:

   ```js
   document.querySelector(".t-success-check svg path").getTotalLength()
   // → 19.42 → use stroke-dasharray: 20 (round up by 1px for safety)
   ```

2. **Dynamic** — measure on mount and set both properties inline. Use this when paths vary per-render:

   ```js
   const path = wrapper.querySelector("svg path");
   const len = Math.ceil(path.getTotalLength());
   path.style.strokeDasharray = String(len);
   path.style.strokeDashoffset = String(len);
   ```

If the dasharray is too short the stroke pre-reveals before the animation starts; too long and the path appears to draw past its end before fading in. Round up by 1px to absorb sub-pixel float jitter.

11-avatar-group-hover.md
# Avatar group hover

## When to use

Hovering an item in a horizontal stack (avatar row, chip group, badge cluster, segmented button) should lift the hovered item, gently lift its neighbors with a power-falloff, then snap everything back with an overshoot spring on `mouseleave`. Direction-aware easing (clean ease-in on hover, bouncy ease-out on return) is what gives the group its springy, physical feel.

Equally good for: pill stacks in a tag editor, chips in a filter bar, reaction-emoji rows, anywhere a horizontal row benefits from a "comb" interaction signal.

## HTML usage

```html
<!-- Apply .t-avatar to each item in your group (avatar,
     chip, badge, button — anything). Bring your own size,
     shape, and stacking; this stylesheet only owns the
     hover transform + transition. -->
<div class="t-avatar-group">
  <div class="t-avatar"><!-- your item --></div>
  <div class="t-avatar"><!-- your item --></div>
  <!-- … -->
</div>
```

Wire-up (vanilla JS):
  On `mouseenter` of any .t-avatar, walk every sibling and
  set inline:
    el.style.setProperty('--shift',
      (lift * Math.pow(falloff, distance)).toFixed(3) + 'px');
    el.style.setProperty('--scale-active',
      i === activeIdx ? scale : 1);
  Set transition-timing-function inline BEFORE the
  variable writes — use --avatar-ease-in on hover-in and
  --avatar-ease-out on the root's `mouseleave` (resets
  --shift to 0 and --scale-active to 1).

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--avatar-lift` | `-4px` | sourced from `--p11-lift` |
| `--avatar-dur` | `320ms` | sourced from `--p11-dur` |
| `--avatar-scale` | `1.05` | sourced from `--p11-scale` |
| `--avatar-falloff` | `0.45` | sourced from `--p11-falloff` |
| `--avatar-ease-in` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p11-ease-in` |
| `--avatar-ease-out` | `cubic-bezier(0.34, 3.85, 0.64, 1)` | sourced from `--p11-ease-out` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --avatar-lift: -4px;
  --avatar-dur: 320ms;
  --avatar-scale: 1.05;
  --avatar-falloff: 0.45;
  --avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
  --avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
}
```

## CSS

```css
/* Hover-spring transition only — bring your own avatar/chip
   styling (size, shape, border, stacking, background). */
.t-avatar {
  transform-origin: center;
  /* translateY before scale so scale doesn't amplify the lift offset. */
  transform:
    translateY(var(--shift, 0px))
    scale(var(--scale-active, 1));
  transition: transform var(--avatar-dur) var(--avatar-ease-in);
  will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
  .t-avatar { transition: none !important; transform: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Distance-falloff lift with direction-aware easing. The trick
// is setting transition-timing-function inline BEFORE writing the
// CSS variables — the browser uses whatever timing-function is
// current at the moment a transitionable property changes, so this
// gives us ease-in on the way up and a bouncy spring on the return
// without two separate transition declarations.
const root = document.querySelector(".t-avatar-group");
const avatars = Array.from(root.querySelectorAll(".t-avatar"));
const cs = getComputedStyle(document.documentElement);
const num = (name, fb) => {
  const v = parseFloat(cs.getPropertyValue(name));
  return Number.isFinite(v) ? v : fb;
};
const ease = (name, fb) =>
  cs.getPropertyValue(name).trim() || fb;

function setShifts(activeIdx, phase) {
  const lift    = num("--avatar-lift", -4);
  const falloff = num("--avatar-falloff", 0.45);
  const scale   = num("--avatar-scale", 1.05);
  const tf      = phase === "out"
    ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")
    : ease("--avatar-ease-in",  "cubic-bezier(0.22, 1, 0.36, 1)");

  avatars.forEach((el, i) => {
    el.style.transitionTimingFunction = tf;
    if (activeIdx == null) {
      el.style.setProperty("--shift", "0px");
      el.style.setProperty("--scale-active", "1");
      return;
    }
    const d = Math.abs(i - activeIdx);
    el.style.setProperty(
      "--shift",
      (lift * Math.pow(falloff, d)).toFixed(3) + "px"
    );
    el.style.setProperty(
      "--scale-active",
      i === activeIdx ? String(scale) : "1"
    );
  });
}

avatars.forEach((el, i) => {
  el.addEventListener("mouseenter", () => setShifts(i, "in"));
});
root.addEventListener("mouseleave", () => setShifts(null, "out"));
```

### React form

```jsx
import { useRef } from "react";

// `items` is any list of React nodes (avatars, chips, badges, …)
// — this hook only owns the hover-spring transition. Each item is
// wrapped in a .t-avatar so it picks up the transform/transition
// rules from CSS.
export function AvatarGroup({ items }) {
  const rootRef = useRef(null);

  const setShifts = (activeIdx, phase) => {
    if (!rootRef.current) return;
    const cs = getComputedStyle(document.documentElement);
    const num = (name, fb) => {
      const v = parseFloat(cs.getPropertyValue(name));
      return Number.isFinite(v) ? v : fb;
    };
    const ease = (name, fb) =>
      cs.getPropertyValue(name).trim() || fb;

    const lift    = num("--avatar-lift", -4);
    const falloff = num("--avatar-falloff", 0.45);
    const scale   = num("--avatar-scale", 1.05);
    const tf      = phase === "out"
      ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")
      : ease("--avatar-ease-in",  "cubic-bezier(0.22, 1, 0.36, 1)");

    rootRef.current.querySelectorAll(".t-avatar").forEach((el, i) => {
      el.style.transitionTimingFunction = tf;
      if (activeIdx == null) {
        el.style.setProperty("--shift", "0px");
        el.style.setProperty("--scale-active", "1");
        return;
      }
      const d = Math.abs(i - activeIdx);
      el.style.setProperty(
        "--shift",
        (lift * Math.pow(falloff, d)).toFixed(3) + "px"
      );
      el.style.setProperty(
        "--scale-active",
        i === activeIdx ? String(scale) : "1"
      );
    });
  };

  return (
    <div ref={rootRef} onMouseLeave={() => setShifts(null, "out")}>
      {items.map((node, i) => (
        <div
          key={i}
          className="t-avatar"
          onMouseEnter={() => setShifts(i, "in")}
        >
          {node}
        </div>
      ))}
    </div>
  );
}
```

### Why the timing-function is set inline before the variable writes

Both the lift (hover-in) and the return (mouseleave) animate the same property — `transform`. If we declared one fixed `transition-timing-function` in CSS, both directions would share it. Setting it inline immediately before mutating `--shift` / `--scale-active` means each new transition picks up the timing-function that was current at the moment the property changed, giving us a clean curve on the way up and a bouncy overshoot on the way back without a second `.is-leaving` class.

12-error-state-shake.md
# Error state shake

## When to use

Form validation feedback — invalid email, wrong password, missing required field, mismatched confirmation. The input shakes left/right with overshoot, the border switches to error color, and a message reveals beneath. After a hold timer (long enough to read the message), border + message fade back to neutral. Optional: typing into the input cancels the auto-revert immediately.

The `t-` snippet is also a fit for any "this is wrong, try again" moment that needs a percussive hint without an OS-level alert — a wrong-PIN field on a lock screen, a duplicate-tag warning in a tag editor, a "name already taken" username field.

## HTML usage

```html
<!-- Apply .t-input-wrap to your wrapper, .t-input to the
     element that should shake (your input field, its
     bordered wrapper — whatever owns the visible border),
     and .t-error-msg to the message you want to reveal.
     Bring your own sizing, padding, border colors, and
     typography. -->
<div class="t-input-wrap">
  <div class="t-input">
    <input type="text">
  </div>
  <p class="t-error-msg">Please enter a valid email.</p>
</div>
```

Trigger:
  - Add `.is-error` to .t-input-wrap and .t-input. Your
    own border-color rules drive the visible color; this
    stylesheet only owns the tween.
  - Restart the shake by removing `.is-shaking` from
    .t-input, forcing a reflow, then re-adding it.
  - Optional: after --revert-hold ms, drop both
    `.is-error` classes so border + message fade back
    to neutral over --revert-dur.

Per-segment ease: each keyframe stop carries its own
animation-timing-function so each leg follows the Figma
cubic-bezier curve independently.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--shake-distance` | `6px` | sourced from `--p12-shake-distance` |
| `--shake-overshoot` | `4px` | sourced from `--p12-shake-overshoot` |
| `--shake-dur-a` | `80ms` | sourced from `--p12-shake-dur-a` |
| `--shake-dur-b` | `60ms` | sourced from `--p12-shake-dur-b` |
| `--shake-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p12-shake-ease` |
| `--revert-hold` | `3000ms` | sourced from `--p12-revert-hold` |
| `--revert-dur` | `280ms` | sourced from `--p12-revert-dur` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --shake-distance: 6px;
  --shake-overshoot: 4px;
  --shake-dur-a: 80ms;
  --shake-dur-b: 60ms;
  --shake-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --revert-hold: 3000ms;
  --revert-dur: 280ms;
}
```

## CSS

```css
/* Border-color tween. Define your input's default / focused
   / error border-color in your own component CSS — this rule
   only owns the interpolation. Use a constant border-width
   across states so the tween never shifts inner content. */
.t-input {
  transition: border-color 150ms ease-out;
  will-change: transform;
}
.t-input.is-error {
  /* Error border auto-reverts on the hold timer, so the
     fade-out uses the slower revert duration (matches the
     message fade). */
  transition: border-color var(--revert-dur, 280ms) ease-out;
}

/* Error message reveal. Visibility is delayed by --revert-dur
   on hide so the message stays painted for the full opacity
   fade-out. Entering .is-error drops the delay to 0 so the
   message becomes visible immediately. */
.t-error-msg {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity    var(--revert-dur, 280ms) ease-out,
    visibility 0s linear var(--revert-dur, 280ms);
}
.t-input-wrap.is-error .t-error-msg {
  opacity: 1;
  visibility: visible;
  transition:
    opacity    var(--revert-dur, 280ms) ease-out,
    visibility 0s linear 0s;
}

/* Multi-segment keyframe with per-stop easing so each leg
   of the shake follows its own cubic-bezier independently.
   %-stops are cumulative durations as a fraction of the
   total (80, 60, 80, 60 = 280ms): 28.57%, 57.14%, 78.57%,
   100%. Recompute if any segment duration changes. */
.t-input.is-shaking {
  animation: t-input-shake calc(
      var(--shake-dur-a) * 2 + var(--shake-dur-b) * 2
    ) linear;
}
@keyframes t-input-shake {
  0%      { transform: translateX(0);                                 animation-timing-function: var(--shake-ease); }
  28.57%  { transform: translateX(var(--shake-distance));             animation-timing-function: var(--shake-ease); }
  57.14%  { transform: translateX(calc(var(--shake-distance) * -1)); animation-timing-function: var(--shake-ease); }
  78.57%  { transform: translateX(var(--shake-overshoot));            animation-timing-function: var(--shake-ease); }
  100%    { transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
  .t-input { animation: none !important; transform: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Trigger the error state, replay the shake, and schedule the
// auto-revert. Cancel any in-flight revert so the timer always
// tracks the latest call.
const wrap = document.querySelector(".t-input-wrap");
const input = wrap.querySelector(".t-input");

const cs = getComputedStyle(document.documentElement);
const ms = (name, fb) => {
  const v = parseFloat(cs.getPropertyValue(name));
  return Number.isFinite(v) ? v : fb;
};

function showError() {
  wrap.classList.add("is-error");
  input.classList.add("is-error");

  // Replay the shake from a clean baseline.
  input.classList.remove("is-shaking");
  void input.offsetWidth; // force reflow
  input.classList.add("is-shaking");

  const shakeMs =
    ms("--shake-dur-a", 80) * 2 +
    ms("--shake-dur-b", 60) * 2;
  setTimeout(() => input.classList.remove("is-shaking"), shakeMs + 20);

  // Auto-revert: hold long enough to read the message, then fade
  // border + message back to neutral via the CSS transitions.
  if (wrap._revertTimer) clearTimeout(wrap._revertTimer);
  const hold = ms("--revert-hold", 3000);
  wrap._revertTimer = setTimeout(() => {
    wrap._revertTimer = null;
    wrap.classList.remove("is-error");
    input.classList.remove("is-error");
  }, shakeMs + hold);
}

// Optional but recommended: typing cancels the auto-revert and
// clears the error so the user isn't shaking at a value they're
// already correcting.
const inputEl = wrap.querySelector("input, textarea");
  inputEl?.addEventListener("input", () => {
  if (wrap._revertTimer) {
    clearTimeout(wrap._revertTimer);
    wrap._revertTimer = null;
  }
  wrap.classList.remove("is-error");
  input.classList.remove("is-error");
});
```

### Recomputing the keyframe stops

The `%`-stops in `@keyframes t-input-shake` are cumulative leg durations as a fraction of the total. The default leg pattern is **A, A, B, B** — the two big-swing legs (right peak → left peak) take `--shake-dur-a` each, the two recovery legs (left peak → overshoot → rest) take `--shake-dur-b` each:

```
total                = 2·A + 2·B  =  2·80 + 2·60 = 280ms
stop 1 (start)       =   0  / 280 =   0%      (rest)
stop 2 (after A)     =  80  / 280 =  28.57%   (peak right,    +distance)
stop 3 (after 2·A)   = 160  / 280 =  57.14%   (peak left,    -distance)
stop 4 (after 2·A+B) = 220  / 280 =  78.57%   (overshoot,   +overshoot)
stop 5 (end)         = 280  / 280 = 100%      (rest)
```

The total in the CSS uses `calc(var(--shake-dur-a) * 2 + var(--shake-dur-b) * 2)` — so the math stays consistent with the variables, but the **percentages** are baked literals. If you tune `--shake-dur-a` and `--shake-dur-b` to a different ratio, recompute the percentages by hand or the legs will drift out of sync with the duration calc.

### Why three classes (`.is-error` on wrap + input, `.is-shaking` on input)

- `.is-error` on `.t-input-wrap` controls the **message** visibility — the message lives in the wrap, not the input.
- `.is-error` on `.t-input` controls the **border color** — the input owns the border.
- `.is-shaking` on `.t-input` is **separate** from `.is-error` so you can replay the shake (remove → reflow → add) without flickering the error state on/off in the same tick. Keeping the shake state orthogonal also lets you trigger the shake on its own (e.g. for a "hint" jiggle) without the full error treatment.

13-input-clear-dissolve.md
# Input clear with dissolve

## When to use

Clearing a text field — search box, filter input, any field with a clear (×) button. The typed text flies down + blurs + fades while a soft per-word streak ignites under each word, and the placeholder falls in from above. Per-frame JS is required: the streak envelope and per-word gradient stack cannot be expressed as static @keyframes.

## HTML usage

```html
<!-- Drop .t-clear on a wrapper that you've sized like an
     input field (positioning, padding, border, radius are
     yours). Inside it stack a real <input>, a mirror that
     visualizes the value, a fake placeholder for the new
     empty state, and a glow layer that gets the per-word
     radial-gradient stack written into it from JS. -->
<div class="t-clear has-value">
  <input type="text" value="…" />
  <div class="t-clear-mirror" aria-hidden="true">…</div>
  <div class="t-clear-placeholder" aria-hidden="true">Search</div>
  <div class="t-clear-glow" aria-hidden="true"></div>
  <button class="t-clear-btn" aria-label="Clear">…</button>
</div>
```

Pair with a small JS routine (mirrors the gallery code) that
flips `.is-clearing`, animates the mirror's translateY/opacity
per frame, mirrors the math on the placeholder, and writes a
stack of `radial-gradient(...)` layers onto .t-clear-glow's
background so each word gets its own streak. Per-frame JS is
unavoidable: the streak's rise/peak/fall envelope cannot be
expressed as a static @keyframe.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--clear-dur` | `1000ms` | sourced from `--p13-clear-dur` |
| `--clear-out-dur` | `400ms` | sourced from `--p13-text-out-dur` |
| `--clear-in-dur` | `400ms` | sourced from `--p13-text-in-dur` |
| `--clear-out-fly` | `12px` | sourced from `--p13-text-out-fly` |
| `--clear-in-fly` | `12px` | sourced from `--p13-text-in-fly` |
| `--clear-out-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p13-text-out-ease` |
| `--clear-in-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p13-text-in-ease` |
| `--clear-blur` | `2px` | sourced from `--p13-blur` |
| `--glow-delay` | `50ms` | sourced from `--p13-glow-delay` |
| `--glow-peak-at` | `0.15` | sourced from `--p13-glow-peak-at` |
| `--glow-opacity` | `0.42` | sourced from `--p13-glow-opacity` |
| `--glow-spread` | `1.5` | sourced from `--p13-glow-spread` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --clear-dur: 1000ms;
  --clear-out-dur: 400ms;
  --clear-in-dur: 400ms;
  --clear-out-fly: 12px;
  --clear-in-fly: 12px;
  --clear-out-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --clear-in-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --clear-blur: 2px;
  --glow-delay: 50ms;
  --glow-peak-at: 0.15;
  --glow-opacity: 0.42;
  --glow-spread: 1.5;
}
```

## CSS

```css
/* The wrap clips the glow to its rounded box. The hairline
   border is `inset` so it sits inside that clip — when the
   glow's mix-blend-mode darkens its area, the border
   underneath darkens with it. Bring your own width / height /
   border-radius / surface color. */
.t-clear {
  position: relative;
  overflow: hidden;
}
.t-clear-mirror,
.t-clear-placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  pointer-events: none;
  white-space: nowrap;
  overflow: hidden;
  z-index: 2;
}
.t-clear-mirror { opacity: 0; }
.t-clear.has-value .t-clear-mirror,
.t-clear.is-clearing .t-clear-mirror { opacity: 1; }
/* Hide the input's own glyphs while the mirror owns them so
   the cleared text doesn't double-render with the fly-up. */
.t-clear.has-value > input,
.t-clear.is-clearing > input {
  -webkit-text-fill-color: transparent;
}
.t-clear.has-value .t-clear-placeholder { opacity: 0; }
/* The streak overlay: empty by default; JS writes a stack of
   `radial-gradient(...)` layers into background during a clear,
   then animates opacity. mix-blend-mode: multiply darkens the
   underlying input + hairline; flip to `screen` in dark mode
   so the same alpha values lighten instead of vanish. */
.t-clear-glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  z-index: 3;
  mix-blend-mode: multiply;
}

/* The transitions live in JS (per-frame transform/opacity/
   filter writes), so this stylesheet only owns the resting
   state + the variables that JS reads. Read them with
   `parseFloat(getComputedStyle(root).getPropertyValue(...))`
   so live tweaks apply on the next clear without a reload. */

@media (prefers-reduced-motion: reduce) {
  .t-clear-glow { opacity: 0 !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// One clear routine per .t-clear. Reads timing/geometry from the
// CSS variables each call so live tweaks apply without a reload.
const root = document.documentElement;
const num = (name, fb) => {
  const v = parseFloat(getComputedStyle(root).getPropertyValue(name));
  return Number.isFinite(v) ? v : fb;
};
// Minimal cubic-bezier(x1,y1,x2,y2) sampler so JS easing matches CSS.
function bezier(str) {
  const m = String(str).match(/cubic-bezier\(([-\d.]+),([-\d.]+),([-\d.]+),([-\d.]+)\)/);
  if (!m) return (t) => t;
  const [x1, y1, x2, y2] = m.slice(1).map(parseFloat);
  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
  return (t) => {
    if (t <= 0) return 0;
    if (t >= 1) return 1;
    let s = t;
    for (let i = 0; i < 8; i++) {
      const dx = ((ax * s + bx) * s + cx) * s - t;
      const d = (3 * ax * s + 2 * bx) * s + cx;
      if (Math.abs(dx) < 1e-6 || d === 0) break;
      s -= dx / d;
    }
    return ((ay * s + by) * s + cy) * s;
  };
}

document.querySelectorAll(".t-clear").forEach((wrap) => {
  const input  = wrap.querySelector("input");
  const mirror = wrap.querySelector(".t-clear-mirror");
  const phold  = wrap.querySelector(".t-clear-placeholder");
  const glow   = wrap.querySelector(".t-clear-glow");
  const btn    = wrap.querySelector(".t-clear-btn");
  const canvas = document.createElement("canvas").getContext("2d");
  let clearing = false;

  const sync = () => {
    const has = input.value.length > 0;
    wrap.classList.toggle("has-value", has);
    if (has) mirror.textContent = input.value.replace(/ /g, "\u00a0");
  };

  function buildGlow(text) {
    canvas.font = getComputedStyle(input).font;
    const isDark = root.getAttribute("data-theme") === "dark";
    const rgb = isDark ? "255,255,255" : "0,0,0";
    const w = wrap.clientWidth || 280;
    const padLeft = parseFloat(getComputedStyle(input).paddingLeft) || 12;
    const spread = num("--glow-spread", 1.5);
    const layers = [];
    let x = 0;
    text.split(/(\s+)/).forEach((seg) => {
      const segW = canvas.measureText(seg).width;
      if (seg.trim()) {
        const cx = padLeft + x + segW / 2;
        const hw = Math.max(segW * 0.45, 8) * spread;
        [[0, 0.8, 7, 0.22], [hw * 0.45, 0.55, 8, 0.18],
         [-hw * 0.4, 0.65, 6, 0.16], [hw * 0.15, 0.9, 5, 0.14]]
          .forEach(([dx, rwm, rh, a]) => {
            const lx = (((cx + dx) / w) * 100).toFixed(2);
            layers.push(
              `radial-gradient(ellipse ${Math.max(hw * rwm, 2).toFixed(1)}px ${rh}px at ${lx}% 100%, rgba(${rgb},${a}), transparent)`
            );
          });
      }
      x += segW;
    });
    return layers.join(", ");
  }

  function clearWithAnimation() {
    if (clearing || !input.value) return;
    clearing = true;
    const keepFocus = document.activeElement === input;
    mirror.textContent = input.value.replace(/ /g, "\u00a0");

    const total = num("--clear-dur", 1000);
    const outDur = num("--clear-out-dur", 400);
    const inDur  = num("--clear-in-dur", 400);
    const outFly = num("--clear-out-fly", 12);
    const inFly  = num("--clear-in-fly", 12);
    const blur   = num("--clear-blur", 2);
    const delay  = num("--glow-delay", 50);
    const peakAt = num("--glow-peak-at", 0.15);
    const gOp    = num("--glow-opacity", 0.42);
    const easeOut = bezier(getComputedStyle(root).getPropertyValue("--clear-out-ease"));
    const easeIn  = bezier(getComputedStyle(root).getPropertyValue("--clear-in-ease"));

    input.value = "";
    wrap.classList.remove("has-value");
    wrap.classList.add("is-clearing");
    glow.style.background = buildGlow(mirror.textContent);
    glow.style.opacity = "0";
    phold.style.transform = `translateY(-${inFly}px)`;
    phold.style.opacity = "0.9";
    phold.style.filter = `blur(${blur}px)`;

    const t0 = performance.now();
    (function tick(now) {
      const el = now - t0;
      const eo = easeOut(Math.min(1, el / outDur));
      mirror.style.transform = `translateY(${(eo * outFly).toFixed(1)}px)`;
      mirror.style.opacity = (1 - eo).toFixed(3);
      mirror.style.filter = `blur(${(eo * blur).toFixed(1)}px)`;

      const ei = easeIn(Math.min(1, el / inDur));
      phold.style.transform = `translateY(${(-inFly + ei * inFly).toFixed(1)}px)`;
      phold.style.opacity = (0.9 + ei * 0.1).toFixed(3);
      phold.style.filter = `blur(${(blur - ei * blur).toFixed(1)}px)`;

      let g = 0;
      if (el > delay) {
        const gp = Math.min(1, (el - delay) / Math.max(1, total - delay));
        g = gp < peakAt ? gp / peakAt : 1 - (gp - peakAt) / (1 - peakAt);
      }
      glow.style.opacity = (g * gOp).toFixed(3);

      if (el < total) {
        requestAnimationFrame(tick);
      } else {
        wrap.classList.remove("is-clearing");
        [mirror, phold].forEach((el) => (el.style.cssText = ""));
        mirror.textContent = "";
        glow.style.opacity = "0";
        glow.style.background = "";
        clearing = false;
        if (keepFocus) requestAnimationFrame(() => input.focus({ preventScroll: true }));
      }
    })(performance.now());
  }

  const keep = (e) => { if (document.activeElement === input) e.preventDefault(); };
  btn.addEventListener("pointerdown", keep);
  btn.addEventListener("mousedown", keep);
  btn.addEventListener("click", clearWithAnimation);
  input.addEventListener("input", sync);
  sync();
});
```

### Dark mode

The glow uses `mix-blend-mode: multiply` in light mode. In dark mode flip to `screen`, bump `--glow-opacity` to ~0.85, and paint **white** gradients in JS — multiply over a dark surface vanishes.

14-skeleton-reveal.md
# Skeleton loader and reveal

## When to use

A placeholder that loads then reveals real content — list rows, cards, profile headers. The skeleton pulses, then both layers cross-fade with a matching cross-blur. Bring your own bars / avatar / text; the skeleton stays in the same slot as the content so the swap is layout-free.

## HTML usage

```html
<div class="t-skel" data-state="loading">
  <div class="t-skel-skeleton is-pulsing">…</div>
  <div class="t-skel-content">…</div>
</div>
```

State:
  - Mount with `.is-pulsing` on the skeleton so it pulses
    --pulse-count times.
  - When data arrives, add `.is-revealed` to .t-skel — the
    skeleton fades out + blurs and the content fades in +
    un-blurs over --reveal-dur.
  - To replay the loading state without animating the
    reverse: add `.is-resetting` to .t-skel, remove
    `.is-revealed`, force a reflow, then drop `.is-resetting`.

Bring your own avatar / text / wrapping. The skeleton stays
in the same flex slot as the content so the swap is
layout-free.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--pulse-dur` | `1000ms` | sourced from `--p14-pulse-dur` |
| `--pulse-count` | `1` | sourced from `--p14-pulse-count` |
| `--pulse-min` | `0.5` | sourced from `--p14-pulse-min` |
| `--reveal-dur` | `400ms` | sourced from `--p14-reveal-dur` |
| `--reveal-blur` | `2px` | sourced from `--p14-reveal-blur` |
| `--reveal-ease` | `ease-in-out` | sourced from `--p14-reveal-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --pulse-dur: 1000ms;
  --pulse-count: 1;
  --pulse-min: 0.5;
  --reveal-dur: 400ms;
  --reveal-blur: 2px;
  --reveal-ease: ease-in-out;
}
```

## CSS

```css
/* The wrap stacks two layers on the same coordinates. The
   skeleton owns the cold pulse + the fade-out side of the
   reveal; the content owns the fade-in side. They share the
   same duration / ease so the swap reads as one motion. */
.t-skel { position: relative; }
.t-skel-skeleton,
.t-skel-content {
  position: absolute;
  inset: 0;
}

.t-skel-skeleton {
  z-index: 1;
  opacity: 1;
  filter: blur(0);
  transition:
    opacity var(--reveal-dur) var(--reveal-ease),
    filter  var(--reveal-dur) var(--reveal-ease);
}
.t-skel-content {
  z-index: 2;
  opacity: 0;
  filter: blur(var(--reveal-blur));
  transition:
    opacity var(--reveal-dur) var(--reveal-ease),
    filter  var(--reveal-dur) var(--reveal-ease);
}
.t-skel.is-revealed .t-skel-skeleton {
  opacity: 0;
  filter: blur(var(--reveal-blur));
}
.t-skel.is-revealed .t-skel-content {
  opacity: 1;
  filter: blur(0);
}
/* Snap-back when replaying: kill transitions so the reverse
   (revealed → skeleton) is instant. Drop `.is-resetting`
   after a forced reflow and the next reveal animates again. */
.t-skel.is-resetting .t-skel-skeleton,
.t-skel.is-resetting .t-skel-content {
  transition: none !important;
}

/* Pulse: place the animation on the bar/avatar children, not
   on the skeleton itself, so the skeleton's opacity / filter
   stay free for the cross-fade transition above. */
.t-skel-skeleton.is-pulsing > * {
  animation: t-skel-pulse var(--pulse-dur) ease-in-out var(--pulse-count);
}
@keyframes t-skel-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: var(--pulse-min); }
}

@media (prefers-reduced-motion: reduce) {
  .t-skel-skeleton, .t-skel-content {
    transition: none !important;
  }
  .t-skel-skeleton.is-pulsing > * { animation: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
const skel = document.querySelector(".t-skel");
const skeleton = skel.querySelector(".t-skel-skeleton");
const cs = getComputedStyle(document.documentElement);
const num = (name, fb) => {
  const v = parseFloat(cs.getPropertyValue(name));
  return Number.isFinite(v) ? v : fb;
};

// Call when async data arrives:
function reveal() {
  skel.classList.add("is-revealed");
}

// Demo replay: snap back, pulse, then reveal.
function replay() {
  skel.classList.add("is-resetting");
  skel.classList.remove("is-revealed");
  skeleton.classList.remove("is-pulsing");
  void skeleton.offsetWidth;
  skel.classList.remove("is-resetting");
  skeleton.classList.add("is-pulsing");
  const total = num("--pulse-dur", 1000) * num("--pulse-count", 1);
  setTimeout(() => skel.classList.add("is-revealed"), total);
}
```

15-shimmer-text.md
# Shimmer text

## When to use

A loading / "thinking" label that shimmers — streaming status, "Generating…", any in-progress copy that should feel alive without a spinner. Pure CSS: duplicate the string into `data-text` on `.t-shimmer` and tune `--shimmer-base` / `--shimmer-highlight` per theme.

## HTML usage

```html
<!-- Duplicate the visible string into data-text so the
     ::before layer can mask the gradient onto the same
     glyphs. Keep them in sync if the text changes. -->
<span class="t-shimmer" data-text="Planning next moves">
  Planning next moves
</span>
```

Pure CSS — no JS, no class toggling. Tune --shimmer-base /
--shimmer-highlight in your own theme rules so the colors
follow light / dark mode.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--shimmer-dur` | `2000ms` | sourced from `--p15-dur` |
| `--shimmer-base` | `#7c7c7c` | sourced from `--p15-base` |
| `--shimmer-highlight` | `#0d0d0d` | sourced from `--p15-highlight` |
| `--shimmer-band` | `400%` | sourced from `--p15-band` |
| `--shimmer-ease` | `linear` | sourced from `--p15-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --shimmer-dur: 2000ms;
  --shimmer-base: #7c7c7c;
  --shimmer-highlight: #0d0d0d;
  --shimmer-band: 400%;
  --shimmer-ease: linear;
}
```

## CSS

```css
/* Two-layer construction:
   1. The base text renders normally in --shimmer-base.
   2. ::before duplicates it via content: attr(data-text),
      paints a transparent → highlight → transparent gradient
      onto it, and clips that gradient to the glyphs via
      background-clip: text. Animating background-position
      sweeps the band across the text. */
.t-shimmer {
  position: relative;
  display: inline-block;
  color: var(--shimmer-base);
}
.t-shimmer::before {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: linear-gradient(
    90deg,
    transparent          0%,
    transparent         40%,
    var(--shimmer-highlight) 50%,
    transparent         60%,
    transparent        100%
  );
  background-size: var(--shimmer-band) 100%;
  background-repeat: no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  animation: t-shimmer var(--shimmer-dur) var(--shimmer-ease) infinite;
}
@keyframes t-shimmer {
  0%   { background-position: 100% 0; }
  100% { background-position: 0% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .t-shimmer::before { animation: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

16-tabs-sliding.md
# Tabs sliding

## When to use

A segmented control / tab bar where the active pill slides between options — view switchers, filter segments, small mutually-exclusive button sets. JS writes the active tab's `offsetLeft` / `offsetWidth` onto the pill; CSS owns the tween.

## HTML usage

```html
<div class="t-tabs" role="tablist">
  <span class="t-tabs-pill" aria-hidden="true"></span>
  <button class="t-tab" role="tab" aria-selected="true">Plan</button>
  <button class="t-tab" role="tab" aria-selected="false">Debug</button>
  <button class="t-tab" role="tab" aria-selected="false">Ask</button>
</div>
```

Wire-up:
  On click, flip aria-selected on each tab and write the
  active tab's offsetLeft / offsetWidth onto the pill:
    pill.style.transform = `translateX(${tab.offsetLeft}px)`;
    pill.style.width     = `${tab.offsetWidth}px`;
  On first paint and resize, write the same values WITHOUT
  a transition (suspend with `transition: none`, force a
  reflow, restore) so the pill snaps to position before any
  animation can run.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--tabs-dur` | `250ms` | sourced from `--p16-dur` |
| `--tabs-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p16-ease` |
| `--tabs-text-muted` | `rgba(15, 15, 15, 0.8)` | sourced from `--p16-text-muted` |
| `--tabs-text-active` | `#0f0f0f` | sourced from `--p16-text-active` |
| `--tabs-bar-bg` | `#f1f1f1` | sourced from `--p16-bar-bg` |
| `--tabs-pill-bg` | `#ffffff` | sourced from `--p16-pill-bg` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --tabs-dur: 250ms;
  --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tabs-text-muted: rgba(15, 15, 15, 0.8);
  --tabs-text-active: #0f0f0f;
  --tabs-bar-bg: #f1f1f1;
  --tabs-pill-bg: #ffffff;
}
```

## CSS

```css
/* The bar is just a flex container with padding for the pill
   to sit inside. Tabs sit on z-index: 1, the pill on z-index: 0,
   so labels read above the pill background. */
.t-tabs {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 3px;
  border-radius: 48px;
  background: var(--tabs-bar-bg);
}
.t-tab {
  position: relative;
  appearance: none;
  border: 0;
  background: transparent;
  height: 30px;
  padding: 4px 12px;
  color: var(--tabs-text-muted);
  cursor: pointer;
  border-radius: 48px;
  z-index: 1;
  transition: color var(--tabs-dur) var(--tabs-ease);
}
.t-tab:not([aria-selected="true"]):hover,
.t-tab[aria-selected="true"] {
  color: var(--tabs-text-active);
}

/* The pill: width + transform are written inline by JS so
   the transition tweens between the previous and next
   measured positions. */
.t-tabs-pill {
  position: absolute;
  top: 3px;
  left: 0;
  height: 30px;
  width: 0;
  background: var(--tabs-pill-bg);
  border-radius: 48px;
  transform: translateX(0);
  transition:
    transform var(--tabs-dur) var(--tabs-ease),
    width     var(--tabs-dur) var(--tabs-ease);
  will-change: transform, width;
  z-index: 0;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .t-tabs-pill, .t-tab { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
const bar = document.querySelector(".t-tabs");
const pill = bar.querySelector(".t-tabs-pill");
const tabs = [...bar.querySelectorAll(".t-tab")];

function moveTo(tab, animate) {
  if (!animate) {
    const prev = pill.style.transition;
    pill.style.transition = "none";
    pill.style.transform = `translateX(${tab.offsetLeft}px)`;
    pill.style.width = `${tab.offsetWidth}px`;
    void pill.offsetWidth;
    pill.style.transition = prev;
  } else {
    pill.style.transform = `translateX(${tab.offsetLeft}px)`;
    pill.style.width = `${tab.offsetWidth}px`;
  }
}
const active = () =>
  tabs.find((t) => t.getAttribute("aria-selected") === "true") || tabs[0];

tabs.forEach((tab) => {
  tab.addEventListener("click", () => {
    tabs.forEach((t) =>
      t.setAttribute("aria-selected", t === tab ? "true" : "false")
    );
    moveTo(tab, true);
  });
});
requestAnimationFrame(() => moveTo(active(), false));
window.addEventListener("resize", () => moveTo(active(), false));
```

17-tooltip.md
# Tooltip open/close

## When to use

A hover/focus tooltip that fades + scales in with a short appear-delay but disappears immediately on leave. Pure CSS — the wrap (not the trigger) is the hover target so the pointer can drift onto the tooltip without flicker.

## HTML usage

```html
<span class="t-tt-wrap">
  <button class="t-tt-trigger" aria-describedby="tt-1">…</button>
  <span class="t-tt" id="tt-1" role="tooltip">Tooltip text</span>
</span>
```

Pure CSS — no JS. The wrap (not the trigger) is the hover
target so the pointer can drift onto the tooltip without
flicker. transition-delay only applies in the hover/focus
rule, so leaving snaps it to 0 and the disappear plays
immediately. Trigger styling is yours; only the tooltip
positioning + its transition live here.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--tt-in-dur` | `150ms` | sourced from `--p17-in-dur` |
| `--tt-out-dur` | `50ms` | sourced from `--p17-out-dur` |
| `--tt-scale` | `0.98` | sourced from `--p17-scale-from` |
| `--tt-delay` | `80ms` | sourced from `--p17-delay` |
| `--tt-in-ease` | `ease-out` | sourced from `--p17-in-ease` |
| `--tt-out-ease` | `ease-out` | sourced from `--p17-out-ease` |
| `--tt-bg` | `#ffffff` | sourced from `--p17-bg` |
| `--tt-fg` | `#2f2f2f` | sourced from `--p17-fg` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --tt-in-dur: 150ms;
  --tt-out-dur: 50ms;
  --tt-scale: 0.98;
  --tt-delay: 80ms;
  --tt-in-ease: ease-out;
  --tt-out-ease: ease-out;
  --tt-bg: #ffffff;
  --tt-fg: #2f2f2f;
}
```

## CSS

```css
.t-tt-wrap {
  position: relative;
  display: inline-block;
}
.t-tt {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translate(-50%, 0) scale(var(--tt-scale));
  transform-origin: 50% 100%;
  padding: 8px 12px;
  border-radius: 8px;
  background: var(--tt-bg);
  color: var(--tt-fg);
  white-space: nowrap;
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.06),
    0 2px 6px 0 rgba(0, 0, 0, 0.05),
    0 4px 42px 0 rgba(0, 0, 0, 0.06);
  opacity: 0;
  pointer-events: none;
  /* Default rule controls the LEAVE state. transition-delay
     stays unset so leaving plays without delay. */
  transition:
    opacity   var(--tt-out-dur) var(--tt-out-ease),
    transform var(--tt-out-dur) var(--tt-out-ease);
}
/* The 50ms delay belongs ONLY to the hover rule so leaving
   the trigger snaps the delay back to 0 and the disappear
   plays immediately. */
.t-tt-wrap:hover .t-tt,
.t-tt-trigger:focus-visible + .t-tt {
  opacity: 1;
  transform: translate(-50%, 0) scale(1);
  transition-duration: var(--tt-in-dur);
  transition-timing-function: var(--tt-in-ease);
  transition-delay: var(--tt-delay);
}

@media (prefers-reduced-motion: reduce) {
  .t-tt { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

18-texts-reveal.md
# Texts reveal

## When to use

A headline + supporting line that rise into view with staggered blur — hero copy, empty states, onboarding steps. Exit is decoupled: a single quiet fade with no Y-return so dismissing doesn't replay the reveal in reverse.

## HTML usage

```html
<div class="t-stagger">
  <strong class="t-stagger-line t-stagger-line--1">…</strong>
  <span class="t-stagger-line t-stagger-line--2">…</span>
</div>
```

State:
  - Add `.is-shown` to play the staggered entrance.
  - Add `.is-hiding` (and remove `.is-shown`) to fade
    out in place over a short 200ms — independent of the
    entrance timing so the exit doesn't replay the stagger.

Add more lines by adding `.t-stagger-line--N` with
`transition-delay: calc(var(--stagger-stagger) * (N - 1))`.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--stagger-dur` | `500ms` | sourced from `--p18-dur` |
| `--stagger-distance` | `12px` | sourced from `--p18-distance` |
| `--stagger-stagger` | `40ms` | sourced from `--p18-stagger` |
| `--stagger-blur` | `3px` | sourced from `--p18-blur` |
| `--stagger-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p18-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --stagger-dur: 500ms;
  --stagger-distance: 12px;
  --stagger-stagger: 40ms;
  --stagger-blur: 3px;
  --stagger-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
/* Lines start translated down + blurred + invisible; .is-shown
   on the parent flips them to their resting state. The second
   line's transition-delay holds it back by --stagger-stagger
   so the eye lands on the headline first. */
.t-stagger-line {
  display: block;
  opacity: 0;
  transform: translateY(var(--stagger-distance));
  filter: blur(var(--stagger-blur));
  transition:
    opacity   var(--stagger-dur) var(--stagger-ease),
    transform var(--stagger-dur) var(--stagger-ease),
    filter    var(--stagger-dur) var(--stagger-ease);
  will-change: transform, opacity, filter;
}
.t-stagger-line--2 { transition-delay: var(--stagger-stagger); }

.t-stagger.is-shown .t-stagger-line {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}
/* Exit decouples from the stagger: same fade for every line,
   no Y return, no blur — so the disappearance reads as a
   single quiet fade instead of a reverse reveal. */
.t-stagger.is-hiding .t-stagger-line {
  opacity: 0;
  transform: translateY(0);
  filter: blur(0);
  transition:
    opacity 200ms ease,
    transform 0s linear,
    filter 0s linear;
  transition-delay: 0s;
}

@media (prefers-reduced-motion: reduce) {
  .t-stagger-line { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
const block = document.querySelector(".t-stagger");

function showText() {
  block.classList.remove("is-hiding");
  block.classList.remove("is-shown");
  void block.offsetHeight;
  block.classList.add("is-shown");
}
function hideText() {
  block.classList.add("is-hiding");
  block.classList.remove("is-shown");
  setTimeout(() => block.classList.remove("is-hiding"), 200);
}
```

19-card-tilt.md
# Card hover tilt

## When to use

A card / tile / media surface that tilts in 3D toward the pointer while hovered, with a soft light "glare" tracking the cursor across it. Use for product cards, credit / membership cards, feature tiles, cover art — anything that should feel physical and reactive on hover. Pointer-only (skips touch) and flattens under reduced motion.

The pointer is tracked on an **outer flat wrapper** (`.t-tilt`) that never transforms, so the tilting card can't rotate its own edges out from under the cursor (which causes hover flicker). The inner `.t-tilt-card` is the element that actually rotates.

## HTML usage

```html
<div class="t-tilt">                <!-- flat hit area -->
  <div class="t-tilt-card">       <!-- the element that tilts -->
    … card content …
    <div class="t-tilt-glare"></div>
  </div>
</div>
```

Track the pointer on the OUTER `.t-tilt` (it never
transforms) and write four custom properties from JS:
  el.style.setProperty('--tilt-rx', rxDeg + 'deg');
  el.style.setProperty('--tilt-ry', ryDeg + 'deg');
  el.style.setProperty('--tilt-gx', gxPct + '%');
  el.style.setProperty('--tilt-gy', gyPct + '%');
Add `.is-tilting` while moving (fast follow) and
`.is-hover` to fade the glare in; remove both on leave.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--tilt-perspective` | `1000px` | sourced from `--p19-perspective` |
| `--tilt-return` | `1000ms` | sourced from `--p19-return-dur` |
| `--tilt-return-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-return-ease` |
| `--tilt-follow` | `400ms` | sourced from `--p19-follow-dur` |
| `--tilt-follow-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-follow-ease` |
| `--tilt-glare-opacity` | `0.32` | sourced from `--p19-glare-opacity` |
| `--tilt-glare-fade` | `300ms` | sourced from `--p19-glare-fade` |
| `--tilt-glare-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-glare-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --tilt-perspective: 1000px;
  --tilt-return: 1000ms;
  --tilt-return-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tilt-follow: 400ms;
  --tilt-follow-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --tilt-glare-opacity: 0.32;
  --tilt-glare-fade: 300ms;
  --tilt-glare-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
/* The outer wrapper is the flat hit area; touch-action:none
   lets a finger drag tilt the card instead of scrolling the
   page, so tap-hold-drag works on mobile. */
.t-tilt { touch-action: none; }
/* The card tilts toward the pointer via rotateX/rotateY fed
   from JS; on leave it eases back to flat. A separate
   .is-tilting class swaps in a short linear follow while the
   pointer moves so the tilt tracks the cursor 1:1. */
.t-tilt-card {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  transform:
    perspective(var(--tilt-perspective))
    rotateX(var(--tilt-rx, 0deg))
    rotateY(var(--tilt-ry, 0deg));
  transform-style: preserve-3d;
  transition: transform var(--tilt-return) var(--tilt-return-ease);
  will-change: transform;
}
.t-tilt-card.is-tilting {
  transition: transform var(--tilt-follow) var(--tilt-follow-ease);
}
/* Cursor-tracked glare: layered soft circles that add like
   light (screen blend) at the pointer position. */
.t-tilt-glare {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  mix-blend-mode: screen;
  background:
    radial-gradient(circle 95px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
      rgba(255,255,255,0.48), rgba(255,255,255,0.06) 52%, rgba(255,255,255,0) 84%),
    radial-gradient(circle 200px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
      rgba(255,255,255,0.22), rgba(255,255,255,0.04) 58%, rgba(255,255,255,0) 78%),
    radial-gradient(circle 360px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
      rgba(255,255,255,0.10), rgba(255,255,255,0) 88%);
  transition: opacity var(--tilt-glare-fade) var(--tilt-glare-ease);
}
.t-tilt.is-hover .t-tilt-glare { opacity: var(--tilt-glare-opacity); }

@media (prefers-reduced-motion: reduce) {
  .t-tilt-card { transform: none !important; transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Track the pointer on the OUTER .t-tilt (never transforms) and write
// rotation + glare position onto the inner card. Works for mouse
// (hover) and touch / pen (tap-hold-drag) — a touch pointermove only
// fires while a finger is down, so the press naturally drives the tilt.
const tilt = document.querySelector(".t-tilt");
const card = tilt.querySelector(".t-tilt-card");
const reduce = matchMedia("(prefers-reduced-motion: reduce)");

const MAX = 14; // peak tilt in degrees at the card edges (raise for a stronger lean)

function reset() {
  tilt.classList.remove("is-hover");
  card.classList.remove("is-tilting");
  card.style.setProperty("--tilt-rx", "0deg");
  card.style.setProperty("--tilt-ry", "0deg");
}

function track(e) {
  if (reduce.matches) return;
  const r = tilt.getBoundingClientRect();
  const px = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
  const py = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
  tilt.classList.add("is-hover");
  card.classList.add("is-tilting");
  card.style.setProperty("--tilt-ry", ((px - 0.5) * MAX).toFixed(2) + "deg");
  card.style.setProperty("--tilt-rx", ((0.5 - py) * MAX).toFixed(2) + "deg");
  card.style.setProperty("--tilt-gx", (px * 100).toFixed(1) + "%");
  card.style.setProperty("--tilt-gy", (py * 100).toFixed(1) + "%");
}

tilt.addEventListener("pointerdown", (e) => {
  // Touch / pen: capture so the drag keeps targeting the card even if
  // the finger drifts past its edge. Pair with touch-action: none on
  // .t-tilt so the drag tilts instead of scrolling the page.
  if (e.pointerType !== "mouse") {
    try { tilt.setPointerCapture(e.pointerId); } catch (_) {}
  }
});
tilt.addEventListener("pointermove", track);
tilt.addEventListener("pointerup", reset);
tilt.addEventListener("pointercancel", reset);
tilt.addEventListener("pointerleave", (e) => {
  // Mouse: leaving the card flattens it. Touch already reset on up.
  if (e.pointerType === "mouse") reset();
});
```

### Peak tilt angle

The rotation magnitude is a JS constant (`MAX`, in degrees), not a CSS variable — the orchestration writes `--tilt-rx` / `--tilt-ry` from the pointer position scaled by `MAX`. Raise it for a stronger lean (the live demo goes up to ~40°); 10–16° reads as a subtle, tasteful tilt.

### Why the pointer is tracked on the flat wrapper

Bind `pointermove` to the outer `.t-tilt` (which never transforms), not the `.t-tilt-card` that rotates. If you track the tilting element, its rotating edges slip out from under the cursor near the borders and the hover flickers on and off.

### Touch / mobile

Because it uses Pointer Events, the tilt also works on touch: tap-hold-drag on the card and it follows your finger (a touch `pointermove` only fires while pressed). Two pieces make this reliable — `touch-action: none` on `.t-tilt` so the drag tilts instead of scrolling the page, and `setPointerCapture` on `pointerdown` so the gesture keeps targeting the card even if the finger drifts past its edge.

20-plus-menu-morph.md
# Plus to menu morph

## When to use

A small circular trigger (a "+" FAB, a compose button, an add-action affordance) that **morphs into the menu / panel it opens** instead of popping a separate surface next to it. The button's box grows in width / height and relaxes its corner radius into a rounded panel while the plus icon cross-fades + rotates out and the menu content slides in.

Reach for this over **menu dropdown** when the trigger and the surface are the *same* element (the button becomes the panel). Use plain **menu dropdown** when the surface is a distinct popover that merely grows from the trigger's corner.

## HTML usage

```html
<div class="t-morph" data-open="false">
  <div class="t-morph-menu"> … menu items … </div>
  <button class="t-morph-plus" aria-expanded="false">+</button>
</div>
```

Toggle `data-open` on the container (and `aria-expanded`
on the button). CSS animates the surface size + corner
radius and cross-fades the plus ↔ menu. Wrap the morph in
a relatively-positioned anchor sized to the OPEN footprint
if you want it to grow out of a fixed corner.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--morph-open-dur` | `350ms` | sourced from `--p20-open-dur` |
| `--morph-close-dur` | `250ms` | sourced from `--p20-close-dur` |
| `--morph-ease` | `cubic-bezier(0.34, 1.25, 0.64, 1)` | sourced from `--p20-ease` |
| `--morph-close-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p20-close-ease` |
| `--morph-r-closed` | `40px` | sourced from `--p20-r-closed` |
| `--morph-r-open` | `20px` | sourced from `--p20-r-open` |
| `--morph-fade-dur` | `200ms` | sourced from `--p20-fade-dur` |
| `--morph-slide` | `40px` | sourced from `--p20-slide-in-shift` |
| `--morph-rotate` | `45deg` | sourced from `--p20-rotate` |
| `--morph-scale` | `0.97` | sourced from `--p20-scale` |
| `--morph-blur` | `2px` | sourced from `--p20-blur` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --morph-open-dur: 350ms;
  --morph-close-dur: 250ms;
  --morph-ease: cubic-bezier(0.34, 1.25, 0.64, 1);
  --morph-close-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --morph-r-closed: 40px;
  --morph-r-open: 20px;
  --morph-fade-dur: 200ms;
  --morph-slide: 40px;
  --morph-rotate: 45deg;
  --morph-scale: 0.97;
  --morph-blur: 2px;
}
```

## CSS

```css
/* Closed: a small circular button. Open: a rounded panel.
   Width/height/border-radius animate; the open state uses a
   bouncier ease than the close. */
.t-morph {
  position: relative;
  width: 40px;
  height: 40px;
  border-radius: var(--morph-r-closed);
  overflow: hidden;
  transition:
    width var(--morph-close-dur) var(--morph-close-ease),
    height var(--morph-close-dur) var(--morph-close-ease),
    border-radius var(--morph-close-dur) var(--morph-close-ease);
}
.t-morph[data-open="true"] {
  width: 183px;
  height: 172px;
  border-radius: var(--morph-r-open);
  transition:
    width var(--morph-open-dur) var(--morph-ease),
    height var(--morph-open-dur) var(--morph-ease),
    border-radius var(--morph-open-dur) var(--morph-ease);
}
/* Plus fades + slides out and the icon rotates into an ×. */
.t-morph-plus {
  position: absolute;
  inset: auto 0 0 auto;
  width: 40px; height: 40px;
  display: grid; place-items: center;
  border: 0; background: transparent; cursor: pointer;
  transition:
    opacity var(--morph-fade-dur) var(--morph-close-ease),
    transform var(--morph-open-dur) var(--morph-close-ease),
    filter var(--morph-fade-dur) var(--morph-close-ease);
}
.t-morph-plus svg {
  transition: transform var(--morph-open-dur) var(--morph-close-ease);
}
.t-morph[data-open="true"] .t-morph-plus {
  opacity: 0;
  transform: translateX(calc(-1 * var(--morph-slide)));
  filter: blur(var(--morph-blur));
  pointer-events: none;
}
.t-morph[data-open="true"] .t-morph-plus svg {
  transform: scale(var(--morph-scale)) rotate(var(--morph-rotate));
}
/* Menu starts slid in + scaled + blurred; reveals on open. */
.t-morph-menu {
  position: absolute;
  inset: 0;
  opacity: 0;
  transform: translateX(var(--morph-slide)) scale(var(--morph-scale));
  filter: blur(var(--morph-blur));
  pointer-events: none;
  transition:
    opacity var(--morph-fade-dur) var(--morph-close-ease),
    transform var(--morph-open-dur) var(--morph-close-ease),
    filter var(--morph-fade-dur) var(--morph-close-ease);
}
.t-morph[data-open="true"] .t-morph-menu {
  opacity: 1;
  transform: translateX(0) scale(1);
  filter: blur(0);
  pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
  .t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Toggle data-open on the container; CSS owns the morph. Mirror the
// state to aria-expanded and close on outside click / Escape.
const morph = document.querySelector(".t-morph");
const plus = morph.querySelector(".t-morph-plus");

function setOpen(open) {
  morph.setAttribute("data-open", String(open));
  plus.setAttribute("aria-expanded", String(open));
}

plus.addEventListener("click", (e) => {
  e.stopPropagation();
  setOpen(morph.getAttribute("data-open") !== "true");
});
document.addEventListener("click", (e) => {
  if (!morph.contains(e.target)) setOpen(false);
});
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") setOpen(false);
});
```

### Pin the plus button to a corner

The plus button must overlay the panel, pinned to a corner (`inset: auto 0 0 auto`), so it stays put while the box grows up-and-left out of it. If it's in normal flow it gets shoved around as the container resizes. `overflow: hidden` on `.t-morph` is load-bearing — it clips the menu content during the size morph so items don't spill outside the growing rounded box.

### Open and close use different eases

The bouncy `--morph-ease` only drives the open; the close falls back to the calm `--morph-close-ease`. Don't collapse them into one variable. Adjust the open `width` / `height` in the snippet to your real panel size — they're hardcoded, not derived from the content.

21-accordion.md
# Accordion expand

## When to use

A disclosure / accordion / collapsible section whose panel grows and shrinks in height when toggled, with the header chevron flipping between a downward "v" and an upward "^". Use for settings groups, FAQs, filter sections, "show more" details — any header + collapsible body.

Height animates via `grid-template-rows: 0fr ↔ 1fr`, so there's **no JS height measuring** and content of any size animates cleanly. The chevron flips vertically (`scaleY`) from a "v" to a "^", passing through a flat line at the midpoint.

## HTML usage

```html
<div class="t-acc" data-open="false">
  <button class="t-acc-head" aria-expanded="false">
    Title
    <span class="t-acc-chevron">
      <svg viewBox="0 0 16 16"><path d="M4 6.5L8 10.5L12 6.5"/></svg>
    </span>
  </button>
  <div class="t-acc-panel"><div class="t-acc-panel-inner"> … </div></div>
</div>
```

Toggle `data-open` on the item. The panel animates via
grid-template-rows 0fr ↔ 1fr (no JS height measuring) and
the chevron flips vertically (scaleY) from a "v" to a "^".

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--acc-expand` | `250ms` | sourced from `--p21-expand-dur` |
| `--acc-collapse` | `250ms` | sourced from `--p21-collapse-dur` |
| `--acc-chevron` | `250ms` | sourced from `--p21-chevron-dur` |
| `--acc-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p21-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --acc-expand: 250ms;
  --acc-collapse: 250ms;
  --acc-chevron: 250ms;
  --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
/* grid-template-rows 0fr → 1fr gives a clean height animation
   with no JS measurement; the inner element clips overflow. */
.t-acc-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--acc-collapse) var(--acc-ease);
}
.t-acc[data-open="true"] .t-acc-panel {
  grid-template-rows: 1fr;
  transition: grid-template-rows var(--acc-expand) var(--acc-ease);
}
.t-acc-panel-inner {
  overflow: hidden;
  opacity: 0;
  filter: blur(2px);
  transition:
    opacity var(--acc-collapse) var(--acc-ease),
    filter var(--acc-collapse) var(--acc-ease);
}
.t-acc[data-open="true"] .t-acc-panel-inner {
  opacity: 1;
  filter: blur(0);
  transition:
    opacity var(--acc-expand) var(--acc-ease),
    filter var(--acc-expand) var(--acc-ease);
}
/* Flip the chevron vertically to turn the "v" into a "^".
   scaleY(-1) about the centre passes through a flat line at
   the midpoint (same look as a `d:` path morph) but animates
   in every browser, unlike CSS `d:` morphing (Chromium only).
   The chevron path is symmetric about the 16x16 viewBox
   centre, so the flip lands exactly on the "^"; non-scaling
   -stroke keeps the stroke width constant through the flip. */
.t-acc-chevron {
  display: inline-flex;
  transform: scaleY(1);
  transform-origin: center;
  transition: transform var(--acc-chevron) var(--acc-ease);
}
.t-acc-chevron path { vector-effect: non-scaling-stroke; }
.t-acc[data-open="true"] .t-acc-chevron {
  transform: scaleY(-1);
}

@media (prefers-reduced-motion: reduce) {
  .t-acc-panel, .t-acc-panel-inner, .t-acc-chevron {
    transition: none !important;
  }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Toggle data-open on the item; CSS owns the height + chevron morph.
const acc = document.querySelector(".t-acc");
const head = acc.querySelector(".t-acc-head");

head.addEventListener("click", () => {
  const open = acc.getAttribute("data-open") === "true";
  acc.setAttribute("data-open", String(!open));
  head.setAttribute("aria-expanded", String(!open));
});
```

### Two-element panel + padding placement

The panel needs the two-element structure (`.t-acc-panel` grid track + `.t-acc-panel-inner` with `overflow: hidden`). The `0fr → 1fr` track can only collapse a child that clips its own overflow. Keep padding on `.t-acc-panel-inner`, never on `.t-acc-panel` — padding on the `0fr` track leaves a residual height strip so the panel never fully closes.

### Why the chevron flips instead of morphing its path

The natural way to turn the "v" into a "^" is to morph the chevron's SVG `d` between two vertex sets — but CSS `d:` path interpolation is **Chromium-only**, so on mobile Safari and Firefox it snaps (or doesn't move at all). A vertical flip (`transform: scaleY(-1)`) reproduces the same motion — it passes through a flat horizontal line at the midpoint, exactly like the path morph — and animates in every browser. Two requirements make it land cleanly: the chevron path must be **symmetric about the centre of its viewBox** (so the flip maps the "v" onto the "^"), and the path needs `vector-effect: non-scaling-stroke` so the stroke width stays constant while the box is squashed mid-flip.

22-toast.md
# Toast open / close

## When to use

Toasts, snackbars, and transient confirmations that rise into view from the bottom edge — "Saved", "Copied", "Message sent". The toast translates up with a fade, a slight scale, and a cross-blur; opening runs on the slower open clock while dismissing uses the faster close clock, so arriving feels deliberate and leaving feels snappy.

Use **toast** when the surface announces itself and goes away on its own; use **modal** when the user must respond before continuing.

## HTML usage

```html
<div class="t-toast" data-open="false"> … </div>
```

Toggle `.is-open` on the toast. It rises from below with a
fade + cross-blur + slight scale; opening uses the slower
open clock, the resting (closed) transition uses the faster
close clock, so a single class gives the open/close asymmetry.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--toast-open` | `350ms` | sourced from `--p22-open-dur` |
| `--toast-close` | `250ms` | sourced from `--p22-close-dur` |
| `--toast-distance` | `16px` | sourced from `--p22-distance` |
| `--toast-blur` | `2px` | sourced from `--p22-blur` |
| `--toast-scale` | `0.97` | sourced from `--p22-scale` |
| `--toast-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p22-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --toast-open: 350ms;
  --toast-close: 250ms;
  --toast-distance: 16px;
  --toast-blur: 2px;
  --toast-scale: 0.97;
  --toast-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-toast {
  opacity: 0;
  transform: translateY(var(--toast-distance)) scale(var(--toast-scale));
  filter: blur(var(--toast-blur));
  will-change: transform, opacity, filter;
  transition:
    opacity var(--toast-close) var(--toast-ease),
    transform var(--toast-close) var(--toast-ease),
    filter var(--toast-close) var(--toast-ease);
}
.t-toast.is-open {
  opacity: 1;
  transform: translateY(0) scale(1);
  filter: blur(0);
  transition:
    opacity var(--toast-open) var(--toast-ease),
    transform var(--toast-open) var(--toast-ease),
    filter var(--toast-open) var(--toast-ease);
}

@media (prefers-reduced-motion: reduce) {
  .t-toast { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

23-like-button.md
# Like button

## When to use

Like / favorite / heart buttons — anywhere a single tap flips a boolean and deserves a tiny celebration. The heart's stroke fills with color, the icon pops with an overshoot scale, and eight tiny particles sprinkle outward before fading.

Toggle `data-liked` on the button. Unliking reverses the fill without the particles — the celebration only plays on the way in.

## HTML usage

```html
<button class="t-like" data-liked="false">
  <span class="t-like-icon"><svg class="t-like-heart">…</svg></span>
  <span class="t-like-particles"><i></i>…8 total…<i></i></span>
  Like
</button>
```

Toggle `data-liked` to fill the heart + spring-pop it. Add
`.is-bursting` (then remove it after the particle duration)
to fire the burst; set each dot's --px/--py/--pdur/--pdelay/
--p-end-scale/--psize in JS per like for an organic spray.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--like-color` | `#f40051` | sourced from `--p23-color` |
| `--like-fill` | `150ms` | sourced from `--p23-fill-dur` |
| `--like-pop` | `350ms` | sourced from `--p23-pop-dur` |
| `--like-pop-ease` | `cubic-bezier(0.34, 1.96, 0.64, 1)` | sourced from `--p23-pop-ease` |
| `--like-particle-dur` | `600ms` | sourced from `--p23-particle-dur` |
| `--like-particle-dist` | `20px` | sourced from `--p23-particle-dist` |
| `--like-particle-size` | `2.5px` | sourced from `--p23-particle-size` |
| `--like-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p23-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --like-color: #f40051;
  --like-fill: 150ms;
  --like-pop: 350ms;
  --like-pop-ease: cubic-bezier(0.34, 1.96, 0.64, 1);
  --like-particle-dur: 600ms;
  --like-particle-dist: 20px;
  --like-particle-size: 2.5px;
  --like-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-like-heart { color: currentColor; transition: color var(--like-fill) var(--like-ease); }
.t-like-heart path {
  fill: transparent; stroke: currentColor;
  transition: fill var(--like-fill) var(--like-ease), stroke var(--like-fill) var(--like-ease);
}
.t-like[data-liked="true"] .t-like-heart { color: var(--like-color); }
.t-like[data-liked="true"] .t-like-heart path { fill: currentColor; }
/* Pop scale lives on an HTML wrapper, never the <svg> itself:
   transforming an inline SVG makes Chromium rasterise it at 1×
   (pixelated on hi-DPI). Wrapping keeps the vector crisp. */
.t-like[data-liked="true"] .t-like-icon { animation: t-like-pop var(--like-pop) var(--like-pop-ease); }
@keyframes t-like-pop { 0% { transform: scale(1); } 30% { transform: scale(0.82); } 100% { transform: scale(1); } }

/* Particle burst — 8 dots flung along per-particle vectors. */
.t-like-particles { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; color: var(--like-color); }
.t-like-particles i {
  position: absolute;
  left: calc(var(--like-particle-size) * var(--psize, 1) / -2);
  top: calc(var(--like-particle-size) * var(--psize, 1) / -2);
  width: calc(var(--like-particle-size) * var(--psize, 1));
  height: calc(var(--like-particle-size) * var(--psize, 1));
  border-radius: 50%; background: currentColor; opacity: 0;
}
@keyframes t-like-burst {
  0%   { opacity: 0; transform: translate(0, 0) scale(0.4); }
  20%  { opacity: 1; transform: translate(calc(var(--px) * 0.25), calc(var(--py) * 0.25)) scale(1); }
  100% { opacity: 0; transform: translate(var(--px), var(--py)) scale(var(--p-end-scale, 0.6)); }
}
.t-like.is-bursting .t-like-particles i {
  animation: t-like-burst var(--pdur, var(--like-particle-dur)) ease-out var(--pdelay, 0ms) forwards;
}

@media (prefers-reduced-motion: reduce) {
  .t-like-icon, .t-like-particles i { animation: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

24-learn-more-hover.md
# Learn more hover

## When to use

"Learn more", "See all", "Continue" — inline links or buttons with a trailing chevron that should acknowledge hover. The chevron slides toward the text's reading direction while its two arms spread apart into a full arrow, then eases back on exit with a slightly faster out clock.

A hover-only affordance: keyboard focus and touch fall back to the resting state, so nothing essential is communicated by the motion alone.

## HTML usage

```html
<button class="t-learn">Learn more
  <span class="t-learn-chevron"><svg>
    <path class="t-learn-arm t-learn-arm-top" d="M6 4L10 8"/>
    <path class="t-learn-arm t-learn-arm-bot" d="M10 8L6 12"/>
  </svg></span>
</button>
```

On hover the chevron shifts right and its two arms rotate
apart about the apex (10, 8) so the angle opens; hover-out
returns. Pure CSS.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--learn-shift` | `2px` | sourced from `--p24-shift` |
| `--learn-spread` | `8deg` | sourced from `--p24-spread` |
| `--learn-in` | `350ms` | sourced from `--p24-in-dur` |
| `--learn-out` | `350ms` | sourced from `--p24-out-dur` |
| `--learn-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p24-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --learn-shift: 2px;
  --learn-spread: 8deg;
  --learn-in: 350ms;
  --learn-out: 350ms;
  --learn-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-learn-chevron {
  display: inline-flex;
  transform: translateX(0);
  transition: transform var(--learn-out) var(--learn-ease);
}
.t-learn-arm {
  transform-box: view-box;
  transform-origin: 10px 8px;
  vector-effect: non-scaling-stroke;
  transition: transform var(--learn-out) var(--learn-ease);
}
.t-learn:hover .t-learn-chevron { transform: translateX(var(--learn-shift)); transition-duration: var(--learn-in); }
.t-learn:hover .t-learn-arm { transition-duration: var(--learn-in); }
.t-learn:hover .t-learn-arm-top { transform: rotate(var(--learn-spread)); }
.t-learn:hover .t-learn-arm-bot { transform: rotate(calc(var(--learn-spread) * -1)); }

@media (prefers-reduced-motion: reduce) {
  .t-learn-chevron, .t-learn-arm { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

25-checkbox-check.md
# Checkbox check

## When to use

Checkboxes, to-do completion, multi-select rows — any boolean control where the checked state should feel earned. The box's background fills first, then the checkmark draws itself via stroke-dashoffset after a short delay; unchecking reverses quickly with no draw.

Toggle `aria-checked` on the control. Like **success check**, the draw needs `stroke-dasharray` calibrated to your actual path length (`path.getTotalLength()`).

## HTML usage

```html
<button class="t-check" role="checkbox" aria-checked="false">
  <svg viewBox="0 0 10.1668 10.1668">
    <path d="M1 5.52L3.92 9.17L9.17 1"/>
  </svg>
</button>
```

Toggle `aria-checked`. The box fills, then the check stroke
draws in via stroke-dashoffset. Set --check-len to the path's
getTotalLength() (rounded up) so it never over/under-draws;
transitioning offset lets a mid-draw uncheck reverse cleanly.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--check-box` | `150ms` | sourced from `--p25-box-dur` |
| `--check-draw` | `350ms` | sourced from `--p25-draw-dur` |
| `--check-delay` | `0ms` | sourced from `--p25-draw-delay` |
| `--check-uncheck` | `150ms` | sourced from `--p25-uncheck-dur` |
| `--check-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p25-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --check-box: 150ms;
  --check-draw: 350ms;
  --check-delay: 0ms;
  --check-uncheck: 150ms;
  --check-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-check {
  transition:
    background var(--check-box) var(--check-ease),
    box-shadow var(--check-box) var(--check-ease);
}
.t-check svg path {
  stroke-dasharray: var(--check-len, 15);
  stroke-dashoffset: var(--check-len, 15);
  transition: stroke-dashoffset var(--check-uncheck) var(--check-ease);
}
.t-check[aria-checked="true"] svg path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset var(--check-draw) var(--check-ease) var(--check-delay);
}

@media (prefers-reduced-motion: reduce) {
  .t-check, .t-check svg path { transition: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

26-spinning-counter.md
# Spinning counter

## When to use

Numbers that change with fanfare — points, prices, follower counts, dashboards KPIs. Each digit is a clipped vertical reel of 0-9 cells; the strip translates up through several full spins before landing on the target digit, with a per-column stagger and a vertical-only SVG blur while moving.

Reach for this over **number pop-in** when the change should feel like an event (a jackpot roll) rather than a quiet update. The reels are built in JS — one `.t-reel-col` per digit — so bring the small builder snippet from the recipe.

## HTML usage

```html
<div class="t-reel"></div>  <!-- reels built in JS -->
```

Build one .t-reel-col per digit, each clipping a strip
(.t-reel-strip) of 0-9 cells; translate the strip up by
(spins*10 + digit) cells to spin then land. A directional
(vertical-only) SVG feGaussianBlur stdDeviation="0 Y" gives
the motion streak (CSS blur() would smear sideways); decay it
to 0 per column as each reel settles.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--reel-dur` | `1400ms` | sourced from `--p26-dur` |
| `--reel-cell` | `30px` | sourced from `--p26-cell` |
| `--reel-spin-blur` | `3px` | sourced from `--p26-spin-blur` |
| `--reel-stagger` | `90ms` | sourced from `--p26-stagger` |
| `--reel-ease` | `cubic-bezier(0.16, 1, 0.3, 1)` | sourced from `--p26-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --reel-dur: 1400ms;
  --reel-cell: 30px;
  --reel-spin-blur: 3px;
  --reel-stagger: 90ms;
  --reel-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
```

## CSS

```css
.t-reel { display: inline-flex; align-items: center; height: var(--reel-cell); font-variant-numeric: tabular-nums; }
.t-reel-col {
  position: relative; height: var(--reel-cell); overflow: hidden;
  /* Soft-fade the window edges instead of hard-cropping. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);
}
.t-reel-strip { display: flex; flex-direction: column; will-change: transform, filter; }
.t-reel-digit { height: var(--reel-cell); display: flex; align-items: center; justify-content: center; }
/* JS drives the tween: strip.style.transition =
     'transform var(--reel-dur) var(--reel-ease) ' + (col*var(--reel-stagger)) + 'ms';
   and decays each column's feGaussianBlur stdDeviation from
   var(--reel-spin-blur) to 0 over its own window. */

@media (prefers-reduced-motion: reduce) {
  .t-reel-strip { transition: none !important; filter: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

27-toggle.md
# Toggle

## When to use

On/off switches — settings rows, theme toggles, feature flags. The thumb travels across the track with a two-step overshoot (past the end, back, settle) while the track color cross-fades on its own clock.

Toggle `data-on` on the switch. Add `.is-init` on first interaction so the "off" keyframes don't animate on page load — without it every switch on the page plays its return bounce once at mount.

## HTML usage

```html
<button class="t-toggle" role="switch" data-on="false">
  <span class="t-toggle-thumb"></span>
</button>
```

Toggle `data-on`. Add `.is-init` on first interaction so the
"off" keyframes don't play on load. The thumb travels with a
double bounce (overshoot past the end, swing back, settle);
the track colour cross-fades on its own clock.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--toggle-dur` | `350ms` | sourced from `--p27-dur` |
| `--toggle-travel` | `14.66px` | sourced from `--p27-travel` |
| `--toggle-ov1` | `1px` | sourced from `--p27-ov1` |
| `--toggle-ov2` | `0px` | sourced from `--p27-ov2` |
| `--toggle-track` | `0ms` | sourced from `--p27-track-dur` |
| `--toggle-ease` | `cubic-bezier(0.34, 1.35, 0.64, 1)` | sourced from `--p27-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --toggle-dur: 350ms;
  --toggle-travel: 14.66px;
  --toggle-ov1: 1px;
  --toggle-ov2: 0px;
  --toggle-track: 0ms;
  --toggle-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
}
```

## CSS

```css
.t-toggle { transition: background var(--toggle-track) var(--toggle-ease); }
.t-toggle-thumb { translate: 0 0; will-change: translate; }
.t-toggle[data-on="true"] .t-toggle-thumb { translate: var(--toggle-travel) 0; }
.t-toggle.is-init[data-on="true"] .t-toggle-thumb { animation: t-toggle-on var(--toggle-dur) var(--toggle-ease) both; }
.t-toggle.is-init[data-on="false"] .t-toggle-thumb { animation: t-toggle-off var(--toggle-dur) var(--toggle-ease) both; }
@keyframes t-toggle-on {
  0% { translate: 0 0; }
  55% { translate: calc(var(--toggle-travel) + var(--toggle-ov1)) 0; }
  80% { translate: calc(var(--toggle-travel) - var(--toggle-ov2)) 0; }
  100% { translate: var(--toggle-travel) 0; }
}
@keyframes t-toggle-off {
  0% { translate: var(--toggle-travel) 0; }
  55% { translate: calc(0px - var(--toggle-ov1)) 0; }
  80% { translate: var(--toggle-ov2) 0; }
  100% { translate: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .t-toggle-thumb { animation: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

None — pure CSS. Toggle the documented HTML attributes or class names from whatever already drives state in your app.

28-thinking-states.md
# Thinking states

## When to use

An AI status line that narrates what the agent is doing — "Setting up a workplace", "Running a command", "Browsing files". The line shimmers while a state holds (the same masked highlight as **shimmer text**), then swaps to the next state with the **text states swap** motion: the old line exits up through a small blur while the new one rises in from below.

Use this over a bare shimmer when the label changes while the work runs — the swap keeps the narration alive without a hard cut. Outgoing and incoming lines animate at the same time, so a swap costs one `--think-swap`, not two.

## HTML usage

```html
<span class="t-think" role="status">
  <span class="t-think-sizer" aria-hidden="true">Longest state here</span>
  <span class="t-think-text" data-text="Thinking…">Thinking…</span>
</span>
```

The shimmer runs on ::before (content: attr(data-text),
background-clip: text) while a state holds. JS swaps the line
every --think-hold: exit the outgoing copy (.is-exit) while
the incoming copy enters from below (.is-enter-start →
reflow → release), held back by --think-gap. Keep textContent
and data-text in sync so the shimmer copy always matches the
visible line.

The hidden sizer holds your longest state and is what gives
the box its width: lines are absolutely positioned across
that width, so every state centres in a box that never
resizes mid-swap. Drop the sizer if the line should hug
whatever state is showing, and set text-align: left on
.t-think if the states should share a left edge instead.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--think-hold` | `2000ms` | sourced from `--p28-hold` |
| `--think-swap` | `150ms` | sourced from `--p28-swap-dur` |
| `--think-gap` | `50ms` | sourced from `--p28-swap-gap` |
| `--think-distance` | `8px` | sourced from `--p28-swap-distance` |
| `--think-blur` | `2px` | sourced from `--p28-swap-blur` |
| `--think-shimmer` | `2000ms` | sourced from `--p28-shimmer-dur` |
| `--think-base` | `#7c7c7c` | sourced from `--p28-base` |
| `--think-highlight` | `#0d0d0d` | sourced from `--p28-highlight` |
| `--think-ease` | `ease-in-out` | sourced from `--p28-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --think-hold: 2000ms;
  --think-swap: 150ms;
  --think-gap: 50ms;
  --think-distance: 8px;
  --think-blur: 2px;
  --think-shimmer: 2000ms;
  --think-base: #7c7c7c;
  --think-highlight: #0d0d0d;
  --think-ease: ease-in-out;
}
```

## CSS

```css
/* Set your type on .t-think: the sizer only reports the right
   width if it is set in the same font as the line. */
.t-think { position: relative; display: inline-block; text-align: center; }
.t-think-sizer { display: block; visibility: hidden; white-space: nowrap; }
.t-think-text {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: block;
  color: var(--think-base);
  white-space: nowrap;
  transform: translateY(0);
  filter: blur(0);
  opacity: 1;
  transition:
    transform var(--think-swap) var(--think-ease),
    filter var(--think-swap) var(--think-ease),
    opacity var(--think-swap) var(--think-ease);
  will-change: transform, filter, opacity;
}
/* Shimmer sweeps the glyphs only (background-clip: text). */
.t-think-text::before {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: linear-gradient(90deg,
    transparent 0%, transparent 40%,
    var(--think-highlight) 50%,
    transparent 60%, transparent 100%);
  background-size: 400% 100%;
  background-repeat: no-repeat;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  animation: t-think-shimmer var(--think-shimmer) linear infinite;
}
@keyframes t-think-shimmer {
  0%   { background-position: 100% 0; }
  100% { background-position: 0% 0; }
}
/* The outgoing line floats over the box so both halves animate. */
.t-think-text.is-exit {
  transform: translateY(calc(var(--think-distance) * -1));
  filter: blur(var(--think-blur));
  opacity: 0;
}
.t-think-text.is-enter-start {
  transition: none;
  transform: translateY(var(--think-distance));
  filter: blur(var(--think-blur));
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .t-think-text { transition: none !important; transform: none !important; filter: none !important; }
  .t-think-text::before { display: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Cycle the states: hold, then swap. Both lines are absolutely
// positioned, so the outgoing and incoming copies animate at the same
// time over a box the hidden sizer holds steady, and textContent +
// data-text move together so the shimmer's ::before copy always
// matches the visible line.
const box = document.querySelector(".t-think");
let live = box.querySelector(".t-think-text");
const STATES = ["Setting up a workplace", "Running a command", "Browsing files"];
let i = 0;

const ms = (name, fb) => {
  const v = parseFloat(
    getComputedStyle(document.documentElement).getPropertyValue(name)
  );
  return Number.isFinite(v) ? v : fb;
};

(function cycle() {
  setTimeout(() => {
    const swap = ms("--think-swap", 150);
    const gap = ms("--think-gap", 50);
    const leaving = live;
    i = (i + 1) % STATES.length;

    leaving.classList.add("is-exit");

    const next = document.createElement("span");
    next.className = "t-think-text is-enter-start";
    next.textContent = STATES[i];
    next.setAttribute("data-text", STATES[i]);
    box.appendChild(next);
    live = next;

    const release = () => {
      void next.offsetWidth; // flush the enter-start rest state
      next.classList.remove("is-enter-start");
    };
    if (gap > 0) setTimeout(release, gap);
    else release();

    setTimeout(() => {
      leaving.remove();
      cycle();
    }, swap + gap);
  }, ms("--think-hold", 2000));
})();
```

29-reasoning-stream.md
# Reasoning stream

## When to use

An agent "thinking out loud" — a small card plays back a stream of reasoning by stepping the transcript up two lines at a time, holding between steps. Soft mask fades at the top and bottom edges sell "more above, more below", and the transcript is cloned once so the loop wraps without a visible jump.

Use for reasoning previews, live log tickers, changelog streams — any tall text that should scroll by inside a fixed viewport on its own clock.

## HTML usage

```html
<div class="t-reason">
  <div class="t-reason-viewport">
    <div class="t-reason-scroll">
      <div class="t-reason-text">
        <p>…transcript…</p>
      </div>
    </div>
  </div>
</div>
```

The card clips a tall transcript; JS steps the scroll up
--reason-lines lines every --reason-hold and clones
.t-reason-text once so the offset can wrap by one copy's
height mid-hold — the loop never shows a jump. The edge fades
are a mask on the viewport (not gradients painted over the
text), so the card background can be anything.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--reason-hold` | `840ms` | sourced from `--p29-hold` |
| `--reason-step` | `500ms` | sourced from `--p29-step-dur` |
| `--reason-lines` | `2` | sourced from `--p29-lines` |
| `--reason-fade` | `28px` | sourced from `--p29-fade` |
| `--reason-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p29-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --reason-hold: 840ms;
  --reason-step: 500ms;
  --reason-lines: 2;
  --reason-fade: 28px;
  --reason-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-reason { position: relative; overflow: hidden; }
.t-reason-viewport {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* Edge fades are a mask, not painted gradients, so the card
     background can be anything. */
  -webkit-mask-image: linear-gradient(
    transparent 0,
    black var(--reason-fade),
    black calc(100% - var(--reason-fade)),
    transparent 100%);
  mask-image: linear-gradient(
    transparent 0,
    black var(--reason-fade),
    black calc(100% - var(--reason-fade)),
    transparent 100%);
}
.t-reason-scroll {
  position: absolute;
  left: 0;
  right: 0;
  transform: translateY(0);
  will-change: transform;
}
/* JS drives the tween:
     scroll.style.transition = 'transform var(--reason-step) var(--reason-ease)';
     scroll.style.transform  = 'translateY(-' + offset + 'px)';
   and wraps the offset by one copy's height with transition: none. */

@media (prefers-reduced-motion: reduce) {
  .t-reason-scroll { transition: none !important; transform: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Clone the transcript once so the wrap is seamless, then step the
// scroll up --reason-lines lines every --reason-hold. The offset
// wraps by one copy's height the moment a step lands past it — the
// clone underneath makes the jump invisible.
const scroll = document.querySelector(".t-reason-scroll");
const text = scroll.querySelector(".t-reason-text");
scroll.appendChild(text.cloneNode(true));

const num = (name, fb) => {
  const v = parseFloat(
    getComputedStyle(document.documentElement).getPropertyValue(name)
  );
  return Number.isFinite(v) ? v : fb;
};

let offset = 0;
(function step() {
  setTimeout(() => {
    const lineH = parseFloat(getComputedStyle(text).lineHeight) || 18;
    const stepPx = lineH * num("--reason-lines", 2);
    const dur = num("--reason-step", 500);
    const ease = getComputedStyle(document.documentElement)
      .getPropertyValue("--reason-ease").trim() || "ease-out";
    offset += stepPx;
    scroll.style.transition = "transform " + dur + "ms " + ease;
    scroll.style.transform = "translateY(" + (-offset) + "px)";
    setTimeout(() => {
      const copyH = text.offsetHeight;
      if (offset >= copyH) {
        offset -= copyH;
        scroll.style.transition = "none";
        scroll.style.transform = "translateY(" + (-offset) + "px)";
        void scroll.offsetWidth; // flush before the next tween
      }
      step();
    }, dur + 30);
  }, num("--reason-hold", 1200));
})();
```

30-streaming-text.md
# Streaming text

## When to use

Model output arriving word by word — chat responses, AI completions, any streamed paragraph. JS wraps each word in a span; words rest visible, and a replay wipes them all, then resolves them in order through opacity plus a small blur, one every `--stream-gap`.

Reach for this over a typewriter effect when the text should feel like it condenses into place rather than being typed — the cross-blur reads as resolution, not keystrokes.

## HTML usage

```html
<div class="t-stream">Your streamed paragraph…</div>
```

JS wraps each word in a .t-stream-w span; spans rest visible.
To replay the stream: wipe every span with transition: none,
force one reflow, restore the transition, then add .is-in word
by word every --stream-gap — each word resolves through
opacity + a small blur over --stream-fade.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--stream-gap` | `60ms` | sourced from `--p30-word-gap` |
| `--stream-fade` | `350ms` | sourced from `--p30-word-dur` |
| `--stream-blur` | `1px` | sourced from `--p30-blur` |
| `--stream-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p30-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --stream-gap: 60ms;
  --stream-fade: 350ms;
  --stream-blur: 1px;
  --stream-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-stream-w {
  opacity: 0;
  filter: blur(var(--stream-blur));
  transition:
    opacity var(--stream-fade) var(--stream-ease),
    filter var(--stream-fade) var(--stream-ease);
}
.t-stream-w.is-in {
  opacity: 1;
  filter: blur(0);
}

@media (prefers-reduced-motion: reduce) {
  .t-stream-w { transition: none !important; filter: none !important; opacity: 1 !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Wrap each word in a span once; stream() wipes them (no transition)
// and resolves them in order, one every --stream-gap.
const block = document.querySelector(".t-stream");
const words = block.textContent.trim().split(/\s+/);
block.textContent = "";
const spans = words.map((w, i) => {
  const s = document.createElement("span");
  s.className = "t-stream-w is-in";
  s.textContent = w;
  block.appendChild(s);
  if (i < words.length - 1) block.appendChild(document.createTextNode(" "));
  return s;
});

const gap = parseFloat(
  getComputedStyle(document.documentElement).getPropertyValue("--stream-gap")
) || 60;

function stream() {
  // Snap back to nothing without animating the wipe itself.
  spans.forEach((s) => {
    s.style.transition = "none";
    s.classList.remove("is-in");
  });
  void block.offsetWidth; // flush the wipe
  spans.forEach((s) => { s.style.transition = ""; });
  (function next(n) {
    if (n >= spans.length) return;
    spans[n].classList.add("is-in");
    setTimeout(() => next(n + 1), gap);
  })(0);
}
```

31-matrix-loader.md
# Matrix dot loader

## When to use

Tiny inline loaders built from a 4×4 matrix of 2px dots. All four variants share one colour-pulse keyframe; a per-dot delay table gives each its motion character — a column scan, a randomized-looking twinkle, a perimeter orbit, a centre-out pulse. Rounded variants drop the four corner dots.

Use where a spinner would be too loud: alongside a status line, inside a compact button, in a table cell. The loader whispers.

## HTML usage

```html
<div class="t-matrix" data-variant="scan"></div>
```

JS builds sixteen <i> dots per loader and hands each a --d
delay (ms) into the shared colour-pulse cycle — the variant is
just a delay table. scan: col * cycle/10. twinkle: the order
[7,2,11,5,14,9,0,12,3,15,6,10,13,1,8,4] * cycle/16. orbit: the
ring [1,2,7,11,14,13,8,4] * cycle/8, centre holds steady.
pulse: inner [5,6,9,10] first, the rest cycle*0.16 behind.
Rounded variants mark the corners [0,3,12,15] .is-gap so they
render nothing.

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--matrix-cycle` | `1200ms` | sourced from `--p33-cycle` |
| `--matrix-base` | `#d9d9d9` | sourced from `--p33-base` |
| `--matrix-active` | `#85858f` | sourced from `--p33-active` |
| `--matrix-ease` | `ease-in-out` | sourced from `--p33-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --matrix-cycle: 1200ms;
  --matrix-base: #d9d9d9;
  --matrix-active: #85858f;
  --matrix-ease: ease-in-out;
}
```

## CSS

```css
.t-matrix {
  display: grid;
  grid-template-columns: repeat(4, 2px);
  grid-auto-rows: 2px;
  gap: 2px;
}
.t-matrix i {
  display: block;
  background: var(--matrix-base);
  animation: t-matrix-pulse var(--matrix-cycle) var(--matrix-ease) infinite;
  animation-delay: calc(var(--d, 0) * 1ms);
}
/* Hole positions (rounded variants) render nothing. */
.t-matrix i.is-gap { visibility: hidden; animation: none; }
@keyframes t-matrix-pulse {
  0%, 45%, 100% { background-color: var(--matrix-base); }
  15%           { background-color: var(--matrix-active); }
}

@media (prefers-reduced-motion: reduce) {
  .t-matrix i { animation: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Build 16 dots per loader and hand each a --d delay (ms) into the
// shared pulse cycle — the variant is just a delay table.
const CORNERS = [0, 3, 12, 15];
// Clockwise perimeter (corner-less ring), then centre cells.
const RING = [1, 2, 7, 11, 14, 13, 8, 4];
const INNER = [5, 6, 9, 10];
const TWINKLE = [7, 2, 11, 5, 14, 9, 0, 12, 3, 15, 6, 10, 13, 1, 8, 4];

const cycle = parseFloat(
  getComputedStyle(document.documentElement).getPropertyValue("--matrix-cycle")
) || 1200;

document.querySelectorAll(".t-matrix").forEach((loader) => {
  const variant = loader.getAttribute("data-variant");
  const rounded = loader.getAttribute("data-rounded") === "true";
  for (let idx = 0; idx < 16; idx++) {
    const dot = document.createElement("i");
    const col = idx % 4;
    if (rounded && CORNERS.includes(idx)) {
      dot.className = "is-gap";
    } else if (variant === "scan") {
      dot.style.setProperty("--d", String(Math.round(col * (cycle / 10))));
    } else if (variant === "twinkle") {
      dot.style.setProperty("--d", String(Math.round(TWINKLE[idx] * (cycle / 16))));
    } else if (variant === "orbit") {
      const k = RING.indexOf(idx);
      if (k !== -1) {
        dot.style.setProperty("--d", String(Math.round(k * (cycle / 8))));
      } else {
        dot.style.animation = "none"; // centre holds steady under the ring
      }
    } else if (variant === "pulse") {
      const ring = INNER.includes(idx) ? 0 : 1;
      dot.style.setProperty("--d", String(Math.round(ring * (cycle * 0.16))));
    }
    loader.appendChild(dot);
  }
});
```

32-banner-stacking.md
# Banner stacking

## When to use

Sonner-style banner / toast stacking. Each new banner rises in with the **toast** motion (rise + cross-blur + slight scale) while older banners push back — smaller, higher, dimmer — instead of leaving; the fourth arrival sends the oldest out. Hovering the stack fans it into a readable list.

Use over a single **toast** when notifications can overlap: the stack keeps the newest legible while acknowledging the queue behind it.

## HTML usage

```html
<div class="t-stack">
  <div class="t-stack-banner" data-depth="0">…</div>
</div>
```

Sonner-style. Append a new banner with .is-enter + data-depth
0, step every older banner's data-depth one back, force one
reflow, then remove .is-enter in the same task (a rAF hop is
skipped when the frame clock is throttled). A fourth banner
gets .is-leaving; remove it after --stack-close. Hover spread
is geometry in JS: pointer inside the collapsed stack box adds
.is-spread, and it holds until the pointer leaves the taller
spread column ((stack height + gap) * 2 above).

## Tunable variables

| Variable | Default | Notes |
| --- | --- | --- |
| `--stack-open` | `350ms` | sourced from `--p34-open-dur` |
| `--stack-close` | `250ms` | sourced from `--p34-close-dur` |
| `--stack-rise` | `80px` | sourced from `--p34-distance` |
| `--stack-blur` | `2px` | sourced from `--p34-blur` |
| `--stack-scale` | `0.97` | sourced from `--p34-scale-in` |
| `--stack-peek` | `12px` | sourced from `--p34-peek` |
| `--stack-spread-gap` | `8px` | sourced from `--p34-spread-gap` |
| `--stack-depth-scale` | `0.06` | sourced from `--p34-depth-scale` |
| `--stack-depth-fade` | `0.4` | sourced from `--p34-depth-fade` |
| `--stack-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p34-ease` |

The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.

```css
:root {
  --stack-open: 350ms;
  --stack-close: 250ms;
  --stack-rise: 80px;
  --stack-blur: 2px;
  --stack-scale: 0.97;
  --stack-peek: 12px;
  --stack-spread-gap: 8px;
  --stack-depth-scale: 0.06;
  --stack-depth-fade: 0.4;
  --stack-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
```

## CSS

```css
.t-stack { position: relative; }
.t-stack-banner {
  position: absolute;
  left: 0;
  bottom: 0;
  /* Centre origin so an arriving banner scales like a toast; the
     push-back depths switch to the bottom edge, which they can do
     freely because the swap happens at identity. */
  transform-origin: 50% 50%;
  /* Explicit blur(0) rather than an unset filter — blur → none is
     not reliably interpolated, and the cross-blur would snap. */
  filter: blur(0);
  will-change: transform, opacity, filter;
  transition:
    transform var(--stack-open) var(--stack-ease),
    opacity var(--stack-open) var(--stack-ease),
    filter var(--stack-open) var(--stack-ease);
}
.t-stack-banner[data-depth="0"] {
  z-index: 3;
  transform: translateY(0) scale(1);
  opacity: 1;
}
.t-stack-banner[data-depth="1"] {
  z-index: 2;
  transform-origin: 50% 100%;
  transform: translateY(calc(var(--stack-peek) * -1))
             scale(calc(1 - var(--stack-depth-scale)));
  opacity: calc(1 - var(--stack-depth-fade));
}
.t-stack-banner[data-depth="2"] {
  z-index: 1;
  transform-origin: 50% 100%;
  transform: translateY(calc(var(--stack-peek) * -2))
             scale(calc(1 - var(--stack-depth-scale) * 2));
  opacity: calc(1 - var(--stack-depth-fade) * 1.6);
}
/* Spread: newest stays put, older ones climb one banner height
   each (100% = the banner's own height). Driven by a class, not
   :hover — the gaps between spread banners belong to no element. */
.t-stack.is-spread .t-stack-banner[data-depth="1"],
.t-stack.is-spread .t-stack-banner[data-depth="2"] { opacity: 1; }
.t-stack.is-spread .t-stack-banner[data-depth="1"] {
  transform: translateY(calc((100% + var(--stack-spread-gap)) * -1)) scale(1);
}
.t-stack.is-spread .t-stack-banner[data-depth="2"] {
  transform: translateY(calc((100% + var(--stack-spread-gap)) * -2)) scale(1);
}
.t-stack-banner.is-enter {
  transition: none;
  transform: translateY(var(--stack-rise)) scale(var(--stack-scale));
  opacity: 0;
  filter: blur(var(--stack-blur));
}
.t-stack-banner.is-leaving {
  z-index: 0;
  transform-origin: 50% 100%;
  transform: translateY(calc(var(--stack-peek) * -3))
             scale(calc(1 - var(--stack-depth-scale) * 3));
  opacity: 0;
  filter: blur(var(--stack-blur));
  transition:
    transform var(--stack-close) var(--stack-ease),
    opacity var(--stack-close) var(--stack-ease),
    filter var(--stack-close) var(--stack-ease);
}

@media (prefers-reduced-motion: reduce) {
  .t-stack-banner { transition: none !important; transform: none !important; filter: none !important; }
}
```

The `@media (prefers-reduced-motion: reduce)` guard at the bottom of the snippet is required — keep it. It zeroes the transition for users who have asked for less motion at the OS level.

## JavaScript orchestration

```js
// Stack management: a new banner arrives at depth 0, everything
// already stacked steps one depth back (pure data-depth swap — CSS
// transitions the rest), and a fourth banner pushes the oldest out.
const stack = document.querySelector(".t-stack");
let banners = []; // newest first

const ms = (name, fb) => {
  const v = parseFloat(
    getComputedStyle(document.documentElement).getPropertyValue(name)
  );
  return Number.isFinite(v) ? v : fb;
};

function addBanner(contentHTML) {
  const el = document.createElement("div");
  el.className = "t-stack-banner is-enter";
  el.setAttribute("data-depth", "0");
  el.innerHTML = contentHTML;
  banners.unshift(el);
  stack.appendChild(el);
  banners.forEach((b, i) => {
    if (i === 0) return;
    if (i > 2) {
      if (!b.classList.contains("is-leaving")) {
        b.classList.add("is-leaving");
        setTimeout(() => b.remove(), ms("--stack-close", 250) + 60);
      }
    } else {
      b.setAttribute("data-depth", String(i));
    }
  });
  banners = banners.slice(0, 3)
    .concat(banners.slice(3).filter((b) => !b.classList.contains("is-leaving")));
  // Flush the pre-open rest state, then release it in the same task.
  // A rAF hop would be skipped whenever the frame clock is throttled,
  // and the banner would land with no motion at all.
  void el.offsetWidth;
  el.classList.remove("is-enter");
}

// Hover spread is geometry, not :hover — the gaps between spread
// banners belong to no element, and boundary events would also fire
// when a banner animates in under a still pointer.
const stage = stack.parentElement;
const spreadHeight = () =>
  (stack.offsetHeight + ms("--stack-spread-gap", 8)) * 2;
const within = (e, above) => {
  const r = stack.getBoundingClientRect();
  return e.clientX >= r.left && e.clientX <= r.right &&
    e.clientY <= r.bottom && e.clientY >= r.top - above;
};
stage.addEventListener("pointermove", (e) => {
  if (stack.classList.contains("is-spread")) {
    if (!within(e, spreadHeight())) stack.classList.remove("is-spread");
  } else if (within(e, 0)) {
    stack.classList.add("is-spread");
  }
});
stage.addEventListener("pointerleave", () => stack.classList.remove("is-spread"));
```

SKILL.md
---
name: transitions-dev
description: Production-ready CSS transitions for web apps. Use when implementing notification badges, dropdowns, modals, panel reveals, page transitions, card resizes, number pop-ins, text swaps, icon swaps, success checks, avatar group hovers, error state shakes, search/input clear, skeleton loaders, shimmer text, sliding tabs, tooltips, staggered text reveals, card hover tilt, plus-to-menu morph, accordions, toasts, like buttons, learn-more hovers, checkbox checks, spinning counters, toggles, AI thinking states, reasoning streams, streaming text, matrix dot loaders, or banner stacking. Triggers on "add a transition", "animate the dropdown", "make the modal open smoothly", "swap icon", "page slide", "stagger animation", "open / close transition", "make it animate", "fade between", "success animation", "form error", "shake on invalid", "hover lift", "avatar stack hover", "clear the search", "skeleton loader", "loading shimmer", "shimmer text", "sliding tabs", "segmented control", "tooltip", "reveal text", "tilt card", "3D hover tilt", "cursor glare", "plus to menu", "FAB morph", "accordion", "collapsible", "expand / collapse", "disclosure", "toast", "snackbar", "like button", "heart animation", "learn more arrow", "checkbox", "check animation", "spinning counter", "odometer", "slot machine digits", "toggle", "switch", "thinking states", "AI status line", "agent reasoning", "reasoning stream", "streaming text", "stream words in", "matrix loader", "dot loader", "banner stack", "stacked toasts". Also "motion tokens", "scan for ad-hoc transitions", "replace hardcoded durations with motion tokens", "tokenize my animations", and the commands transitions reveal, transitions review, transitions apply, transitions refine.
---

# Transitions.dev

Thirty-two portable CSS transitions, each namespaced under `t-*` selectors with semantic CSS custom properties. Drop-in: paste the snippet, wire the documented HTML hooks, done. No framework dependencies, no demo-specific markup, and every snippet ships a `prefers-reduced-motion` guard.

## Quick reference

| Transition | When to use | Reference |
| --- | --- | --- |
| **Card resize** | Tween a container's width or height when its layout state changes. | [01-card-resize.md](./01-card-resize.md) |
| **Number pop-in** | Re-enter each digit with a blurred slide when a number updates. | [02-number-pop-in.md](./02-number-pop-in.md) |
| **Notification badge** | Slide a small badge onto a trigger and pop the dot. | [03-notification-badge.md](./03-notification-badge.md) |
| **Text states swap** | Swap text in place with a blurred up-and-down transition. | [04-text-states-swap.md](./04-text-states-swap.md) |
| **Menu dropdown** | Open an origin-aware dropdown that grows from its trigger. | [05-menu-dropdown.md](./05-menu-dropdown.md) |
| **Modal open / close** | Scale-up modal dialog with a softer scale-down on close. | [06-modal.md](./06-modal.md) |
| **Panel reveal** | Slide a panel into a region with a cross-blur. | [07-panel-reveal.md](./07-panel-reveal.md) |
| **Page side-by-side** | Slide between two side-by-side pages (list ↔ detail, step 1 ↔ step 2). | [08-page-side-by-side.md](./08-page-side-by-side.md) |
| **Icon swap** | Cross-fade two icons in the same slot with blur and scale. | [09-icon-swap.md](./09-icon-swap.md) |
| **Success check** | Compose fade + rotate + Y-bob + path stroke-draw to celebrate a completed action. | [10-success-check.md](./10-success-check.md) |
| **Avatar group hover** | Distance-falloff lift on a row of items with a bouncy spring on return. | [11-avatar-group-hover.md](./11-avatar-group-hover.md) |
| **Error state shake** | Per-segment cubic-bezier shake with auto-reverting border + message. | [12-error-state-shake.md](./12-error-state-shake.md) |
| **Input clear with dissolve** | Fly-out + per-word streak when a text field is cleared. | [13-input-clear-dissolve.md](./13-input-clear-dissolve.md) |
| **Skeleton loader and reveal** | Pulse a placeholder, then cross-fade + cross-blur to the loaded content. | [14-skeleton-reveal.md](./14-skeleton-reveal.md) |
| **Shimmer text** | Sweep a highlight band across muted text on a loop (pure CSS). | [15-shimmer-text.md](./15-shimmer-text.md) |
| **Tabs sliding** | Slide the active pill between tabs in a segmented control. | [16-tabs-sliding.md](./16-tabs-sliding.md) |
| **Tooltip open/close** | Delayed fade+scale in, instant out (pure CSS). | [17-tooltip.md](./17-tooltip.md) |
| **Texts reveal** | Staggered blurred rise for stacked text lines, quiet fade out. | [18-texts-reveal.md](./18-texts-reveal.md) |
| **Card hover tilt** | Tilt a card in 3D toward the pointer with a cursor-tracked glare. | [19-card-tilt.md](./19-card-tilt.md) |
| **Plus to menu morph** | Morph a circular trigger into the menu / panel it opens. | [20-plus-menu-morph.md](./20-plus-menu-morph.md) |
| **Accordion expand** | Grow / shrink a panel via grid-rows with a chevron flip. | [21-accordion.md](./21-accordion.md) |
| **Toast open / close** | Rise a toast from below with fade + cross-blur, slower in than out. | [22-toast.md](./22-toast.md) |
| **Like button** | Fill a heart with a pop + particle burst on like. | [23-like-button.md](./23-like-button.md) |
| **Learn more hover** | Slide the chevron and spread its arms into an arrow on hover. | [24-learn-more-hover.md](./24-learn-more-hover.md) |
| **Checkbox check** | Fill the box, then stroke-draw the checkmark. | [25-checkbox-check.md](./25-checkbox-check.md) |
| **Spinning counter** | Spin slot-machine digit reels with vertical motion blur. | [26-spinning-counter.md](./26-spinning-counter.md) |
| **Toggle** | Travel the switch thumb with a double-bounce overshoot. | [27-toggle.md](./27-toggle.md) |
| **Thinking states** | Shimmer a status line while it holds, then swap it to the next state. | [28-thinking-states.md](./28-thinking-states.md) |
| **Reasoning stream** | Step an agent-reasoning transcript up two lines at a time on a loop. | [29-reasoning-stream.md](./29-reasoning-stream.md) |
| **Streaming text** | Resolve streamed words one by one through a soft cross-blur. | [30-streaming-text.md](./30-streaming-text.md) |
| **Matrix dot loader** | Pulse a 4×4 dot matrix in scan / twinkle / orbit / pulse patterns. | [31-matrix-loader.md](./31-matrix-loader.md) |
| **Banner stacking** | Stack banners like toasts — new ones rise in, older ones push back. | [32-banner-stacking.md](./32-banner-stacking.md) |

## Decision rules

When the user asks for a transition, match against the visible UI element first, then the verb:

- **Trigger + small dot floating on top** → notification badge.
- **Trigger + surface that grows from it** → dropdown (anchored, origin-aware) or modal (centered, no anchor).
- **Surface that slides into a region of the page** → panel reveal.
- **Two screens, list ↔ detail or step 1 ↔ step 2** → page side-by-side.
- **Element changes width or height** → card resize.
- **Element's text content changes in place** → text states swap.
- **Two icons in the same slot** → icon swap.
- **A number updates** → number pop-in.
- **Confirmation / success / "done" moment** (checkmark, payment processed, file uploaded) → success check.
- **Hovering an item in a horizontal stack** (avatars, chips, segmented buttons, tag pills) → avatar group hover.
- **Form validation error / "this is wrong" feedback** (invalid field, wrong PIN, duplicate name) → error state shake.
- **Clearing a text field** (search box × button, filter reset) → input clear with dissolve.
- **Placeholder that loads then swaps to real content** (list row, card, profile header) → skeleton loader and reveal.
- **In-progress / "thinking" text that should feel alive** (loading label, streaming status) → shimmer text.
- **Small horizontal set of mutually-exclusive options with a moving highlight** (view switcher, segmented control, filter tabs) → tabs sliding.
- **Hover/focus hint that appears over a trigger** (icon tooltip, info bubble) → tooltip open / close.
- **Stacked headline + supporting line entering with rhythm** (hero copy, empty state, onboarding step) → texts reveal.
- **Card / tile that should react in 3D to the pointer on hover** (product card, cover art, membership card, with or without a light glare) → card hover tilt.
- **Circular trigger that becomes the surface it opens** (+ FAB grows into a menu / panel, compose button expands) → plus → menu morph. If the surface is a *separate* popover that merely grows from the trigger, use menu dropdown instead.
- **Header with a collapsible body that grows / shrinks in height** (settings group, FAQ, filter section, "show more", disclosure) → accordion expand.
- **No clear match** → fall back to `transitions reveal` and let the user pick. Don't guess.

If two transitions could fit, prefer the lower-overhead one (card resize over panel reveal, dropdown over modal, success check over a full modal celebration) unless the design clearly calls for the heavier surface. The success check is animation-only — if you also need to swap from a spinner to the check, pair it with **icon swap**.

## Commands

The skill exposes four namespaced verbs the agent should recognise in addition to direct transition requests. Every command starts with `transitions` so the invocation never collides with verbs from other skills installed in the same project.

### transitions reveal — list every transition

**Trigger phrases:** `transitions reveal`, "reveal the transitions", "list all transitions", "what transitions are in this skill", "show the transitions catalog".

**Behaviour:** print the thirty-two transitions as a numbered plain-text list — name, one-line summary, and the matching reference filename. Reuse the rows in `## Quick reference` above; do not invent new copy. No project access.

### transitions review — audit the project for fit

**Trigger phrases:** `transitions review`, "review my project", "audit my animations", "where would transitions.dev help", "find places to use this skill".

**Behaviour:**

1. Search the workspace for indicators: `transition:` declarations, `@keyframes`, hardcoded `ms` / `s` durations in style files, components matching the decision-rule patterns (modals, dropdowns, badges, search inputs, skeletons, tabs, tooltips, …).
2. For each hit, match against the decision rules and pick the single best-fit transition.
3. Output a numbered list grouped by file:
   - `path/to/Component.tsx:L42` — looks like a dropdown opening, suggest **menu-dropdown** (`05-menu-dropdown.md`).
   - Skip ad-hoc transitions that already use a `t-*` class.
4. Do not edit anything. End with: "Run `transitions apply` on any line to install the suggested transition."

### transitions apply — install the best-fit transition

**Trigger phrases:** `transitions apply`, "apply a transition here", "add the right transition", "install transitions-dev here", "fix the animation on this element".

**Behaviour:**

1. Read context: the currently-open file, the element nearest the cursor, surrounding CSS / JSX. If the user named a transition explicitly (e.g. `transitions apply menu-dropdown`), use it.
2. Run the decision rules from `## Decision rules` on that context and pick **one** transition. If two could fit, prefer the lower-overhead one (same tie-breaker the existing rules use).
3. Surface a one-line proposal: "I'd apply **menu-dropdown** here because the element opens from a trigger and is anchored. Confirm to install?".
4. On confirmation, follow the existing five-step procedure in `## Output format` verbatim (root block, snippet, hooks, reduced-motion guard, JS orchestration if needed).
5. If the agent can't pick a single transition with confidence, fall back to `transitions reveal` and ask the user to choose.

### transitions refine — replace ad-hoc motion with the motion tokens

**Trigger phrases:** `transitions refine`, "refine my transitions", "scan for ad-hoc transitions", "replace hardcoded durations with motion tokens", "tokenize my animations", "tune the durations / easing", "audit my custom keyframes", "make the timing consistent", "align to the motion tokens".

**Behaviour:**

1. **Scan the whole project** (not just dedicated stylesheets — also inline `style=` / CSS-in-JS, styled-components, `<style>` blocks, Tailwind arbitrary values like `duration-[300ms]`) for ad-hoc motion: `transition` / `animation` shorthands and longhands, custom `@keyframes` blocks, hardcoded durations (`…ms` / `…s`), easing (`cubic-bezier(...)` or keywords), translate distances (`px`), `scale(...)`, and `blur(...)`.
2. For each value, infer **what the motion does** (modal close, dropdown open, tooltip, badge appear, text reveal, page slide, shake, …) from the surrounding selectors / component plus the `## Decision rules`. For a `@keyframes` block, read the `animation` that drives it and judge the keyframes' own duration/easing.
3. **The key decision point is usage, not the raw number.** Look the inferred usage up in `## Motion tokens` and suggest the token whose documented usage matches — only when the usages line up. A 300ms modal close maps to `--duration-quick` because both are "modal close", even though the numbers differ. If a value's usage matches **no** token's usage, list it as `no matching token usage` and leave it untouched — never force a swap just because a number is close.
4. Output a numbered list grouped by file, showing only values that should change, each as `path/to/Component.css:L42` — `modal close: 300ms → var(--duration-quick) (150ms)`, `ease → var(--ease-smooth-out)`. For keyframe-driven motion, suggest the token for the driving `animation`'s duration/easing.
5. Do not edit anything. End with: "Confirm any line to apply the change, or run `transitions apply` to install a full transition instead."

## Motion tokens

The shared motion scale behind the thirty-two transitions — the same tokens the [transitions.dev](https://transitions.dev) Motion tokens tab exposes. They ship at the top of [`_root.css`](./_root.css), so once it's imported you can reference any of them as `var(--…)` (e.g. `transition: transform var(--duration-fast) var(--ease-smooth-out)`).

`transitions refine` maps each existing value to a usage below, then suggests the token to reference. Match on **usage**, not on the raw number — a 300ms modal close still maps to `--duration-quick` (150ms).

**Durations**

| Token | Value | Usage |
| --- | --- | --- |
| `--duration-stagger` | `40ms` | per-item stagger offset |
| `--duration-micro` | `80ms` | tooltip/path delay, shake segment, large stagger |
| `--duration-quick` | `150ms` | modal/dropdown close, text swap, tooltip appear |
| `--duration-fast` | `250ms` | icon swap, dropdown/modal open, tabs sliding, page slide |
| `--duration-medium` | `350ms` | panel close, toast close |
| `--duration-slow` | `400ms` | panel open, skeleton content reveal, input clear |
| `--duration-very-slow` | `500ms` | emphasis moments, badge appear, text reveal, success check |

**Easings**

| Token | Value | Usage |
| --- | --- | --- |
| `--ease-smooth-out` | `cubic-bezier(0.22, 1, 0.36, 1)` | modal/dropdown/panel open + close, page slide, resize, position change |
| `--ease-in-out` | `ease-in-out` | icon swap, text swap, text reveal, skeleton reveal |
| `--ease-out` | `ease-out` | tooltip open / close |
| `--ease-linear` | `linear` | shimmer, skeleton pulse, spinner |
| `--ease-bounce` | `cubic-bezier(0.34, 1.36, 0.64, 1)` | badge pop open |
| `--ease-bounce-strong` | `cubic-bezier(0.34, 3.85, 0.64, 1)` | bouncy hover-out (avatar return) |

**Distances**

| Token | Value | Usage |
| --- | --- | --- |
| `--distance-micro` | `4px` | text swap |
| `--distance-small` | `6px` | error shake (small segment) |
| `--distance-base` | `8px` | badge diagonal reveal, page slide, error shake (large segment) |
| `--distance-medium` | `12px` | text reveal |
| `--distance-large` | `30px` | check badge appear |

**Scales**

| Token | Value | Usage |
| --- | --- | --- |
| `--scale-large` | `0.96` | modal open / close |
| `--scale-medium` | `0.97` | dropdown open |
| `--scale-small` | `0.98` | tooltip open |
| `--scale-tiny` | `0.99` | dropdown close |

**Blur**

| Token | Value | Usage |
| --- | --- | --- |
| `--blur-small` | `2px` | panel reveal, icon swap, text swap, skeleton reveal, number pop-in |
| `--blur-medium` | `3px` | page slide, text reveal |
| `--blur-large` | `8px` | success check open |

## Universal install

Copy [`_root.css`](./_root.css) into your project **once** and import it (or paste its `:root` block into your global stylesheet). It leads with the shared **motion-token scale** (`--duration-*`, `--ease-*`, `--distance-*`, `--scale-*`, `--blur-*` — see `## Motion tokens`), followed by the semantic tunable variables for **all thirty-two** transitions. Every snippet reads from these names — `--resize-*`, `--badge-*`, `--dropdown-*`, `--clear-*`, `--shimmer-*`, `--tabs-*`, `--tt-*`, `--stagger-*`, `--tilt-*`, `--morph-*`, `--acc-*`, and the rest.

Each reference file also restates just the variables that snippet needs, so you can install a single transition without pulling the whole block. Don't duplicate the block — if `_root.css` is already imported, skip re-pasting any per-snippet `:root`.

The `--pX-*` source tokens used by the live demo at [transitions.dev](https://transitions.dev) are intentionally **not** exported. Tunable values are renamed to semantic names so the user owns the design vocabulary. A few transitions (input clear, shimmer text, tabs, tooltip) carry **color** tokens that differ by theme — each reference file documents the `html[data-theme="dark"]` overrides.

## Output format

When inserting a transition into the user's project:

1. **Install the variables from `_root.css`** into the user's global stylesheet, but only if they aren't already there — or just the per-snippet `:root` block from the reference file if installing a single transition. If the universal block is already imported, do **not** duplicate it.
2. **Paste the chosen transition's CSS verbatim** from the relevant reference file. Do not rewrite selectors, do not collapse the transition into shorthand, do not strip `will-change`. The snippets are tuned and tested.
3. **Wire the documented HTML hooks** — class names (`.t-dropdown`, `.t-modal`, `.t-success-check`, `.t-avatar`, `.t-clear`, `.t-skel`, `.t-shimmer`, `.t-tabs`, `.t-tt`, `.t-stagger`, `.t-tilt`, `.t-morph`, `.t-acc`, …) and state attributes (`data-open`, `data-state`, `data-page`, `data-origin`, `aria-selected`, `aria-expanded`, `.is-open`, `.is-closing`, `.is-error`, `.is-shaking`, `.has-value`, `.is-clearing`, `.is-pulsing`, `.is-revealed`, `.is-shown`, `.is-hiding`, `.is-hover`, `.is-tilting`).
4. **Preserve the `@media (prefers-reduced-motion: reduce)` block.** Every snippet ships one. Removing it makes the component fail accessibility audits.
5. **For transitions that need JS** (dropdown, modal, text swap, number pop-in, page slide, success check, avatar group hover, error state shake, input clear, skeleton reveal, tabs sliding, texts reveal, card hover tilt, plus → menu morph, accordion expand), copy the small orchestration snippet from the reference file and adapt the selectors to the user's DOM. Keep the timing reads (`getComputedStyle(...)getPropertyValue("--…")`) so durations stay in sync with the `:root` values. Shimmer text and tooltip are **pure CSS** — no JS needed.

Keep the diff small: only edit the files needed to introduce the transition. Don't rename the user's existing variables, don't reformat unrelated CSS, don't pull in a motion library.

## Common mistakes to avoid

- **Stripping the close-state class cleanup** on dropdown/modal — without the `setTimeout` that removes `.is-closing`, the next open jumps from the closing scale instead of the resting pre-open scale.
- **Forgetting the reflow** in the text swap, number pop-in, success check replay, and error state shake — `void el.offsetWidth` (or `offsetHeight`) between class/attribute removal and re-addition is what guarantees the animation replays.
- **Animating a single container** instead of the inner pieces — for the badge, animate the dot, not the trigger; for page slide, animate the page sections, not the container.
- **Replacing `transition: …` with `transition: all`** — every snippet enumerates exact properties on purpose so unrelated style changes don't ride in for free.
- **Hardcoding the success check's `stroke-dasharray`** — the snippet ships `20` as a placeholder. Replace it with `path.getTotalLength()` rounded up by 1 for *your* path, otherwise the stroke pre-reveals or over-draws.
- **Setting `transition-timing-function` in CSS** for the avatar group hover — it has to be set inline in JS *before* the `--shift` / `--scale-active` writes so the bouncy ease-out only applies on `mouseleave`.
- **Mixing `.is-error` and `.is-shaking` into one class** for the error state shake — keeping them orthogonal is what allows the shake to replay (remove → reflow → re-add) without flickering the whole error treatment.
- **Leaving the input clear glow on `mix-blend-mode: multiply` in dark mode** — flip to `screen`, bump `--glow-opacity` to ~0.85, and paint white gradients in JS.
- **Forgetting to write the tabs pill's first position without a transition** — on first paint and resize, set `transform` + `width` with `transition: none` (then reflow + restore) or the pill animates in from `translateX(0)` / `width: 0`.
- **Tracking the pointer on the tilting element itself** for card hover tilt — bind `pointermove` to the flat outer `.t-tilt` wrapper, not `.t-tilt-card`, or the rotating edges slip under the cursor and the hover flickers.
- **Padding on the accordion grid track** — put padding on `.t-acc-panel-inner`, never on `.t-acc-panel`; padding on the `0fr` track leaves a residual height strip so the panel never fully closes.
- **Morphing the accordion chevron's `d` path** — CSS `d:` path interpolation is Chromium-only, so it never animates on mobile Safari / Firefox. Flip the chevron vertically (`transform: scaleY(-1)`) instead — it passes through a flat line at the midpoint just like the path morph and works everywhere. Keep the path symmetric about its viewBox centre and add `vector-effect: non-scaling-stroke` so the stroke stays constant through the flip. This is what the snippet ships.

## Reference files

- [01-card-resize.md](./01-card-resize.md) — Card resize
- [02-number-pop-in.md](./02-number-pop-in.md) — Number pop-in
- [03-notification-badge.md](./03-notification-badge.md) — Notification badge
- [04-text-states-swap.md](./04-text-states-swap.md) — Text states swap
- [05-menu-dropdown.md](./05-menu-dropdown.md) — Menu dropdown
- [06-modal.md](./06-modal.md) — Modal open / close
- [07-panel-reveal.md](./07-panel-reveal.md) — Panel reveal
- [08-page-side-by-side.md](./08-page-side-by-side.md) — Page side-by-side
- [09-icon-swap.md](./09-icon-swap.md) — Icon swap
- [10-success-check.md](./10-success-check.md) — Success check
- [11-avatar-group-hover.md](./11-avatar-group-hover.md) — Avatar group hover
- [12-error-state-shake.md](./12-error-state-shake.md) — Error state shake
- [13-input-clear-dissolve.md](./13-input-clear-dissolve.md) — Input clear with dissolve
- [14-skeleton-reveal.md](./14-skeleton-reveal.md) — Skeleton loader and reveal
- [15-shimmer-text.md](./15-shimmer-text.md) — Shimmer text
- [16-tabs-sliding.md](./16-tabs-sliding.md) — Tabs sliding
- [17-tooltip.md](./17-tooltip.md) — Tooltip open/close
- [18-texts-reveal.md](./18-texts-reveal.md) — Texts reveal
- [19-card-tilt.md](./19-card-tilt.md) — Card hover tilt
- [20-plus-menu-morph.md](./20-plus-menu-morph.md) — Plus to menu morph
- [21-accordion.md](./21-accordion.md) — Accordion expand
- [22-toast.md](./22-toast.md) — Toast open / close
- [23-like-button.md](./23-like-button.md) — Like button
- [24-learn-more-hover.md](./24-learn-more-hover.md) — Learn more hover
- [25-checkbox-check.md](./25-checkbox-check.md) — Checkbox check
- [26-spinning-counter.md](./26-spinning-counter.md) — Spinning counter
- [27-toggle.md](./27-toggle.md) — Toggle
- [28-thinking-states.md](./28-thinking-states.md) — Thinking states
- [29-reasoning-stream.md](./29-reasoning-stream.md) — Reasoning stream
- [30-streaming-text.md](./30-streaming-text.md) — Streaming text
- [31-matrix-loader.md](./31-matrix-loader.md) — Matrix dot loader
- [32-banner-stacking.md](./32-banner-stacking.md) — Banner stacking
- [_root.css](./_root.css) — the universal install block on its own, ready to import directly.