transitions-refine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/skills/refine-live/SKILL.md +205 -0
  2. package/.agents/skills/transitions-dev/01-card-resize.md +53 -0
  3. package/.agents/skills/transitions-dev/02-number-pop-in.md +119 -0
  4. package/.agents/skills/transitions-dev/03-notification-badge.md +110 -0
  5. package/.agents/skills/transitions-dev/04-text-states-swap.md +97 -0
  6. package/.agents/skills/transitions-dev/05-menu-dropdown.md +105 -0
  7. package/.agents/skills/transitions-dev/06-modal.md +94 -0
  8. package/.agents/skills/transitions-dev/07-panel-reveal.md +81 -0
  9. package/.agents/skills/transitions-dev/08-page-side-by-side.md +100 -0
  10. package/.agents/skills/transitions-dev/09-icon-swap.md +78 -0
  11. package/.agents/skills/transitions-dev/10-success-check.md +169 -0
  12. package/.agents/skills/transitions-dev/11-avatar-group-hover.md +200 -0
  13. package/.agents/skills/transitions-dev/12-error-state-shake.md +202 -0
  14. package/.agents/skills/transitions-dev/13-input-clear-dissolve.md +276 -0
  15. package/.agents/skills/transitions-dev/14-skeleton-reveal.md +149 -0
  16. package/.agents/skills/transitions-dev/15-shimmer-text.md +95 -0
  17. package/.agents/skills/transitions-dev/16-tabs-sliding.md +146 -0
  18. package/.agents/skills/transitions-dev/17-tooltip.md +103 -0
  19. package/.agents/skills/transitions-dev/18-texts-reveal.md +110 -0
  20. package/.agents/skills/transitions-dev/19-card-tilt.md +170 -0
  21. package/.agents/skills/transitions-dev/20-plus-menu-morph.md +167 -0
  22. package/.agents/skills/transitions-dev/21-accordion.md +124 -0
  23. package/.agents/skills/transitions-dev/SKILL.md +225 -0
  24. package/.agents/skills/transitions-dev/_root.css +204 -0
  25. package/README.md +89 -0
  26. package/bin/cli.mjs +264 -0
  27. package/demo.html +2531 -0
  28. package/package.json +37 -0
  29. package/server/inject.mjs +116 -0
  30. package/server/motion-tokens.mjs +106 -0
  31. package/server/refine-agent.mjs +86 -0
  32. package/server/relay.mjs +421 -0
@@ -0,0 +1,200 @@
1
+ # Avatar group hover
2
+
3
+ ## When to use
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ ## HTML usage
10
+
11
+ ```html
12
+ <!-- Apply .t-avatar to each item in your group (avatar,
13
+ chip, badge, button — anything). Bring your own size,
14
+ shape, and stacking; this stylesheet only owns the
15
+ hover transform + transition. -->
16
+ <div class="t-avatar-group">
17
+ <div class="t-avatar"><!-- your item --></div>
18
+ <div class="t-avatar"><!-- your item --></div>
19
+ <!-- … -->
20
+ </div>
21
+ ```
22
+
23
+ Wire-up (vanilla JS):
24
+ On `mouseenter` of any .t-avatar, walk every sibling and
25
+ set inline:
26
+ el.style.setProperty('--shift',
27
+ (lift * Math.pow(falloff, distance)).toFixed(3) + 'px');
28
+ el.style.setProperty('--scale-active',
29
+ i === activeIdx ? scale : 1);
30
+ Set transition-timing-function inline BEFORE the
31
+ variable writes — use --avatar-ease-in on hover-in and
32
+ --avatar-ease-out on the root's `mouseleave` (resets
33
+ --shift to 0 and --scale-active to 1).
34
+
35
+ ## Tunable variables
36
+
37
+ | Variable | Default | Notes |
38
+ | --- | --- | --- |
39
+ | `--avatar-lift` | `-4px` | sourced from `--p11-lift` |
40
+ | `--avatar-dur` | `320ms` | sourced from `--p11-dur` |
41
+ | `--avatar-scale` | `1.05` | sourced from `--p11-scale` |
42
+ | `--avatar-falloff` | `0.45` | sourced from `--p11-falloff` |
43
+ | `--avatar-ease-in` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p11-ease-in` |
44
+ | `--avatar-ease-out` | `cubic-bezier(0.34, 3.85, 0.64, 1)` | sourced from `--p11-ease-out` |
45
+
46
+ The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.
47
+
48
+ ```css
49
+ :root {
50
+ --avatar-lift: -4px;
51
+ --avatar-dur: 320ms;
52
+ --avatar-scale: 1.05;
53
+ --avatar-falloff: 0.45;
54
+ --avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
55
+ --avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
56
+ }
57
+ ```
58
+
59
+ ## CSS
60
+
61
+ ```css
62
+ /* Hover-spring transition only — bring your own avatar/chip
63
+ styling (size, shape, border, stacking, background). */
64
+ .t-avatar {
65
+ transform-origin: center;
66
+ /* translateY before scale so scale doesn't amplify the lift offset. */
67
+ transform:
68
+ translateY(var(--shift, 0px))
69
+ scale(var(--scale-active, 1));
70
+ transition: transform var(--avatar-dur) var(--avatar-ease-in);
71
+ will-change: transform;
72
+ }
73
+
74
+ @media (prefers-reduced-motion: reduce) {
75
+ .t-avatar { transition: none !important; transform: none !important; }
76
+ }
77
+ ```
78
+
79
+ 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.
80
+
81
+ ## JavaScript orchestration
82
+
83
+ ```js
84
+ // Distance-falloff lift with direction-aware easing. The trick
85
+ // is setting transition-timing-function inline BEFORE writing the
86
+ // CSS variables — the browser uses whatever timing-function is
87
+ // current at the moment a transitionable property changes, so this
88
+ // gives us ease-in on the way up and a bouncy spring on the return
89
+ // without two separate transition declarations.
90
+ const root = document.querySelector(".t-avatar-group");
91
+ const avatars = Array.from(root.querySelectorAll(".t-avatar"));
92
+ const cs = getComputedStyle(document.documentElement);
93
+ const num = (name, fb) => {
94
+ const v = parseFloat(cs.getPropertyValue(name));
95
+ return Number.isFinite(v) ? v : fb;
96
+ };
97
+ const ease = (name, fb) =>
98
+ cs.getPropertyValue(name).trim() || fb;
99
+
100
+ function setShifts(activeIdx, phase) {
101
+ const lift = num("--avatar-lift", -4);
102
+ const falloff = num("--avatar-falloff", 0.45);
103
+ const scale = num("--avatar-scale", 1.05);
104
+ const tf = phase === "out"
105
+ ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")
106
+ : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
107
+
108
+ avatars.forEach((el, i) => {
109
+ el.style.transitionTimingFunction = tf;
110
+ if (activeIdx == null) {
111
+ el.style.setProperty("--shift", "0px");
112
+ el.style.setProperty("--scale-active", "1");
113
+ return;
114
+ }
115
+ const d = Math.abs(i - activeIdx);
116
+ el.style.setProperty(
117
+ "--shift",
118
+ (lift * Math.pow(falloff, d)).toFixed(3) + "px"
119
+ );
120
+ el.style.setProperty(
121
+ "--scale-active",
122
+ i === activeIdx ? String(scale) : "1"
123
+ );
124
+ });
125
+ }
126
+
127
+ avatars.forEach((el, i) => {
128
+ el.addEventListener("mouseenter", () => setShifts(i, "in"));
129
+ });
130
+ root.addEventListener("mouseleave", () => setShifts(null, "out"));
131
+ ```
132
+
133
+ ### React form
134
+
135
+ ```jsx
136
+ import { useRef } from "react";
137
+
138
+ // `items` is any list of React nodes (avatars, chips, badges, …)
139
+ // — this hook only owns the hover-spring transition. Each item is
140
+ // wrapped in a .t-avatar so it picks up the transform/transition
141
+ // rules from CSS.
142
+ export function AvatarGroup({ items }) {
143
+ const rootRef = useRef(null);
144
+
145
+ const setShifts = (activeIdx, phase) => {
146
+ if (!rootRef.current) return;
147
+ const cs = getComputedStyle(document.documentElement);
148
+ const num = (name, fb) => {
149
+ const v = parseFloat(cs.getPropertyValue(name));
150
+ return Number.isFinite(v) ? v : fb;
151
+ };
152
+ const ease = (name, fb) =>
153
+ cs.getPropertyValue(name).trim() || fb;
154
+
155
+ const lift = num("--avatar-lift", -4);
156
+ const falloff = num("--avatar-falloff", 0.45);
157
+ const scale = num("--avatar-scale", 1.05);
158
+ const tf = phase === "out"
159
+ ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")
160
+ : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
161
+
162
+ rootRef.current.querySelectorAll(".t-avatar").forEach((el, i) => {
163
+ el.style.transitionTimingFunction = tf;
164
+ if (activeIdx == null) {
165
+ el.style.setProperty("--shift", "0px");
166
+ el.style.setProperty("--scale-active", "1");
167
+ return;
168
+ }
169
+ const d = Math.abs(i - activeIdx);
170
+ el.style.setProperty(
171
+ "--shift",
172
+ (lift * Math.pow(falloff, d)).toFixed(3) + "px"
173
+ );
174
+ el.style.setProperty(
175
+ "--scale-active",
176
+ i === activeIdx ? String(scale) : "1"
177
+ );
178
+ });
179
+ };
180
+
181
+ return (
182
+ <div ref={rootRef} onMouseLeave={() => setShifts(null, "out")}>
183
+ {items.map((node, i) => (
184
+ <div
185
+ key={i}
186
+ className="t-avatar"
187
+ onMouseEnter={() => setShifts(i, "in")}
188
+ >
189
+ {node}
190
+ </div>
191
+ ))}
192
+ </div>
193
+ );
194
+ }
195
+ ```
196
+
197
+ ### Why the timing-function is set inline before the variable writes
198
+
199
+ 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.
200
+
@@ -0,0 +1,202 @@
1
+ # Error state shake
2
+
3
+ ## When to use
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ ## HTML usage
10
+
11
+ ```html
12
+ <!-- Apply .t-input-wrap to your wrapper, .t-input to the
13
+ element that should shake (your input field, its
14
+ bordered wrapper — whatever owns the visible border),
15
+ and .t-error-msg to the message you want to reveal.
16
+ Bring your own sizing, padding, border colors, and
17
+ typography. -->
18
+ <div class="t-input-wrap">
19
+ <div class="t-input">
20
+ <input type="text">
21
+ </div>
22
+ <p class="t-error-msg">Please enter a valid email.</p>
23
+ </div>
24
+ ```
25
+
26
+ Trigger:
27
+ - Add `.is-error` to .t-input-wrap and .t-input. Your
28
+ own border-color rules drive the visible color; this
29
+ stylesheet only owns the tween.
30
+ - Restart the shake by removing `.is-shaking` from
31
+ .t-input, forcing a reflow, then re-adding it.
32
+ - Optional: after --revert-hold ms, drop both
33
+ `.is-error` classes so border + message fade back
34
+ to neutral over --revert-dur.
35
+
36
+ Per-segment ease: each keyframe stop carries its own
37
+ animation-timing-function so each leg follows the Figma
38
+ cubic-bezier curve independently.
39
+
40
+ ## Tunable variables
41
+
42
+ | Variable | Default | Notes |
43
+ | --- | --- | --- |
44
+ | `--shake-distance` | `6px` | sourced from `--p12-shake-distance` |
45
+ | `--shake-overshoot` | `4px` | sourced from `--p12-shake-overshoot` |
46
+ | `--shake-dur-a` | `80ms` | sourced from `--p12-shake-dur-a` |
47
+ | `--shake-dur-b` | `60ms` | sourced from `--p12-shake-dur-b` |
48
+ | `--shake-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p12-shake-ease` |
49
+ | `--revert-hold` | `3000ms` | sourced from `--p12-revert-hold` |
50
+ | `--revert-dur` | `280ms` | sourced from `--p12-revert-dur` |
51
+
52
+ The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.
53
+
54
+ ```css
55
+ :root {
56
+ --shake-distance: 6px;
57
+ --shake-overshoot: 4px;
58
+ --shake-dur-a: 80ms;
59
+ --shake-dur-b: 60ms;
60
+ --shake-ease: cubic-bezier(0.22, 1, 0.36, 1);
61
+ --revert-hold: 3000ms;
62
+ --revert-dur: 280ms;
63
+ }
64
+ ```
65
+
66
+ ## CSS
67
+
68
+ ```css
69
+ /* Border-color tween. Define your input's default / focused
70
+ / error border-color in your own component CSS — this rule
71
+ only owns the interpolation. Use a constant border-width
72
+ across states so the tween never shifts inner content. */
73
+ .t-input {
74
+ transition: border-color 150ms ease-out;
75
+ will-change: transform;
76
+ }
77
+ .t-input.is-error {
78
+ /* Error border auto-reverts on the hold timer, so the
79
+ fade-out uses the slower revert duration (matches the
80
+ message fade). */
81
+ transition: border-color var(--revert-dur, 280ms) ease-out;
82
+ }
83
+
84
+ /* Error message reveal. Visibility is delayed by --revert-dur
85
+ on hide so the message stays painted for the full opacity
86
+ fade-out. Entering .is-error drops the delay to 0 so the
87
+ message becomes visible immediately. */
88
+ .t-error-msg {
89
+ opacity: 0;
90
+ visibility: hidden;
91
+ transition:
92
+ opacity var(--revert-dur, 280ms) ease-out,
93
+ visibility 0s linear var(--revert-dur, 280ms);
94
+ }
95
+ .t-input-wrap.is-error .t-error-msg {
96
+ opacity: 1;
97
+ visibility: visible;
98
+ transition:
99
+ opacity var(--revert-dur, 280ms) ease-out,
100
+ visibility 0s linear 0s;
101
+ }
102
+
103
+ /* Multi-segment keyframe with per-stop easing so each leg
104
+ of the shake follows its own cubic-bezier independently.
105
+ %-stops are cumulative durations as a fraction of the
106
+ total (80, 60, 80, 60 = 280ms): 28.57%, 57.14%, 78.57%,
107
+ 100%. Recompute if any segment duration changes. */
108
+ .t-input.is-shaking {
109
+ animation: t-input-shake calc(
110
+ var(--shake-dur-a) * 2 + var(--shake-dur-b) * 2
111
+ ) linear;
112
+ }
113
+ @keyframes t-input-shake {
114
+ 0% { transform: translateX(0); animation-timing-function: var(--shake-ease); }
115
+ 28.57% { transform: translateX(var(--shake-distance)); animation-timing-function: var(--shake-ease); }
116
+ 57.14% { transform: translateX(calc(var(--shake-distance) * -1)); animation-timing-function: var(--shake-ease); }
117
+ 78.57% { transform: translateX(var(--shake-overshoot)); animation-timing-function: var(--shake-ease); }
118
+ 100% { transform: translateX(0); }
119
+ }
120
+
121
+ @media (prefers-reduced-motion: reduce) {
122
+ .t-input { animation: none !important; transform: none !important; }
123
+ }
124
+ ```
125
+
126
+ 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.
127
+
128
+ ## JavaScript orchestration
129
+
130
+ ```js
131
+ // Trigger the error state, replay the shake, and schedule the
132
+ // auto-revert. Cancel any in-flight revert so the timer always
133
+ // tracks the latest call.
134
+ const wrap = document.querySelector(".t-input-wrap");
135
+ const input = wrap.querySelector(".t-input");
136
+
137
+ const cs = getComputedStyle(document.documentElement);
138
+ const ms = (name, fb) => {
139
+ const v = parseFloat(cs.getPropertyValue(name));
140
+ return Number.isFinite(v) ? v : fb;
141
+ };
142
+
143
+ function showError() {
144
+ wrap.classList.add("is-error");
145
+ input.classList.add("is-error");
146
+
147
+ // Replay the shake from a clean baseline.
148
+ input.classList.remove("is-shaking");
149
+ void input.offsetWidth; // force reflow
150
+ input.classList.add("is-shaking");
151
+
152
+ const shakeMs =
153
+ ms("--shake-dur-a", 80) * 2 +
154
+ ms("--shake-dur-b", 60) * 2;
155
+ setTimeout(() => input.classList.remove("is-shaking"), shakeMs + 20);
156
+
157
+ // Auto-revert: hold long enough to read the message, then fade
158
+ // border + message back to neutral via the CSS transitions.
159
+ if (wrap._revertTimer) clearTimeout(wrap._revertTimer);
160
+ const hold = ms("--revert-hold", 3000);
161
+ wrap._revertTimer = setTimeout(() => {
162
+ wrap._revertTimer = null;
163
+ wrap.classList.remove("is-error");
164
+ input.classList.remove("is-error");
165
+ }, shakeMs + hold);
166
+ }
167
+
168
+ // Optional but recommended: typing cancels the auto-revert and
169
+ // clears the error so the user isn't shaking at a value they're
170
+ // already correcting.
171
+ const inputEl = wrap.querySelector("input, textarea");
172
+ inputEl?.addEventListener("input", () => {
173
+ if (wrap._revertTimer) {
174
+ clearTimeout(wrap._revertTimer);
175
+ wrap._revertTimer = null;
176
+ }
177
+ wrap.classList.remove("is-error");
178
+ input.classList.remove("is-error");
179
+ });
180
+ ```
181
+
182
+ ### Recomputing the keyframe stops
183
+
184
+ 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:
185
+
186
+ ```
187
+ total = 2·A + 2·B = 2·80 + 2·60 = 280ms
188
+ stop 1 (start) = 0 / 280 = 0% (rest)
189
+ stop 2 (after A) = 80 / 280 = 28.57% (peak right, +distance)
190
+ stop 3 (after 2·A) = 160 / 280 = 57.14% (peak left, -distance)
191
+ stop 4 (after 2·A+B) = 220 / 280 = 78.57% (overshoot, +overshoot)
192
+ stop 5 (end) = 280 / 280 = 100% (rest)
193
+ ```
194
+
195
+ 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.
196
+
197
+ ### Why three classes (`.is-error` on wrap + input, `.is-shaking` on input)
198
+
199
+ - `.is-error` on `.t-input-wrap` controls the **message** visibility — the message lives in the wrap, not the input.
200
+ - `.is-error` on `.t-input` controls the **border color** — the input owns the border.
201
+ - `.is-shaking` on `.t-input` is **separate** from `.is-error` so you can replay the shake (remove → reflow → add) without flickering the error state on/off in the same tick. Keeping the shake state orthogonal also lets you trigger the shake on its own (e.g. for a "hint" jiggle) without the full error treatment.
202
+
@@ -0,0 +1,276 @@
1
+ # Input clear with dissolve
2
+
3
+ ## When to use
4
+
5
+ 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.
6
+
7
+ ## HTML usage
8
+
9
+ ```html
10
+ <!-- Drop .t-clear on a wrapper that you've sized like an
11
+ input field (positioning, padding, border, radius are
12
+ yours). Inside it stack a real <input>, a mirror that
13
+ visualizes the value, a fake placeholder for the new
14
+ empty state, and a glow layer that gets the per-word
15
+ radial-gradient stack written into it from JS. -->
16
+ <div class="t-clear has-value">
17
+ <input type="text" value="…" />
18
+ <div class="t-clear-mirror" aria-hidden="true">…</div>
19
+ <div class="t-clear-placeholder" aria-hidden="true">Search</div>
20
+ <div class="t-clear-glow" aria-hidden="true"></div>
21
+ <button class="t-clear-btn" aria-label="Clear">…</button>
22
+ </div>
23
+ ```
24
+
25
+ Pair with a small JS routine (mirrors the gallery code) that
26
+ flips `.is-clearing`, animates the mirror's translateY/opacity
27
+ per frame, mirrors the math on the placeholder, and writes a
28
+ stack of `radial-gradient(...)` layers onto .t-clear-glow's
29
+ background so each word gets its own streak. Per-frame JS is
30
+ unavoidable: the streak's rise/peak/fall envelope cannot be
31
+ expressed as a static @keyframe.
32
+
33
+ ## Tunable variables
34
+
35
+ | Variable | Default | Notes |
36
+ | --- | --- | --- |
37
+ | `--clear-dur` | `1000ms` | sourced from `--p13-clear-dur` |
38
+ | `--clear-out-dur` | `400ms` | sourced from `--p13-text-out-dur` |
39
+ | `--clear-in-dur` | `400ms` | sourced from `--p13-text-in-dur` |
40
+ | `--clear-out-fly` | `12px` | sourced from `--p13-text-out-fly` |
41
+ | `--clear-in-fly` | `12px` | sourced from `--p13-text-in-fly` |
42
+ | `--clear-out-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p13-text-out-ease` |
43
+ | `--clear-in-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p13-text-in-ease` |
44
+ | `--clear-blur` | `2px` | sourced from `--p13-blur` |
45
+ | `--glow-delay` | `50ms` | sourced from `--p13-glow-delay` |
46
+ | `--glow-peak-at` | `0.15` | sourced from `--p13-glow-peak-at` |
47
+ | `--glow-opacity` | `0.42` | sourced from `--p13-glow-opacity` |
48
+ | `--glow-spread` | `1.5` | sourced from `--p13-glow-spread` |
49
+
50
+ The `:root` defaults below match the live tuning on [transitions.dev](https://transitions.dev). Drop them into your global stylesheet once — every transition in this skill reads from semantic names like these, so multiple transitions can share a single `:root` block.
51
+
52
+ ```css
53
+ :root {
54
+ --clear-dur: 1000ms;
55
+ --clear-out-dur: 400ms;
56
+ --clear-in-dur: 400ms;
57
+ --clear-out-fly: 12px;
58
+ --clear-in-fly: 12px;
59
+ --clear-out-ease: cubic-bezier(0.22, 1, 0.36, 1);
60
+ --clear-in-ease: cubic-bezier(0.22, 1, 0.36, 1);
61
+ --clear-blur: 2px;
62
+ --glow-delay: 50ms;
63
+ --glow-peak-at: 0.15;
64
+ --glow-opacity: 0.42;
65
+ --glow-spread: 1.5;
66
+ }
67
+ ```
68
+
69
+ ## CSS
70
+
71
+ ```css
72
+ /* The wrap clips the glow to its rounded box. The hairline
73
+ border is `inset` so it sits inside that clip — when the
74
+ glow's mix-blend-mode darkens its area, the border
75
+ underneath darkens with it. Bring your own width / height /
76
+ border-radius / surface color. */
77
+ .t-clear {
78
+ position: relative;
79
+ overflow: hidden;
80
+ }
81
+ .t-clear-mirror,
82
+ .t-clear-placeholder {
83
+ position: absolute;
84
+ inset: 0;
85
+ display: flex;
86
+ align-items: center;
87
+ pointer-events: none;
88
+ white-space: nowrap;
89
+ overflow: hidden;
90
+ z-index: 2;
91
+ }
92
+ .t-clear-mirror { opacity: 0; }
93
+ .t-clear.has-value .t-clear-mirror,
94
+ .t-clear.is-clearing .t-clear-mirror { opacity: 1; }
95
+ /* Hide the input's own glyphs while the mirror owns them so
96
+ the cleared text doesn't double-render with the fly-up. */
97
+ .t-clear.has-value > input,
98
+ .t-clear.is-clearing > input {
99
+ -webkit-text-fill-color: transparent;
100
+ }
101
+ .t-clear.has-value .t-clear-placeholder { opacity: 0; }
102
+ /* The streak overlay: empty by default; JS writes a stack of
103
+ `radial-gradient(...)` layers into background during a clear,
104
+ then animates opacity. mix-blend-mode: multiply darkens the
105
+ underlying input + hairline; flip to `screen` in dark mode
106
+ so the same alpha values lighten instead of vanish. */
107
+ .t-clear-glow {
108
+ position: absolute;
109
+ inset: 0;
110
+ pointer-events: none;
111
+ opacity: 0;
112
+ z-index: 3;
113
+ mix-blend-mode: multiply;
114
+ }
115
+
116
+ /* The transitions live in JS (per-frame transform/opacity/
117
+ filter writes), so this stylesheet only owns the resting
118
+ state + the variables that JS reads. Read them with
119
+ `parseFloat(getComputedStyle(root).getPropertyValue(...))`
120
+ so live tweaks apply on the next clear without a reload. */
121
+
122
+ @media (prefers-reduced-motion: reduce) {
123
+ .t-clear-glow { opacity: 0 !important; }
124
+ }
125
+ ```
126
+
127
+ 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.
128
+
129
+ ## JavaScript orchestration
130
+
131
+ ```js
132
+ // One clear routine per .t-clear. Reads timing/geometry from the
133
+ // CSS variables each call so live tweaks apply without a reload.
134
+ const root = document.documentElement;
135
+ const num = (name, fb) => {
136
+ const v = parseFloat(getComputedStyle(root).getPropertyValue(name));
137
+ return Number.isFinite(v) ? v : fb;
138
+ };
139
+ // Minimal cubic-bezier(x1,y1,x2,y2) sampler so JS easing matches CSS.
140
+ function bezier(str) {
141
+ const m = String(str).match(/cubic-bezier\(([-\d.]+),([-\d.]+),([-\d.]+),([-\d.]+)\)/);
142
+ if (!m) return (t) => t;
143
+ const [x1, y1, x2, y2] = m.slice(1).map(parseFloat);
144
+ const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
145
+ const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
146
+ return (t) => {
147
+ if (t <= 0) return 0;
148
+ if (t >= 1) return 1;
149
+ let s = t;
150
+ for (let i = 0; i < 8; i++) {
151
+ const dx = ((ax * s + bx) * s + cx) * s - t;
152
+ const d = (3 * ax * s + 2 * bx) * s + cx;
153
+ if (Math.abs(dx) < 1e-6 || d === 0) break;
154
+ s -= dx / d;
155
+ }
156
+ return ((ay * s + by) * s + cy) * s;
157
+ };
158
+ }
159
+
160
+ document.querySelectorAll(".t-clear").forEach((wrap) => {
161
+ const input = wrap.querySelector("input");
162
+ const mirror = wrap.querySelector(".t-clear-mirror");
163
+ const phold = wrap.querySelector(".t-clear-placeholder");
164
+ const glow = wrap.querySelector(".t-clear-glow");
165
+ const btn = wrap.querySelector(".t-clear-btn");
166
+ const canvas = document.createElement("canvas").getContext("2d");
167
+ let clearing = false;
168
+
169
+ const sync = () => {
170
+ const has = input.value.length > 0;
171
+ wrap.classList.toggle("has-value", has);
172
+ if (has) mirror.textContent = input.value.replace(/ /g, "\u00a0");
173
+ };
174
+
175
+ function buildGlow(text) {
176
+ canvas.font = getComputedStyle(input).font;
177
+ const isDark = root.getAttribute("data-theme") === "dark";
178
+ const rgb = isDark ? "255,255,255" : "0,0,0";
179
+ const w = wrap.clientWidth || 280;
180
+ const padLeft = parseFloat(getComputedStyle(input).paddingLeft) || 12;
181
+ const spread = num("--glow-spread", 1.5);
182
+ const layers = [];
183
+ let x = 0;
184
+ text.split(/(\s+)/).forEach((seg) => {
185
+ const segW = canvas.measureText(seg).width;
186
+ if (seg.trim()) {
187
+ const cx = padLeft + x + segW / 2;
188
+ const hw = Math.max(segW * 0.45, 8) * spread;
189
+ [[0, 0.8, 7, 0.22], [hw * 0.45, 0.55, 8, 0.18],
190
+ [-hw * 0.4, 0.65, 6, 0.16], [hw * 0.15, 0.9, 5, 0.14]]
191
+ .forEach(([dx, rwm, rh, a]) => {
192
+ const lx = (((cx + dx) / w) * 100).toFixed(2);
193
+ layers.push(
194
+ `radial-gradient(ellipse ${Math.max(hw * rwm, 2).toFixed(1)}px ${rh}px at ${lx}% 100%, rgba(${rgb},${a}), transparent)`
195
+ );
196
+ });
197
+ }
198
+ x += segW;
199
+ });
200
+ return layers.join(", ");
201
+ }
202
+
203
+ function clearWithAnimation() {
204
+ if (clearing || !input.value) return;
205
+ clearing = true;
206
+ const keepFocus = document.activeElement === input;
207
+ mirror.textContent = input.value.replace(/ /g, "\u00a0");
208
+
209
+ const total = num("--clear-dur", 1000);
210
+ const outDur = num("--clear-out-dur", 400);
211
+ const inDur = num("--clear-in-dur", 400);
212
+ const outFly = num("--clear-out-fly", 12);
213
+ const inFly = num("--clear-in-fly", 12);
214
+ const blur = num("--clear-blur", 2);
215
+ const delay = num("--glow-delay", 50);
216
+ const peakAt = num("--glow-peak-at", 0.15);
217
+ const gOp = num("--glow-opacity", 0.42);
218
+ const easeOut = bezier(getComputedStyle(root).getPropertyValue("--clear-out-ease"));
219
+ const easeIn = bezier(getComputedStyle(root).getPropertyValue("--clear-in-ease"));
220
+
221
+ input.value = "";
222
+ wrap.classList.remove("has-value");
223
+ wrap.classList.add("is-clearing");
224
+ glow.style.background = buildGlow(mirror.textContent);
225
+ glow.style.opacity = "0";
226
+ phold.style.transform = `translateY(-${inFly}px)`;
227
+ phold.style.opacity = "0.9";
228
+ phold.style.filter = `blur(${blur}px)`;
229
+
230
+ const t0 = performance.now();
231
+ (function tick(now) {
232
+ const el = now - t0;
233
+ const eo = easeOut(Math.min(1, el / outDur));
234
+ mirror.style.transform = `translateY(${(eo * outFly).toFixed(1)}px)`;
235
+ mirror.style.opacity = (1 - eo).toFixed(3);
236
+ mirror.style.filter = `blur(${(eo * blur).toFixed(1)}px)`;
237
+
238
+ const ei = easeIn(Math.min(1, el / inDur));
239
+ phold.style.transform = `translateY(${(-inFly + ei * inFly).toFixed(1)}px)`;
240
+ phold.style.opacity = (0.9 + ei * 0.1).toFixed(3);
241
+ phold.style.filter = `blur(${(blur - ei * blur).toFixed(1)}px)`;
242
+
243
+ let g = 0;
244
+ if (el > delay) {
245
+ const gp = Math.min(1, (el - delay) / Math.max(1, total - delay));
246
+ g = gp < peakAt ? gp / peakAt : 1 - (gp - peakAt) / (1 - peakAt);
247
+ }
248
+ glow.style.opacity = (g * gOp).toFixed(3);
249
+
250
+ if (el < total) {
251
+ requestAnimationFrame(tick);
252
+ } else {
253
+ wrap.classList.remove("is-clearing");
254
+ [mirror, phold].forEach((el) => (el.style.cssText = ""));
255
+ mirror.textContent = "";
256
+ glow.style.opacity = "0";
257
+ glow.style.background = "";
258
+ clearing = false;
259
+ if (keepFocus) requestAnimationFrame(() => input.focus({ preventScroll: true }));
260
+ }
261
+ })(performance.now());
262
+ }
263
+
264
+ const keep = (e) => { if (document.activeElement === input) e.preventDefault(); };
265
+ btn.addEventListener("pointerdown", keep);
266
+ btn.addEventListener("mousedown", keep);
267
+ btn.addEventListener("click", clearWithAnimation);
268
+ input.addEventListener("input", sync);
269
+ sync();
270
+ });
271
+ ```
272
+
273
+ ### Dark mode
274
+
275
+ 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.
276
+