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.
- package/.agents/skills/refine-live/SKILL.md +205 -0
- package/.agents/skills/transitions-dev/01-card-resize.md +53 -0
- package/.agents/skills/transitions-dev/02-number-pop-in.md +119 -0
- package/.agents/skills/transitions-dev/03-notification-badge.md +110 -0
- package/.agents/skills/transitions-dev/04-text-states-swap.md +97 -0
- package/.agents/skills/transitions-dev/05-menu-dropdown.md +105 -0
- package/.agents/skills/transitions-dev/06-modal.md +94 -0
- package/.agents/skills/transitions-dev/07-panel-reveal.md +81 -0
- package/.agents/skills/transitions-dev/08-page-side-by-side.md +100 -0
- package/.agents/skills/transitions-dev/09-icon-swap.md +78 -0
- package/.agents/skills/transitions-dev/10-success-check.md +169 -0
- package/.agents/skills/transitions-dev/11-avatar-group-hover.md +200 -0
- package/.agents/skills/transitions-dev/12-error-state-shake.md +202 -0
- package/.agents/skills/transitions-dev/13-input-clear-dissolve.md +276 -0
- package/.agents/skills/transitions-dev/14-skeleton-reveal.md +149 -0
- package/.agents/skills/transitions-dev/15-shimmer-text.md +95 -0
- package/.agents/skills/transitions-dev/16-tabs-sliding.md +146 -0
- package/.agents/skills/transitions-dev/17-tooltip.md +103 -0
- package/.agents/skills/transitions-dev/18-texts-reveal.md +110 -0
- package/.agents/skills/transitions-dev/19-card-tilt.md +170 -0
- package/.agents/skills/transitions-dev/20-plus-menu-morph.md +167 -0
- package/.agents/skills/transitions-dev/21-accordion.md +124 -0
- package/.agents/skills/transitions-dev/SKILL.md +225 -0
- package/.agents/skills/transitions-dev/_root.css +204 -0
- package/README.md +89 -0
- package/bin/cli.mjs +264 -0
- package/demo.html +2531 -0
- package/package.json +37 -0
- package/server/inject.mjs +116 -0
- package/server/motion-tokens.mjs +106 -0
- package/server/refine-agent.mjs +86 -0
- 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
|
+
|