
Transitions Dev
- 7.4k installs
- 2.5k repo stars
- Updated July 30, 2026
- jakubantalik/transitions.dev
transitions-dev is a skill that ships eighteen production-ready CSS transitions for web apps using t-* selectors, semantic custom properties, and documented HTML hooks.
About
Transitions.dev packages eighteen portable CSS transitions for web apps, each namespaced under t-* selectors with semantic CSS custom properties and no framework dependencies. The skill maps visible UI elements to snippets for notification badges, origin-aware dropdowns, centered modals, panel reveals, page side-by-side slides, card resizes, text and icon swaps, number pop-ins, success checks, avatar group hovers, error shakes, input clear dissolves, skeleton loaders, shimmer text, sliding tabs, tooltips, and staggered text reveals. Four namespaced commands list transitions (transitions reveal), audit a codebase (transitions review), install the best-fit snippet with hooks (transitions apply), and align timing to motion tokens (transitions refine). Installation copies _root.css variables once, pastes verbatim CSS, wires documented class hooks and state attributes, preserves prefers-reduced-motion guards, and adds small JS orchestration only where required. Decision rules prefer lower-overhead transitions when two patterns could fit.
- Eighteen drop-in t-* CSS transitions with semantic custom properties and no framework lock-in.
- Decision rules match UI elements first, then verbs, with lower-overhead tie-breakers.
- transitions reveal, review, apply, and refine commands cover catalog, audit, install, and token tuning.
- Universal _root.css variables plus per-snippet hooks, state attributes, and reduced-motion guards.
- Motion tokens document durations, easings, distances, scales, and blur values for consistent timing.
Transitions Dev by the numbers
- 7,432 all-time installs (skills.sh)
- +644 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #71 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
transitions-dev capabilities & compatibility
- Capabilities
- map ui elements to eighteen named transition pat · install _root.css variables and per snippet css · wire t * class hooks and data state attributes f · preserve prefers reduced motion blocks required · run transitions review to suggest best fit snipp · tune durations and easing via transitions refine
- Use cases
- frontend · ui design · refactoring
- Platforms
- macOS · Windows · Linux
- Runs
- Runs locally
- Pricing
- Free
What transitions-dev says it does
Eighteen 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.
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 he
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 | 7.4k |
|---|---|
| repo stars | ★ 2.5k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 30, 2026 |
| Repository | jakubantalik/transitions.dev ↗ |
What it does
Install production CSS transitions for modals, dropdowns, badges, skeletons, and page slides without a motion library.
Who is it for?
Frontend developers adding dropdown, modal, badge, skeleton, tab, tooltip, or page transition polish to existing web UIs.
Skip if: Skip when the task needs timeline animation libraries, 3D scenes, or transitions outside the eighteen documented patterns.
When should I use this skill?
User asks to add a transition, animate a dropdown or modal, swap icons, stagger text, apply skeleton reveals, or run transitions review or refine.
What you get
A chosen transition snippet is installed with variables, hooks, reduced-motion handling, and optional JS timing reads aligned to motion tokens.
- _root.css motion tokens
- t-* transition CSS
- HTML hook wiring
By the numbers
- [object Object]
- [object Object]
Files
Transitions.dev
Eighteen 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 |
| Input clear with dissolve | Fly-out + per-word streak when a text field is cleared. | 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 |
| Shimmer text | Sweep a highlight band across muted text on a loop (pure CSS). | 15-shimmer-text.md |
| Tabs sliding | Slide the active pill between tabs in a segmented control. | 16-tabs-sliding.md |
| Tooltip open/close | Delayed fade+scale in, instant out (pure CSS). | 17-tooltip.md |
| Texts reveal | Staggered blurred rise for stacked text lines, quiet fade out. | 18-texts-reveal.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.
- No clear match → fall back to
transitions revealand 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 eighteen 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 — align existing values to the motion tokens
Trigger phrases: transitions refine, "refine my transitions", "improve the animation values", "tune the durations / easing", "make the timing consistent", "align to the motion tokens".
Behaviour:
1. Search style files for transition/animation values: transition / animation shorthands, @keyframes, durations (…ms / …s), easing (cubic-bezier(...) or keywords), translate distances (px), scale(...), and blur(...). 2. For each declaration, infer what it does (modal close, dropdown open, tooltip, badge appear, text reveal, page slide, shake, …) from the surrounding selectors plus the ## Decision rules. 3. Look the usage up in ## Motion tokens and read the recommended duration, easing, distance, scale, and blur for it. 4. Output a numbered list grouped by file of only the values that differ from the token: path/to/Component.css:L42 — modal close 300ms → 150ms (Quick), ease → cubic-bezier(0.22, 1, 0.36, 1) (Smooth ease out). Note any value with no matching usage and leave it untouched. 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 value vocabulary behind the eighteen transitions. transitions refine maps each existing value to a usage below, then suggests the token whose value differs. Match on usage, not on the raw number — a 300ms modal close is still a Quick (150ms) close.
Durations
| Value | Name | Usage |
|---|---|---|
| 40ms | Stagger | per-item stagger offset |
| 80ms | Micro | tooltip delay, shake segment, large stagger |
| 150ms | Quick | modal close, dropdown close, text swap, tooltip appear |
| 250ms | Fast | icon swap, dropdown open, modal open, tabs sliding, page slide |
| 350ms | Medium | panel close, toast close |
| 400ms | Slow | panel open, skeleton content reveal, input clear |
| 500ms | Very slow | emphasis moments, badge appear, text reveal, success check |
Easings
| Value | Name | Usage |
|---|---|---|
| cubic-bezier(0.22, 1, 0.36, 1) | Smooth ease out | 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 |
| linear | Linear | shimmer, skeleton pulse, spinner |
| cubic-bezier(0.34, 1.36, 0.64, 1) | Bouncy overshoot | badge pop open |
| cubic-bezier(0.34, 3.85, 0.64, 1) | Strong bouncy overshoot | bouncy hover-out (avatar return) |
Distances
| Value | Name | Usage |
|---|---|---|
| 4px | Micro | text swap |
| 6px | Small | error shake (small segment) |
| 8px | Base | badge diagonal reveal, page slide, error shake (large segment) |
| 12px | Medium | text reveal |
| 30px | Large | check badge appear |
Scales
| Value | Name | Usage |
|---|---|---|
| 0.96 | Large | modal open / close |
| 0.97 | Medium | dropdown open |
| 0.98 | Small | tooltip open |
| 0.99 | Tiny | dropdown close |
Blur
| Value | Name | Usage |
|---|---|---|
| 2px | Small | panel reveal, icon swap, text swap, skeleton reveal, number pop-in |
| 3px | Medium | page slide, text reveal |
| 8px | Large | success check open |
Universal install
Copy `_root.css` into your project once and import it (or paste its :root block into your global stylesheet). It defines the semantic tunable variables for all eighteen transitions. Every snippet reads from these names — --resize-*, --badge-*, --dropdown-*, --clear-*, --shimmer-*, --tabs-*, --tt-*, --stagger-*, 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 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, …) and state attributes (data-open, data-state, data-page, aria-selected, .is-open, .is-closing, .is-error, .is-shaking, .has-value, .is-clearing, .is-pulsing, .is-revealed, .is-shown, .is-hiding). 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), 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
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.
- Leaving the input clear glow on `mix-blend-mode: multiply` in dark mode — flip to
screen, bump--glow-opacityto ~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+widthwithtransition: none(then reflow + restore) or the pill animates in fromtranslateX(0)/width: 0.
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
- 13-input-clear-dissolve.md — Input clear with dissolve
- 14-skeleton-reveal.md — Skeleton loader and reveal
- 15-shimmer-text.md — Shimmer text
- 16-tabs-sliding.md — Tabs sliding
- 17-tooltip.md — Tooltip open/close
- 18-texts-reveal.md — Texts reveal
- _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: 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 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 | 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. 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: 150ms;
--text-swap-translate-y: 4px;
--text-swap-blur: 2px;
--text-swap-ease: ease-in-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 | 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. 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: 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
.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 | 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. 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: 250ms;
--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 | 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. 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: 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
/* 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.
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
<!-- 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. 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 {
--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
/* 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
// 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.
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
<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-pulsingon the skeleton so it pulses
--pulse-count times.
- When data arrives, add
.is-revealedto .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. 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 {
--pulse-dur: 1000ms;
--pulse-count: 1;
--pulse-min: 0.5;
--reveal-dur: 400ms;
--reveal-blur: 2px;
--reveal-ease: ease-in-out;
}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
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);
}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
<!-- 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. 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 {
--shimmer-dur: 2000ms;
--shimmer-base: #7c7c7c;
--shimmer-highlight: #0d0d0d;
--shimmer-band: 400%;
--shimmer-ease: linear;
}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.
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
<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. 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 {
--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
/* 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
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));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
<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. 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 {
--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
.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.
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
<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-shownto 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. 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 {
--stagger-dur: 500ms;
--stagger-distance: 12px;
--stagger-stagger: 40ms;
--stagger-blur: 3px;
--stagger-ease: cubic-bezier(0.22, 1, 0.36, 1);
}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
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);
}Related skills
How it compares
Pick transitions-dev for copy-paste CSS micro-interactions; use JS motion libraries when orchestrating complex multi-element timelines.
FAQ
How many transitions does transitions.dev include?
The skill documents eighteen portable CSS transitions, each namespaced under t-* selectors with semantic CSS custom properties.
Does installation require a motion library?
No. Drop-in snippets paste verbatim CSS, wire documented HTML hooks, and ship prefers-reduced-motion guards without framework dependencies.
What commands does the skill expose?
transitions reveal lists all transitions, transitions review audits the project, transitions apply installs one snippet, and transitions refine tunes values to motion tokens.
Is Transitions Dev safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.