
Transitions Dev
- 1.5k installs
- 32 repo stars
- Updated May 20, 2026
- jakubantalik/transitions-dev
transitions-dev is an agent skill for implement production css transitions for modals, dropdowns, badges, and page changes.
About
The transitions-dev skill is designed for implement production CSS transitions for modals, dropdowns, badges, and page changes. Transitions.dev Twelve portable CSS transitions, each namespaced under t-* selectors with semantic CSS custom properties. Drop-in: paste the snippet, wire the documented HTML hooks, done. Invoke when the user implements notification badges, modals, dropdowns, or page CSS transitions.
- 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.
Transitions Dev by the numbers
- 1,462 all-time installs (skills.sh)
- +41 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #294 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
transitions-dev capabilities & compatibility
- Capabilities
- trigger + small dot floating on top → notificati · trigger + surface that grows from it → dropdown · surface that slides into a region of the page → · two screens, list ↔ detail or step 1 ↔ step 2 →
- Use cases
- frontend
What transitions-dev says it does
Production-ready CSS transitions for web apps. Use when implementing notification badges, dropdowns, modals, panel reveals, page transitions, card resizes, number pop-ins, text swa
Production-ready CSS transitions for web apps. Use when implementing notification badges, dropdowns, modals, panel reveals, page transitions, card resizes, numb
npx skills add https://github.com/jakubantalik/transitions-dev --skill transitions-devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 32 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 20, 2026 |
| Repository | jakubantalik/transitions-dev ↗ |
How do I implement production css transitions for modals, dropdowns, badges, and page changes?
Implement production CSS transitions for modals, dropdowns, badges, and page changes.
Who is it for?
Frontend developers adding polished CSS transition patterns to web apps.
Skip if: Skip for GSAP timeline animations covered by dedicated animation skills.
When should I use this skill?
User implements notification badges, modals, dropdowns, or page CSS transitions.
What you get
Completed transitions-dev workflow with documented commands, files, and expected deliverables.
- CSS transition rules
- Component animation patterns
Files
Transitions.dev
Twelve 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 |
| Number pop-in | Re-enter each digit with a blurred slide when a number updates. | 02-number-pop-in.md |
| Notification badge | Slide a small badge onto a trigger and pop the dot. | 03-notification-badge.md |
| Text states swap | Swap text in place with a blurred up-and-down transition. | 04-text-states-swap.md |
| Menu dropdown | Open an origin-aware dropdown that grows from its trigger. | 05-menu-dropdown.md |
| Modal open / close | Scale-up modal dialog with a softer scale-down on close. | 06-modal.md |
| Panel reveal | Slide a panel into a region with a cross-blur. | 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 |
| Icon swap | Cross-fade two icons in the same slot with blur and scale. | 09-icon-swap.md |
| Success check | Compose fade + rotate + Y-bob + path stroke-draw to celebrate a completed action. | 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 |
| Error state shake | Per-segment cubic-bezier shake with auto-reverting border + message. | 12-error-state-shake.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.
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.
Universal install
Drop this :root block into your project once. Every transition snippet reads from these semantic names — there are no per-component values to chase down later.
/* transitions-dev — copy this :root block into your project once.
Every transition snippet reads from these semantic names. */
:root {
/* 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: 200ms;
--text-swap-translate-y: 8px;
--text-swap-blur: 2px;
--text-swap-ease: ease-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: 200ms;
--page-fade-dur: 200ms;
--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: 200ms;
--icon-swap-blur: 2px;
--icon-swap-start-scale: 0.25;
--icon-swap-ease: ease-in-out;
/* Success check */
--check-opacity-dur: 550ms;
--check-rotate-dur: 550ms;
--check-rotate-from: 80deg;
--check-bob-dur: 450ms;
--check-y-amount: 40px;
--check-blur-dur: 500ms;
--check-blur-from: 10px;
--check-path-dur: 550ms;
--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;
}The --pX-* source tokens used by the live demo at transitions.dev are intentionally not exported here. Tunable values are renamed to semantic names (--badge-*, --dropdown-*, --modal-*, …) so the user owns the design vocabulary.
Output format
When inserting a transition into the user's project:
1. Add the `:root` block above to the user's global stylesheet, but only if it isn't already there. If the user already imported the universal install block once, 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-input, …) and state attributes (data-open, data-state, data-page, .is-open, .is-closing, .is-exit, .is-enter-start, .is-animating, .is-error, .is-shaking). 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), 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.
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
setTimeoutthat 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(oroffsetHeight) 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
20as a placeholder. Replace it withpath.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-activewrites so the bouncy ease-out only applies onmouseleave. - 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.
Reference files
- 01-card-resize.md — Card resize
- 02-number-pop-in.md — Number pop-in
- 03-notification-badge.md — Notification badge
- 04-text-states-swap.md — Text states swap
- 05-menu-dropdown.md — Menu dropdown
- 06-modal.md — Modal open / close
- 07-panel-reveal.md — Panel reveal
- 08-page-side-by-side.md — Page side-by-side
- 09-icon-swap.md — Icon swap
- 10-success-check.md — Success check
- 11-avatar-group-hover.md — Avatar group hover
- 12-error-state-shake.md — Error state shake
- _root.css — the universal install block on its own, ready to import directly.
/* transitions-dev — copy this :root block into your project once.
Every transition snippet reads from these semantic names. */
:root {
/* 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: 200ms;
--text-swap-translate-y: 8px;
--text-swap-blur: 2px;
--text-swap-ease: ease-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: 200ms;
--page-fade-dur: 200ms;
--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: 200ms;
--icon-swap-blur: 2px;
--icon-swap-start-scale: 0.25;
--icon-swap-ease: ease-in-out;
/* Success check */
--check-opacity-dur: 550ms;
--check-rotate-dur: 550ms;
--check-rotate-from: 80deg;
--check-bob-dur: 450ms;
--check-y-amount: 40px;
--check-blur-dur: 500ms;
--check-blur-from: 10px;
--check-path-dur: 550ms;
--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;
}
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
<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. 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.
:root {
--resize-dur: 300ms;
--resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
}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.
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
<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. 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.
: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
@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
// 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");
}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
<!-- 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. 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.
: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
@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.
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
<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 | 200ms | sourced from --p6-dur |
--text-swap-translate-y | 8px | sourced from --p6-translate-y |
--text-swap-blur | 2px | sourced from --p6-blur |
--text-swap-ease | ease-out | sourced from --p6-ease |
The :root defaults below match the live tuning on 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.
:root {
--text-swap-dur: 200ms;
--text-swap-translate-y: 8px;
--text-swap-blur: 2px;
--text-swap-ease: ease-out;
}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
// 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);
}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
<div class="t-dropdown" data-origin="top-center">
<!-- your menu contents -->
</div>State:
- Add
.is-opento show. - On close, swap
.is-openfor.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. 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.
: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
.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
// 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);
}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
<div class="t-modal" role="dialog">…</div>State:
- Add
.is-opento open (scales up from --modal-scale). - On close, swap
.is-openfor.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. 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.
: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
.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
// 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);
}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
<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. 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.
: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
.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.
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
<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 | 200ms | sourced from --p8-slide-dur |
--page-fade-dur | 200ms | 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. 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.
:root {
--page-slide-dur: 200ms;
--page-fade-dur: 200ms;
--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
.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
// 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));
}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
<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 | 200ms | 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. 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.
:root {
--icon-swap-dur: 200ms;
--icon-swap-blur: 2px;
--icon-swap-start-scale: 0.25;
--icon-swap-ease: ease-in-out;
}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.
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
<!-- 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 | 550ms | sourced from --p10-opacity-dur |
--check-rotate-dur | 550ms | sourced from --p10-rotate-dur |
--check-rotate-from | 80deg | sourced from --p10-rotate-from |
--check-bob-dur | 450ms | 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 | 550ms | 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. 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.
:root {
--check-opacity-dur: 550ms;
--check-rotate-dur: 550ms;
--check-rotate-from: 80deg;
--check-bob-dur: 450ms;
--check-y-amount: 40px;
--check-blur-dur: 500ms;
--check-blur-from: 10px;
--check-path-dur: 550ms;
--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
/* 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
// 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:
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:
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.
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
<!-- 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. 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.
: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
/* 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
// 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
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.
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
<!-- 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-errorto .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-shakingfrom
.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. 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.
: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
/* 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
// 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-erroron.t-input-wrapcontrols the message visibility — the message lives in the wrap, not the input..is-erroron.t-inputcontrols the border color — the input owns the border..is-shakingon.t-inputis separate from.is-errorso 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.
Related skills
FAQ
What does transitions-dev do?
Implement production CSS transitions for modals, dropdowns, badges, and page changes.
When should I use transitions-dev?
User implements notification badges, modals, dropdowns, or page CSS transitions.
Is transitions-dev safe to install?
Review the Security Audits panel on this page before installing in production.