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,170 @@
|
|
|
1
|
+
# Card hover tilt
|
|
2
|
+
|
|
3
|
+
## When to use
|
|
4
|
+
|
|
5
|
+
A card / tile / media surface that tilts in 3D toward the pointer while hovered, with a soft light "glare" tracking the cursor across it. Use for product cards, credit / membership cards, feature tiles, cover art — anything that should feel physical and reactive on hover. Pointer-only (skips touch) and flattens under reduced motion.
|
|
6
|
+
|
|
7
|
+
The pointer is tracked on an **outer flat wrapper** (`.t-tilt`) that never transforms, so the tilting card can't rotate its own edges out from under the cursor (which causes hover flicker). The inner `.t-tilt-card` is the element that actually rotates.
|
|
8
|
+
|
|
9
|
+
## HTML usage
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<div class="t-tilt"> <!-- flat hit area -->
|
|
13
|
+
<div class="t-tilt-card"> <!-- the element that tilts -->
|
|
14
|
+
… card content …
|
|
15
|
+
<div class="t-tilt-glare"></div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Track the pointer on the OUTER `.t-tilt` (it never
|
|
21
|
+
transforms) and write four custom properties from JS:
|
|
22
|
+
el.style.setProperty('--tilt-rx', rxDeg + 'deg');
|
|
23
|
+
el.style.setProperty('--tilt-ry', ryDeg + 'deg');
|
|
24
|
+
el.style.setProperty('--tilt-gx', gxPct + '%');
|
|
25
|
+
el.style.setProperty('--tilt-gy', gyPct + '%');
|
|
26
|
+
Add `.is-tilting` while moving (fast follow) and
|
|
27
|
+
`.is-hover` to fade the glare in; remove both on leave.
|
|
28
|
+
|
|
29
|
+
## Tunable variables
|
|
30
|
+
|
|
31
|
+
| Variable | Default | Notes |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `--tilt-perspective` | `1000px` | sourced from `--p19-perspective` |
|
|
34
|
+
| `--tilt-return` | `1000ms` | sourced from `--p19-return-dur` |
|
|
35
|
+
| `--tilt-return-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-return-ease` |
|
|
36
|
+
| `--tilt-follow` | `400ms` | sourced from `--p19-follow-dur` |
|
|
37
|
+
| `--tilt-follow-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-follow-ease` |
|
|
38
|
+
| `--tilt-glare-opacity` | `0.32` | sourced from `--p19-glare-opacity` |
|
|
39
|
+
| `--tilt-glare-fade` | `300ms` | sourced from `--p19-glare-fade` |
|
|
40
|
+
| `--tilt-glare-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p19-glare-ease` |
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
```css
|
|
45
|
+
:root {
|
|
46
|
+
--tilt-perspective: 1000px;
|
|
47
|
+
--tilt-return: 1000ms;
|
|
48
|
+
--tilt-return-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
49
|
+
--tilt-follow: 400ms;
|
|
50
|
+
--tilt-follow-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
51
|
+
--tilt-glare-opacity: 0.32;
|
|
52
|
+
--tilt-glare-fade: 300ms;
|
|
53
|
+
--tilt-glare-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## CSS
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
/* The outer wrapper is the flat hit area; touch-action:none
|
|
61
|
+
lets a finger drag tilt the card instead of scrolling the
|
|
62
|
+
page, so tap-hold-drag works on mobile. */
|
|
63
|
+
.t-tilt { touch-action: none; }
|
|
64
|
+
/* The card tilts toward the pointer via rotateX/rotateY fed
|
|
65
|
+
from JS; on leave it eases back to flat. A separate
|
|
66
|
+
.is-tilting class swaps in a short linear follow while the
|
|
67
|
+
pointer moves so the tilt tracks the cursor 1:1. */
|
|
68
|
+
.t-tilt-card {
|
|
69
|
+
position: relative;
|
|
70
|
+
border-radius: 12px;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
transform:
|
|
73
|
+
perspective(var(--tilt-perspective))
|
|
74
|
+
rotateX(var(--tilt-rx, 0deg))
|
|
75
|
+
rotateY(var(--tilt-ry, 0deg));
|
|
76
|
+
transform-style: preserve-3d;
|
|
77
|
+
transition: transform var(--tilt-return) var(--tilt-return-ease);
|
|
78
|
+
will-change: transform;
|
|
79
|
+
}
|
|
80
|
+
.t-tilt-card.is-tilting {
|
|
81
|
+
transition: transform var(--tilt-follow) var(--tilt-follow-ease);
|
|
82
|
+
}
|
|
83
|
+
/* Cursor-tracked glare: layered soft circles that add like
|
|
84
|
+
light (screen blend) at the pointer position. */
|
|
85
|
+
.t-tilt-glare {
|
|
86
|
+
position: absolute;
|
|
87
|
+
inset: 0;
|
|
88
|
+
pointer-events: none;
|
|
89
|
+
opacity: 0;
|
|
90
|
+
mix-blend-mode: screen;
|
|
91
|
+
background:
|
|
92
|
+
radial-gradient(circle 95px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
|
|
93
|
+
rgba(255,255,255,0.48), rgba(255,255,255,0.06) 52%, rgba(255,255,255,0) 84%),
|
|
94
|
+
radial-gradient(circle 200px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
|
|
95
|
+
rgba(255,255,255,0.22), rgba(255,255,255,0.04) 58%, rgba(255,255,255,0) 78%),
|
|
96
|
+
radial-gradient(circle 360px at var(--tilt-gx, 50%) var(--tilt-gy, 50%),
|
|
97
|
+
rgba(255,255,255,0.10), rgba(255,255,255,0) 88%);
|
|
98
|
+
transition: opacity var(--tilt-glare-fade) var(--tilt-glare-ease);
|
|
99
|
+
}
|
|
100
|
+
.t-tilt.is-hover .t-tilt-glare { opacity: var(--tilt-glare-opacity); }
|
|
101
|
+
|
|
102
|
+
@media (prefers-reduced-motion: reduce) {
|
|
103
|
+
.t-tilt-card { transform: none !important; transition: none !important; }
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
## JavaScript orchestration
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
// Track the pointer on the OUTER .t-tilt (never transforms) and write
|
|
113
|
+
// rotation + glare position onto the inner card. Works for mouse
|
|
114
|
+
// (hover) and touch / pen (tap-hold-drag) — a touch pointermove only
|
|
115
|
+
// fires while a finger is down, so the press naturally drives the tilt.
|
|
116
|
+
const tilt = document.querySelector(".t-tilt");
|
|
117
|
+
const card = tilt.querySelector(".t-tilt-card");
|
|
118
|
+
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
|
|
119
|
+
|
|
120
|
+
const MAX = 14; // peak tilt in degrees at the card edges (raise for a stronger lean)
|
|
121
|
+
|
|
122
|
+
function reset() {
|
|
123
|
+
tilt.classList.remove("is-hover");
|
|
124
|
+
card.classList.remove("is-tilting");
|
|
125
|
+
card.style.setProperty("--tilt-rx", "0deg");
|
|
126
|
+
card.style.setProperty("--tilt-ry", "0deg");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function track(e) {
|
|
130
|
+
if (reduce.matches) return;
|
|
131
|
+
const r = tilt.getBoundingClientRect();
|
|
132
|
+
const px = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
|
133
|
+
const py = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
|
|
134
|
+
tilt.classList.add("is-hover");
|
|
135
|
+
card.classList.add("is-tilting");
|
|
136
|
+
card.style.setProperty("--tilt-ry", ((px - 0.5) * MAX).toFixed(2) + "deg");
|
|
137
|
+
card.style.setProperty("--tilt-rx", ((0.5 - py) * MAX).toFixed(2) + "deg");
|
|
138
|
+
card.style.setProperty("--tilt-gx", (px * 100).toFixed(1) + "%");
|
|
139
|
+
card.style.setProperty("--tilt-gy", (py * 100).toFixed(1) + "%");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
tilt.addEventListener("pointerdown", (e) => {
|
|
143
|
+
// Touch / pen: capture so the drag keeps targeting the card even if
|
|
144
|
+
// the finger drifts past its edge. Pair with touch-action: none on
|
|
145
|
+
// .t-tilt so the drag tilts instead of scrolling the page.
|
|
146
|
+
if (e.pointerType !== "mouse") {
|
|
147
|
+
try { tilt.setPointerCapture(e.pointerId); } catch (_) {}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
tilt.addEventListener("pointermove", track);
|
|
151
|
+
tilt.addEventListener("pointerup", reset);
|
|
152
|
+
tilt.addEventListener("pointercancel", reset);
|
|
153
|
+
tilt.addEventListener("pointerleave", (e) => {
|
|
154
|
+
// Mouse: leaving the card flattens it. Touch already reset on up.
|
|
155
|
+
if (e.pointerType === "mouse") reset();
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Peak tilt angle
|
|
160
|
+
|
|
161
|
+
The rotation magnitude is a JS constant (`MAX`, in degrees), not a CSS variable — the orchestration writes `--tilt-rx` / `--tilt-ry` from the pointer position scaled by `MAX`. Raise it for a stronger lean (the live demo goes up to ~40°); 10–16° reads as a subtle, tasteful tilt.
|
|
162
|
+
|
|
163
|
+
### Why the pointer is tracked on the flat wrapper
|
|
164
|
+
|
|
165
|
+
Bind `pointermove` to the outer `.t-tilt` (which never transforms), not the `.t-tilt-card` that rotates. If you track the tilting element, its rotating edges slip out from under the cursor near the borders and the hover flickers on and off.
|
|
166
|
+
|
|
167
|
+
### Touch / mobile
|
|
168
|
+
|
|
169
|
+
Because it uses Pointer Events, the tilt also works on touch: tap-hold-drag on the card and it follows your finger (a touch `pointermove` only fires while pressed). Two pieces make this reliable — `touch-action: none` on `.t-tilt` so the drag tilts instead of scrolling the page, and `setPointerCapture` on `pointerdown` so the gesture keeps targeting the card even if the finger drifts past its edge.
|
|
170
|
+
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Plus to menu morph
|
|
2
|
+
|
|
3
|
+
## When to use
|
|
4
|
+
|
|
5
|
+
A small circular trigger (a "+" FAB, a compose button, an add-action affordance) that **morphs into the menu / panel it opens** instead of popping a separate surface next to it. The button's box grows in width / height and relaxes its corner radius into a rounded panel while the plus icon cross-fades + rotates out and the menu content slides in.
|
|
6
|
+
|
|
7
|
+
Reach for this over **menu dropdown** when the trigger and the surface are the *same* element (the button becomes the panel). Use plain **menu dropdown** when the surface is a distinct popover that merely grows from the trigger's corner.
|
|
8
|
+
|
|
9
|
+
## HTML usage
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<div class="t-morph" data-open="false">
|
|
13
|
+
<div class="t-morph-menu"> … menu items … </div>
|
|
14
|
+
<button class="t-morph-plus" aria-expanded="false">+</button>
|
|
15
|
+
</div>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Toggle `data-open` on the container (and `aria-expanded`
|
|
19
|
+
on the button). CSS animates the surface size + corner
|
|
20
|
+
radius and cross-fades the plus ↔ menu. Wrap the morph in
|
|
21
|
+
a relatively-positioned anchor sized to the OPEN footprint
|
|
22
|
+
if you want it to grow out of a fixed corner.
|
|
23
|
+
|
|
24
|
+
## Tunable variables
|
|
25
|
+
|
|
26
|
+
| Variable | Default | Notes |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `--morph-open-dur` | `350ms` | sourced from `--p20-open-dur` |
|
|
29
|
+
| `--morph-close-dur` | `250ms` | sourced from `--p20-close-dur` |
|
|
30
|
+
| `--morph-ease` | `cubic-bezier(0.34, 1.25, 0.64, 1)` | sourced from `--p20-ease` |
|
|
31
|
+
| `--morph-close-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p20-close-ease` |
|
|
32
|
+
| `--morph-r-closed` | `40px` | sourced from `--p20-r-closed` |
|
|
33
|
+
| `--morph-r-open` | `20px` | sourced from `--p20-r-open` |
|
|
34
|
+
| `--morph-fade-dur` | `200ms` | sourced from `--p20-fade-dur` |
|
|
35
|
+
| `--morph-slide` | `40px` | sourced from `--p20-slide-in-shift` |
|
|
36
|
+
| `--morph-rotate` | `45deg` | sourced from `--p20-rotate` |
|
|
37
|
+
| `--morph-scale` | `0.97` | sourced from `--p20-scale` |
|
|
38
|
+
| `--morph-blur` | `2px` | sourced from `--p20-blur` |
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
:root {
|
|
44
|
+
--morph-open-dur: 350ms;
|
|
45
|
+
--morph-close-dur: 250ms;
|
|
46
|
+
--morph-ease: cubic-bezier(0.34, 1.25, 0.64, 1);
|
|
47
|
+
--morph-close-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
48
|
+
--morph-r-closed: 40px;
|
|
49
|
+
--morph-r-open: 20px;
|
|
50
|
+
--morph-fade-dur: 200ms;
|
|
51
|
+
--morph-slide: 40px;
|
|
52
|
+
--morph-rotate: 45deg;
|
|
53
|
+
--morph-scale: 0.97;
|
|
54
|
+
--morph-blur: 2px;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## CSS
|
|
59
|
+
|
|
60
|
+
```css
|
|
61
|
+
/* Closed: a small circular button. Open: a rounded panel.
|
|
62
|
+
Width/height/border-radius animate; the open state uses a
|
|
63
|
+
bouncier ease than the close. */
|
|
64
|
+
.t-morph {
|
|
65
|
+
position: relative;
|
|
66
|
+
width: 40px;
|
|
67
|
+
height: 40px;
|
|
68
|
+
border-radius: var(--morph-r-closed);
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
transition:
|
|
71
|
+
width var(--morph-close-dur) var(--morph-close-ease),
|
|
72
|
+
height var(--morph-close-dur) var(--morph-close-ease),
|
|
73
|
+
border-radius var(--morph-close-dur) var(--morph-close-ease);
|
|
74
|
+
}
|
|
75
|
+
.t-morph[data-open="true"] {
|
|
76
|
+
width: 183px;
|
|
77
|
+
height: 172px;
|
|
78
|
+
border-radius: var(--morph-r-open);
|
|
79
|
+
transition:
|
|
80
|
+
width var(--morph-open-dur) var(--morph-ease),
|
|
81
|
+
height var(--morph-open-dur) var(--morph-ease),
|
|
82
|
+
border-radius var(--morph-open-dur) var(--morph-ease);
|
|
83
|
+
}
|
|
84
|
+
/* Plus fades + slides out and the icon rotates into an ×. */
|
|
85
|
+
.t-morph-plus {
|
|
86
|
+
position: absolute;
|
|
87
|
+
inset: auto 0 0 auto;
|
|
88
|
+
width: 40px; height: 40px;
|
|
89
|
+
display: grid; place-items: center;
|
|
90
|
+
border: 0; background: transparent; cursor: pointer;
|
|
91
|
+
transition:
|
|
92
|
+
opacity var(--morph-fade-dur) var(--morph-close-ease),
|
|
93
|
+
transform var(--morph-open-dur) var(--morph-close-ease),
|
|
94
|
+
filter var(--morph-fade-dur) var(--morph-close-ease);
|
|
95
|
+
}
|
|
96
|
+
.t-morph-plus svg {
|
|
97
|
+
transition: transform var(--morph-open-dur) var(--morph-close-ease);
|
|
98
|
+
}
|
|
99
|
+
.t-morph[data-open="true"] .t-morph-plus {
|
|
100
|
+
opacity: 0;
|
|
101
|
+
transform: translateX(calc(-1 * var(--morph-slide)));
|
|
102
|
+
filter: blur(var(--morph-blur));
|
|
103
|
+
pointer-events: none;
|
|
104
|
+
}
|
|
105
|
+
.t-morph[data-open="true"] .t-morph-plus svg {
|
|
106
|
+
transform: scale(var(--morph-scale)) rotate(var(--morph-rotate));
|
|
107
|
+
}
|
|
108
|
+
/* Menu starts slid in + scaled + blurred; reveals on open. */
|
|
109
|
+
.t-morph-menu {
|
|
110
|
+
position: absolute;
|
|
111
|
+
inset: 0;
|
|
112
|
+
opacity: 0;
|
|
113
|
+
transform: translateX(var(--morph-slide)) scale(var(--morph-scale));
|
|
114
|
+
filter: blur(var(--morph-blur));
|
|
115
|
+
pointer-events: none;
|
|
116
|
+
transition:
|
|
117
|
+
opacity var(--morph-fade-dur) var(--morph-close-ease),
|
|
118
|
+
transform var(--morph-open-dur) var(--morph-close-ease),
|
|
119
|
+
filter var(--morph-fade-dur) var(--morph-close-ease);
|
|
120
|
+
}
|
|
121
|
+
.t-morph[data-open="true"] .t-morph-menu {
|
|
122
|
+
opacity: 1;
|
|
123
|
+
transform: translateX(0) scale(1);
|
|
124
|
+
filter: blur(0);
|
|
125
|
+
pointer-events: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@media (prefers-reduced-motion: reduce) {
|
|
129
|
+
.t-morph, .t-morph-plus, .t-morph-menu { transition: none !important; }
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
## JavaScript orchestration
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
// Toggle data-open on the container; CSS owns the morph. Mirror the
|
|
139
|
+
// state to aria-expanded and close on outside click / Escape.
|
|
140
|
+
const morph = document.querySelector(".t-morph");
|
|
141
|
+
const plus = morph.querySelector(".t-morph-plus");
|
|
142
|
+
|
|
143
|
+
function setOpen(open) {
|
|
144
|
+
morph.setAttribute("data-open", String(open));
|
|
145
|
+
plus.setAttribute("aria-expanded", String(open));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
plus.addEventListener("click", (e) => {
|
|
149
|
+
e.stopPropagation();
|
|
150
|
+
setOpen(morph.getAttribute("data-open") !== "true");
|
|
151
|
+
});
|
|
152
|
+
document.addEventListener("click", (e) => {
|
|
153
|
+
if (!morph.contains(e.target)) setOpen(false);
|
|
154
|
+
});
|
|
155
|
+
document.addEventListener("keydown", (e) => {
|
|
156
|
+
if (e.key === "Escape") setOpen(false);
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Pin the plus button to a corner
|
|
161
|
+
|
|
162
|
+
The plus button must overlay the panel, pinned to a corner (`inset: auto 0 0 auto`), so it stays put while the box grows up-and-left out of it. If it's in normal flow it gets shoved around as the container resizes. `overflow: hidden` on `.t-morph` is load-bearing — it clips the menu content during the size morph so items don't spill outside the growing rounded box.
|
|
163
|
+
|
|
164
|
+
### Open and close use different eases
|
|
165
|
+
|
|
166
|
+
The bouncy `--morph-ease` only drives the open; the close falls back to the calm `--morph-close-ease`. Don't collapse them into one variable. Adjust the open `width` / `height` in the snippet to your real panel size — they're hardcoded, not derived from the content.
|
|
167
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Accordion expand
|
|
2
|
+
|
|
3
|
+
## When to use
|
|
4
|
+
|
|
5
|
+
A disclosure / accordion / collapsible section whose panel grows and shrinks in height when toggled, with the header chevron flipping between a downward "v" and an upward "^". Use for settings groups, FAQs, filter sections, "show more" details — any header + collapsible body.
|
|
6
|
+
|
|
7
|
+
Height animates via `grid-template-rows: 0fr ↔ 1fr`, so there's **no JS height measuring** and content of any size animates cleanly. The chevron flips vertically (`scaleY`) from a "v" to a "^", passing through a flat line at the midpoint.
|
|
8
|
+
|
|
9
|
+
## HTML usage
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<div class="t-acc" data-open="false">
|
|
13
|
+
<button class="t-acc-head" aria-expanded="false">
|
|
14
|
+
Title
|
|
15
|
+
<span class="t-acc-chevron">
|
|
16
|
+
<svg viewBox="0 0 16 16"><path d="M4 6.5L8 10.5L12 6.5"/></svg>
|
|
17
|
+
</span>
|
|
18
|
+
</button>
|
|
19
|
+
<div class="t-acc-panel"><div class="t-acc-panel-inner"> … </div></div>
|
|
20
|
+
</div>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Toggle `data-open` on the item. The panel animates via
|
|
24
|
+
grid-template-rows 0fr ↔ 1fr (no JS height measuring) and
|
|
25
|
+
the chevron flips vertically (scaleY) from a "v" to a "^".
|
|
26
|
+
|
|
27
|
+
## Tunable variables
|
|
28
|
+
|
|
29
|
+
| Variable | Default | Notes |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `--acc-expand` | `250ms` | sourced from `--p21-expand-dur` |
|
|
32
|
+
| `--acc-collapse` | `250ms` | sourced from `--p21-collapse-dur` |
|
|
33
|
+
| `--acc-chevron` | `250ms` | sourced from `--p21-chevron-dur` |
|
|
34
|
+
| `--acc-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | sourced from `--p21-ease` |
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
:root {
|
|
40
|
+
--acc-expand: 250ms;
|
|
41
|
+
--acc-collapse: 250ms;
|
|
42
|
+
--acc-chevron: 250ms;
|
|
43
|
+
--acc-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## CSS
|
|
48
|
+
|
|
49
|
+
```css
|
|
50
|
+
/* grid-template-rows 0fr → 1fr gives a clean height animation
|
|
51
|
+
with no JS measurement; the inner element clips overflow. */
|
|
52
|
+
.t-acc-panel {
|
|
53
|
+
display: grid;
|
|
54
|
+
grid-template-rows: 0fr;
|
|
55
|
+
transition: grid-template-rows var(--acc-collapse) var(--acc-ease);
|
|
56
|
+
}
|
|
57
|
+
.t-acc[data-open="true"] .t-acc-panel {
|
|
58
|
+
grid-template-rows: 1fr;
|
|
59
|
+
transition: grid-template-rows var(--acc-expand) var(--acc-ease);
|
|
60
|
+
}
|
|
61
|
+
.t-acc-panel-inner {
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
opacity: 0;
|
|
64
|
+
filter: blur(2px);
|
|
65
|
+
transition:
|
|
66
|
+
opacity var(--acc-collapse) var(--acc-ease),
|
|
67
|
+
filter var(--acc-collapse) var(--acc-ease);
|
|
68
|
+
}
|
|
69
|
+
.t-acc[data-open="true"] .t-acc-panel-inner {
|
|
70
|
+
opacity: 1;
|
|
71
|
+
filter: blur(0);
|
|
72
|
+
transition:
|
|
73
|
+
opacity var(--acc-expand) var(--acc-ease),
|
|
74
|
+
filter var(--acc-expand) var(--acc-ease);
|
|
75
|
+
}
|
|
76
|
+
/* Flip the chevron vertically to turn the "v" into a "^".
|
|
77
|
+
scaleY(-1) about the centre passes through a flat line at
|
|
78
|
+
the midpoint (same look as a `d:` path morph) but animates
|
|
79
|
+
in every browser, unlike CSS `d:` morphing (Chromium only).
|
|
80
|
+
The chevron path is symmetric about the 16x16 viewBox
|
|
81
|
+
centre, so the flip lands exactly on the "^"; non-scaling
|
|
82
|
+
-stroke keeps the stroke width constant through the flip. */
|
|
83
|
+
.t-acc-chevron {
|
|
84
|
+
display: inline-flex;
|
|
85
|
+
transform: scaleY(1);
|
|
86
|
+
transform-origin: center;
|
|
87
|
+
transition: transform var(--acc-chevron) var(--acc-ease);
|
|
88
|
+
}
|
|
89
|
+
.t-acc-chevron path { vector-effect: non-scaling-stroke; }
|
|
90
|
+
.t-acc[data-open="true"] .t-acc-chevron {
|
|
91
|
+
transform: scaleY(-1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
.t-acc-panel, .t-acc-panel-inner, .t-acc-chevron {
|
|
96
|
+
transition: none !important;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
## JavaScript orchestration
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
// Toggle data-open on the item; CSS owns the height + chevron morph.
|
|
107
|
+
const acc = document.querySelector(".t-acc");
|
|
108
|
+
const head = acc.querySelector(".t-acc-head");
|
|
109
|
+
|
|
110
|
+
head.addEventListener("click", () => {
|
|
111
|
+
const open = acc.getAttribute("data-open") === "true";
|
|
112
|
+
acc.setAttribute("data-open", String(!open));
|
|
113
|
+
head.setAttribute("aria-expanded", String(!open));
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Two-element panel + padding placement
|
|
118
|
+
|
|
119
|
+
The panel needs the two-element structure (`.t-acc-panel` grid track + `.t-acc-panel-inner` with `overflow: hidden`). The `0fr → 1fr` track can only collapse a child that clips its own overflow. Keep padding on `.t-acc-panel-inner`, never on `.t-acc-panel` — padding on the `0fr` track leaves a residual height strip so the panel never fully closes.
|
|
120
|
+
|
|
121
|
+
### Why the chevron flips instead of morphing its path
|
|
122
|
+
|
|
123
|
+
The natural way to turn the "v" into a "^" is to morph the chevron's SVG `d` between two vertex sets — but CSS `d:` path interpolation is **Chromium-only**, so on mobile Safari and Firefox it snaps (or doesn't move at all). A vertical flip (`transform: scaleY(-1)`) reproduces the same motion — it passes through a flat horizontal line at the midpoint, exactly like the path morph — and animates in every browser. Two requirements make it land cleanly: the chevron path must be **symmetric about the centre of its viewBox** (so the flip maps the "v" onto the "^"), and the path needs `vector-effect: non-scaling-stroke` so the stroke width stays constant while the box is squashed mid-flip.
|
|
124
|
+
|