concertina 0.9.0 → 0.10.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/README.md +282 -218
- package/dist/accordion.cjs +1227 -0
- package/dist/accordion.d.cts +70 -0
- package/dist/accordion.d.ts +70 -0
- package/dist/accordion.js +22 -0
- package/dist/chunk-XPZ74VND.js +1197 -0
- package/dist/index.d.cts +7 -71
- package/dist/index.d.ts +7 -71
- package/dist/index.js +69 -1241
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
<a href="https://github.com/ryandward/concertina/actions/workflows/ci.yml"><img src="https://github.com/ryandward/concertina/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
-
<p align="center"><b>
|
|
15
|
+
<p align="center"><b>66 tests</b> · ~900 lines of source · 1 dependency</p>
|
|
16
16
|
|
|
17
17
|
## The problem
|
|
18
18
|
|
|
19
|
-
Layout shift happens when the browser changes the size of a box and moves everything else to compensate. A button swaps for a stepper — the text next to it reflows. A spinner becomes a table — the page jumps
|
|
19
|
+
Layout shift happens when the browser changes the size of a box and moves everything else to compensate. A button swaps for a stepper — the text next to it reflows. A spinner becomes a table — the page jumps. An accordion opens — the thing you clicked scrolls off the screen.
|
|
20
20
|
|
|
21
|
-
The React ecosystem treats this as a
|
|
21
|
+
The React ecosystem treats this as a state problem. Suspense, skeleton libraries, loading spinners — they model the transition between pending and loaded. They give you a nice-looking placeholder that's a completely different DOM structure from the real content, then act surprised when the swap causes a jump.
|
|
22
22
|
|
|
23
23
|
It's not a state problem. It's a **structure problem.** The box changed size because you swapped the structure inside it.
|
|
24
24
|
|
|
@@ -26,15 +26,13 @@ It's not a state problem. It's a **structure problem.** The box changed size bec
|
|
|
26
26
|
|
|
27
27
|
Don't swap structures. Swap what's inside them.
|
|
28
28
|
|
|
29
|
-
Every tool in concertina
|
|
29
|
+
Every tool in concertina addresses one of three kinds of instability:
|
|
30
30
|
|
|
31
|
-
|
|
|
32
|
-
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
| A panel mounting/unmounting | Glide | Animated enter/exit instead of instant DOM swap |
|
|
37
|
-
| Which accordion item is open | Root + Item + Content | Scroll pinning keeps the opened item visible |
|
|
31
|
+
| Kind | What went wrong | Tools |
|
|
32
|
+
|------|-----------------|-------|
|
|
33
|
+
| **Spatial** | The box changed size | StableSlot, Gigbag, useStableSlot |
|
|
34
|
+
| **Temporal** | The content loaded or animated | WarmupLine, Warmup, Glide, useWarmupExit, usePresence |
|
|
35
|
+
| **Positional** | The viewport scrolled unexpectedly | Accordion, pinToScrollTop, useScrollPin |
|
|
38
36
|
|
|
39
37
|
Structure is the contract. Content is what varies. If you internalize that, the API is obvious. If you don't, no amount of tooling will save you.
|
|
40
38
|
|
|
@@ -45,27 +43,41 @@ npm install concertina
|
|
|
45
43
|
```
|
|
46
44
|
|
|
47
45
|
```tsx
|
|
48
|
-
|
|
46
|
+
// Stability primitives
|
|
47
|
+
import { StableSlot, Slot, Gigbag, WarmupLine, Glide, useWarmupExit } from "concertina";
|
|
48
|
+
|
|
49
|
+
// Accordion (Radix integration) — separate namespace
|
|
50
|
+
import * as Accordion from "concertina/accordion";
|
|
51
|
+
|
|
52
|
+
// Styles
|
|
49
53
|
import "concertina/styles.css";
|
|
50
54
|
```
|
|
51
55
|
|
|
56
|
+
The main entry exports all stability primitives. The `concertina/accordion` sub-path exports the Radix Accordion wrappers (Root, Item, Content, Header, Trigger, useExpanded) so they live in their own namespace. Both entry points are also available from the main `"concertina"` import for backward compatibility.
|
|
57
|
+
|
|
52
58
|
---
|
|
53
59
|
|
|
54
|
-
##
|
|
60
|
+
## Spatial stability
|
|
61
|
+
|
|
62
|
+
The box changed size. Something swapped, grew, or shrank and everything around it moved.
|
|
63
|
+
|
|
64
|
+
### StableSlot + Slot
|
|
55
65
|
|
|
56
66
|
Two components swap in one slot. An "Add" button becomes a quantity stepper. The stepper is wider. The text next to it jumps left.
|
|
57
67
|
|
|
58
68
|
The fix: don't swap them. Render both at the same time, in the same grid cell, stacked. The cell sizes to the bigger one. Toggle which one is visible. The box never changes size because both variants are always in there.
|
|
59
69
|
|
|
60
70
|
```tsx
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
import { StableSlot, Slot } from "concertina";
|
|
72
|
+
|
|
73
|
+
<StableSlot axis="width" className="action-slot">
|
|
74
|
+
<Slot active={!isInCart}>
|
|
63
75
|
<AddButton />
|
|
64
|
-
</
|
|
65
|
-
<
|
|
76
|
+
</Slot>
|
|
77
|
+
<Slot active={isInCart}>
|
|
66
78
|
<QuantityControl />
|
|
67
|
-
</
|
|
68
|
-
</
|
|
79
|
+
</Slot>
|
|
80
|
+
</StableSlot>
|
|
69
81
|
```
|
|
70
82
|
|
|
71
83
|
How it works:
|
|
@@ -75,7 +87,7 @@ How it works:
|
|
|
75
87
|
- Each Slot uses `display: flex; flex-direction: column` so content stretches to fill the reserved width.
|
|
76
88
|
- Zero JS measurement. Pure CSS. Works on the first frame.
|
|
77
89
|
|
|
78
|
-
|
|
90
|
+
#### StableSlot props
|
|
79
91
|
|
|
80
92
|
| Prop | Type | Default | Description |
|
|
81
93
|
|------|------|---------|-------------|
|
|
@@ -83,7 +95,7 @@ How it works:
|
|
|
83
95
|
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
84
96
|
| `className` | `string` | | Passed to wrapper |
|
|
85
97
|
|
|
86
|
-
|
|
98
|
+
#### Slot props
|
|
87
99
|
|
|
88
100
|
| Prop | Type | Description |
|
|
89
101
|
|------|------|-------------|
|
|
@@ -92,9 +104,9 @@ How it works:
|
|
|
92
104
|
|
|
93
105
|
All other HTML attributes are forwarded on both components.
|
|
94
106
|
|
|
95
|
-
|
|
107
|
+
#### Rules for correct behavior
|
|
96
108
|
|
|
97
|
-
Parent containers must allow content-based sizing. A StableSlot inside `grid-template-columns: 1fr 10rem` is trapped — the fixed column clips it
|
|
109
|
+
Parent containers must allow content-based sizing. A StableSlot inside `grid-template-columns: 1fr 10rem` is trapped — the fixed column clips it. Use `auto`:
|
|
98
110
|
|
|
99
111
|
```css
|
|
100
112
|
/* StableSlot can't do its job in here */
|
|
@@ -104,27 +116,25 @@ grid-template-columns: 1fr 10rem;
|
|
|
104
116
|
grid-template-columns: 1fr auto;
|
|
105
117
|
```
|
|
106
118
|
|
|
107
|
-
Every independently appearing element needs its own StableSlot. An Undo link that only shows up in one state gets its own wrapper
|
|
119
|
+
Every independently appearing element needs its own StableSlot. An Undo link that only shows up in one state gets its own wrapper:
|
|
108
120
|
|
|
109
121
|
```tsx
|
|
110
122
|
<div className="action-column">
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
<
|
|
114
|
-
</
|
|
115
|
-
<
|
|
116
|
-
<
|
|
123
|
+
<StableSlot axis="width">
|
|
124
|
+
<Slot active={showDeliver}><Button>Deliver</Button></Slot>
|
|
125
|
+
<Slot active={showCharge}><Button>Charge</Button></Slot>
|
|
126
|
+
</StableSlot>
|
|
127
|
+
<StableSlot>
|
|
128
|
+
<Slot active={showCharge}>
|
|
117
129
|
<button className="undo-link">Undo</button>
|
|
118
|
-
</
|
|
119
|
-
</
|
|
130
|
+
</Slot>
|
|
131
|
+
</StableSlot>
|
|
120
132
|
</div>
|
|
121
133
|
```
|
|
122
134
|
|
|
123
135
|
A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
## Gigbag + Warmup
|
|
137
|
+
### Gigbag
|
|
128
138
|
|
|
129
139
|
```jsx
|
|
130
140
|
if (loading) return <Spinner />; // 48px
|
|
@@ -136,49 +146,52 @@ Three different structures, three different heights. Every transition jumps.
|
|
|
136
146
|
|
|
137
147
|
Gigbag is a container that remembers its largest-ever size via ResizeObserver and will not shrink. Put a spinner in there, then a table, then a spinner again — it stays at the table's height the whole time. Like a guitar case. You don't reshape the case every time you take the guitar out. It also uses `contain: layout style` so internal reflows don't bother the ancestors.
|
|
138
148
|
|
|
139
|
-
Warmup is a CSS-only shimmer grid that goes inside the Gigbag while you're loading. Instead of a spinner that tells the browser nothing about what's coming, the Warmup approximates the content's shape. The browser knows how tall things will be because you told it.
|
|
140
|
-
|
|
141
149
|
```tsx
|
|
142
|
-
|
|
150
|
+
import { Gigbag, Warmup } from "concertina";
|
|
151
|
+
|
|
152
|
+
<Gigbag axis="height">
|
|
143
153
|
{loading ? (
|
|
144
|
-
<
|
|
154
|
+
<Warmup rows={8} columns={3} />
|
|
145
155
|
) : (
|
|
146
156
|
<DataTable data={data} />
|
|
147
157
|
)}
|
|
148
|
-
</
|
|
158
|
+
</Gigbag>
|
|
149
159
|
```
|
|
150
160
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
### Gigbag props
|
|
161
|
+
#### Gigbag props
|
|
154
162
|
|
|
155
163
|
| Prop | Type | Default | Description |
|
|
156
164
|
|------|------|---------|-------------|
|
|
157
165
|
| `axis` | `"width"` \| `"height"` \| `"both"` | `"height"` | Which axis to ratchet |
|
|
158
166
|
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
159
167
|
|
|
160
|
-
###
|
|
168
|
+
### useStableSlot
|
|
161
169
|
|
|
162
|
-
|
|
163
|
-
|------|------|---------|-------------|
|
|
164
|
-
| `rows` | `number` | `3` | Number of placeholder rows |
|
|
165
|
-
| `columns` | `number` | `1` | Columns per row |
|
|
166
|
-
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
170
|
+
For content that changes size unpredictably (prices, names, status messages) where you can't enumerate all variants upfront. This is what Gigbag uses internally. Use it directly when you want a ref-based API instead of a wrapper component.
|
|
167
171
|
|
|
168
|
-
|
|
172
|
+
```tsx
|
|
173
|
+
import { useStableSlot } from "concertina";
|
|
169
174
|
|
|
170
|
-
|
|
175
|
+
const slot = useStableSlot({ axis: "width" });
|
|
171
176
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
--concertina-warmup-bone-height: 2.5rem;
|
|
176
|
-
--concertina-warmup-bone-radius: 0.25rem;
|
|
177
|
-
--concertina-warmup-bone-color: #e5e7eb;
|
|
178
|
-
}
|
|
177
|
+
<div ref={slot.ref} style={slot.style} className="price-amount">
|
|
178
|
+
{formattedPrice}
|
|
179
|
+
</div>
|
|
179
180
|
```
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
| Option | Type | Default | Description |
|
|
183
|
+
|--------|------|---------|-------------|
|
|
184
|
+
| `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to ratchet |
|
|
185
|
+
|
|
186
|
+
Returns `{ ref, style }`. Attach both to the container element.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Temporal stability
|
|
191
|
+
|
|
192
|
+
The content loaded or animated. Something appeared, disappeared, or transitioned between states and the layout jumped during the change.
|
|
193
|
+
|
|
194
|
+
### WarmupLine — shimmer that inherits text metrics
|
|
182
195
|
|
|
183
196
|
This is the single most important thing in the library and the easiest to get wrong.
|
|
184
197
|
|
|
@@ -191,9 +204,11 @@ But `1lh` only works if the shimmer has the right styles. A bare `<div className
|
|
|
191
204
|
**The `WarmupLine` component exists so you can pass the text styles explicitly:**
|
|
192
205
|
|
|
193
206
|
```tsx
|
|
207
|
+
import { WarmupLine } from "concertina";
|
|
208
|
+
|
|
194
209
|
// Toolbar — no parent provides text styles, so pass them directly
|
|
195
210
|
{loading
|
|
196
|
-
? <
|
|
211
|
+
? <WarmupLine className="text-sm text-stone flex-1" />
|
|
197
212
|
: <span className="text-sm text-stone">{count} customers</span>
|
|
198
213
|
}
|
|
199
214
|
|
|
@@ -208,27 +223,23 @@ But `1lh` only works if the shimmer has the right styles. A bare `<div className
|
|
|
208
223
|
|
|
209
224
|
In grid cells, the shimmer inherits from its wrapper (wrapper-once pattern). In standalone contexts like toolbars, pass the same `className` you'd put on the text element. The shimmer's `1lh` resolves against those styles and matches the text exactly.
|
|
210
225
|
|
|
211
|
-
**Width** comes from the container, not the shimmer. In a grid cell, the column definition provides width
|
|
212
|
-
|
|
213
|
-
```css
|
|
214
|
-
/* The shimmer stretches to fill whatever its container provides */
|
|
215
|
-
grid-template-columns: minmax(10rem, 2fr) minmax(3rem, auto) minmax(4.5rem, auto) auto;
|
|
216
|
-
/* name column qty column total column action (StableSlot) */
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
One thing knows the expected content width: the grid column definition. Zero things should guess it.
|
|
226
|
+
**Width** comes from the container, not the shimmer. In a grid cell, the column definition provides width. In a flex toolbar, pass `flex-1` so the shimmer fills the available space. The shimmer never invents a width — it fills whatever its context provides.
|
|
220
227
|
|
|
221
|
-
|
|
228
|
+
> **vs. GitHub Primer's `SkeletonText`**
|
|
229
|
+
>
|
|
230
|
+
> Primer uses `height: var(--font-size)` plus ~70 lines of manual leading math via CSS custom properties. It requires a `size` prop mapped to design tokens (`'bodyMedium'`, `'titleLarge'`). Each token is a hand-maintained record of font-size, line-height, and letter-spacing for that tier.
|
|
231
|
+
>
|
|
232
|
+
> Concertina uses `height: 1lh` — one CSS declaration. `lh` is a relative unit that resolves to the element's computed line-height. Pass text styles via `className` and the shimmer inherits the correct metrics. No token mapping, no manual math, no `size` prop to keep in sync.
|
|
233
|
+
>
|
|
234
|
+
> **Trade-off:** `lh` requires modern browsers (Chrome 109+, Firefox 120+, Safari 16.4+). Primer supports older browsers. If you need IE or pre-2023 Safari, Primer's approach is the right one. If your audience is on modern browsers, `1lh` eliminates an entire category of maintenance.
|
|
222
235
|
|
|
223
|
-
|
|
236
|
+
### The stub-data pattern
|
|
224
237
|
|
|
225
|
-
Gigbag + Warmup works for flat containers. But when your content renders through structured components — an accordion with `Root > Item > Trigger > Content`, or a data table with cell wrappers — a separate loading skeleton is a different DOM structure. Different wrappers, different padding, different height. The swap from skeleton to real content shifts layout.
|
|
238
|
+
Gigbag + Warmup works for flat containers. But when your content renders through structured components — an accordion with `Root > Item > Trigger > Content`, or a data table with cell wrappers — a separate loading skeleton is a different DOM structure. Different wrappers, different padding, different height. The swap from skeleton to real content shifts layout.
|
|
226
239
|
|
|
227
240
|
This is where the core principle applies directly. Don't build a separate loading path. **Pass placeholder data through the same render path as real data.**
|
|
228
241
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
Create stub objects with the same shape as your real data, marked with a `_warmup` flag. Pass them to the same component that renders real data. Each cell renders shimmer or content inside the same wrapper — one wrapper definition, ternary on the guts:
|
|
242
|
+
Create stub objects with the same shape as your real data, marked with a `_warmup` flag. Pass them to the same component that renders real data. Each cell renders shimmer or content inside the same wrapper:
|
|
232
243
|
|
|
233
244
|
```tsx
|
|
234
245
|
// Stub data — same shape as real rows
|
|
@@ -248,31 +259,11 @@ cell: ({ row }) => (
|
|
|
248
259
|
}
|
|
249
260
|
</span>
|
|
250
261
|
)
|
|
251
|
-
|
|
252
|
-
// Table component — no separate loading branch
|
|
253
|
-
function MyTable({ data, loading }) {
|
|
254
|
-
return (
|
|
255
|
-
<Concertina.Root>
|
|
256
|
-
{(loading ? STUB_ROWS : data).map((row) => (
|
|
257
|
-
<Concertina.Item key={row.id} value={row.id}>
|
|
258
|
-
<Concertina.Trigger>
|
|
259
|
-
{/* cells render shimmer or content in the same wrappers */}
|
|
260
|
-
</Concertina.Trigger>
|
|
261
|
-
<Concertina.Content>
|
|
262
|
-
{row._warmup ? null : <DetailPanel row={row} />}
|
|
263
|
-
</Concertina.Content>
|
|
264
|
-
</Concertina.Item>
|
|
265
|
-
))}
|
|
266
|
-
</Concertina.Root>
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
262
|
```
|
|
270
263
|
|
|
271
|
-
|
|
264
|
+
#### The wrapper-once rule
|
|
272
265
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
This is the part that matters. The wrapper is the structural contract — it determines padding, font-size, line-height, and therefore the cell's height. Define it once. Put the ternary inside it. Never write the wrapper in two branches.
|
|
266
|
+
The wrapper is the structural contract — it determines padding, font-size, line-height, and therefore the cell's height. Define it once. Put the ternary inside it. Never write the wrapper in two branches.
|
|
276
267
|
|
|
277
268
|
```tsx
|
|
278
269
|
// WRONG — wrapper duplicated, will drift apart silently
|
|
@@ -290,46 +281,16 @@ return <span className="table-val-money">${total}</span>;
|
|
|
290
281
|
</span>
|
|
291
282
|
```
|
|
292
283
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
One wrapper. One definition. Ternary on the guts. That's the whole rule.
|
|
296
|
-
|
|
297
|
-
### Action columns with StableSlot
|
|
298
|
-
|
|
299
|
-
For columns with interactive controls, pass `null` as the entity during warmup. The StableSlot still renders all variants in `visibility: hidden`, reserving the exact same space:
|
|
300
|
-
|
|
301
|
-
```tsx
|
|
302
|
-
<ActionCell entity={row._warmup ? null : row} />
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
### What TypeScript enforces (and what it doesn't)
|
|
284
|
+
#### TypeScript enforcement
|
|
306
285
|
|
|
307
286
|
A discriminated union guarantees you check `_warmup` before accessing real data:
|
|
308
287
|
|
|
309
288
|
```ts
|
|
310
|
-
type WarmupRow = {
|
|
311
|
-
|
|
312
|
-
id: string;
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
type RealRow = {
|
|
316
|
-
_warmup?: never;
|
|
317
|
-
id: string;
|
|
318
|
-
name: string;
|
|
319
|
-
items: Item[];
|
|
320
|
-
};
|
|
321
|
-
|
|
289
|
+
type WarmupRow = { _warmup: true; id: string };
|
|
290
|
+
type RealRow = { _warmup?: never; id: string; name: string; items: Item[] };
|
|
322
291
|
type Row = WarmupRow | RealRow;
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
The compiler forces the branch:
|
|
326
292
|
|
|
327
|
-
```ts
|
|
328
293
|
function renderCell(row: Row) {
|
|
329
|
-
// TS error: 'name' doesn't exist on WarmupRow
|
|
330
|
-
return <span className="table-val-primary">{row.name}</span>;
|
|
331
|
-
|
|
332
|
-
// compiles — wrapper once, ternary inside
|
|
333
294
|
return (
|
|
334
295
|
<span className="table-val-primary">
|
|
335
296
|
{row._warmup
|
|
@@ -341,42 +302,52 @@ function renderCell(row: Row) {
|
|
|
341
302
|
}
|
|
342
303
|
```
|
|
343
304
|
|
|
344
|
-
|
|
305
|
+
TypeScript prevents you from forgetting the branch. The wrapper-once pattern prevents you from forgetting the wrapper. Use both.
|
|
345
306
|
|
|
346
|
-
|
|
307
|
+
### useWarmupExit
|
|
347
308
|
|
|
348
|
-
|
|
349
|
-
// TS is happy. Layout shifts anyway. Don't do this.
|
|
350
|
-
if (row._warmup) return <div className="concertina-warmup-line" />;
|
|
351
|
-
return <span className="table-val-primary">{row.name}</span>;
|
|
352
|
-
```
|
|
309
|
+
Manages the warmup-to-content transition. When `loading` goes from true to false, holds the warmup state for one animation cycle so shimmer lines can fade out before real content mounts.
|
|
353
310
|
|
|
354
|
-
|
|
311
|
+
```tsx
|
|
312
|
+
import { useWarmupExit } from "concertina";
|
|
355
313
|
|
|
356
|
-
|
|
314
|
+
const warmup = useWarmupExit(loading);
|
|
315
|
+
const rows = warmup.showWarmup ? STUB_ROWS : realData;
|
|
316
|
+
|
|
317
|
+
<div className={warmup.exiting ? "concertina-warmup-exiting" : undefined}>
|
|
318
|
+
{rows.map(row => /* same render path */)}
|
|
319
|
+
</div>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
| Return | Type | Description |
|
|
323
|
+
|--------|------|-------------|
|
|
324
|
+
| `showWarmup` | `boolean` | True during loading AND exit animation — use for data selection |
|
|
325
|
+
| `exiting` | `boolean` | True only during exit animation — use for CSS class |
|
|
357
326
|
|
|
358
|
-
|
|
327
|
+
### Glide
|
|
359
328
|
|
|
360
|
-
`{show && <Panel />}`. The panel is either in the DOM or it's not. When it enters, everything below
|
|
329
|
+
`{show && <Panel />}`. The panel is either in the DOM or it's not. When it enters, everything below shoves down in a single frame. When it leaves, everything snaps back.
|
|
361
330
|
|
|
362
|
-
Glide wraps conditional content with enter/exit CSS animations and delays unmount until the exit animation finishes.
|
|
331
|
+
Glide wraps conditional content with enter/exit CSS animations and delays unmount until the exit animation finishes.
|
|
363
332
|
|
|
364
333
|
```tsx
|
|
365
|
-
|
|
334
|
+
import { Glide } from "concertina";
|
|
335
|
+
|
|
336
|
+
<Glide show={showPanel}>
|
|
366
337
|
<Panel />
|
|
367
|
-
</
|
|
338
|
+
</Glide>
|
|
368
339
|
```
|
|
369
340
|
|
|
370
341
|
When `show` goes true, children mount with a `concertina-glide-entering` class. When `show` goes false, they get `concertina-glide-exiting` and stay in the DOM until the animation finishes. Then they unmount for real.
|
|
371
342
|
|
|
372
|
-
|
|
343
|
+
#### Glide props
|
|
373
344
|
|
|
374
345
|
| Prop | Type | Default | Description |
|
|
375
346
|
|------|------|---------|-------------|
|
|
376
347
|
| `show` | `boolean` | | Whether the content is visible |
|
|
377
348
|
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
378
349
|
|
|
379
|
-
|
|
350
|
+
#### Customizing Glide timing
|
|
380
351
|
|
|
381
352
|
```css
|
|
382
353
|
.concertina-glide {
|
|
@@ -385,85 +356,80 @@ When `show` goes true, children mount with a `concertina-glide-entering` class.
|
|
|
385
356
|
}
|
|
386
357
|
```
|
|
387
358
|
|
|
388
|
-
|
|
359
|
+
### Warmup grid
|
|
389
360
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
## Composing them
|
|
393
|
-
|
|
394
|
-
Gigbag and Glide solve different problems and they compose:
|
|
395
|
-
|
|
396
|
-
```tsx
|
|
397
|
-
{/* a form that animates in/out and doesn't collapse during re-renders */}
|
|
398
|
-
<Concertina.Glide show={isEditing}>
|
|
399
|
-
<Concertina.Gigbag axis="height">
|
|
400
|
-
<EditForm />
|
|
401
|
-
</Concertina.Gigbag>
|
|
402
|
-
</Concertina.Glide>
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
---
|
|
406
|
-
|
|
407
|
-
## useStableSlot
|
|
408
|
-
|
|
409
|
-
For content that changes size unpredictably (prices, names, status messages) where you can't enumerate all variants upfront. This is what Gigbag uses internally. Use it directly when you want a ref-based API instead of a wrapper component.
|
|
361
|
+
For flat containers where the stub-data pattern is overkill. Renders `rows x columns` animated shimmer bones.
|
|
410
362
|
|
|
411
363
|
```tsx
|
|
412
|
-
|
|
364
|
+
import { Gigbag, Warmup } from "concertina";
|
|
413
365
|
|
|
414
|
-
<
|
|
415
|
-
{
|
|
416
|
-
</
|
|
366
|
+
<Gigbag axis="height">
|
|
367
|
+
{loading ? <Warmup rows={8} columns={3} /> : <DataTable data={data} />}
|
|
368
|
+
</Gigbag>
|
|
417
369
|
```
|
|
418
370
|
|
|
419
|
-
|
|
420
|
-
|--------|------|---------|-------------|
|
|
421
|
-
| `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to ratchet |
|
|
422
|
-
|
|
423
|
-
Returns `{ ref, style }`. Attach both to the container element.
|
|
424
|
-
|
|
425
|
-
## useTransitionLock
|
|
426
|
-
|
|
427
|
-
Suppresses CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
|
|
371
|
+
#### Warmup props
|
|
428
372
|
|
|
429
|
-
|
|
430
|
-
|
|
373
|
+
| Prop | Type | Default | Description |
|
|
374
|
+
|------|------|---------|-------------|
|
|
375
|
+
| `rows` | `number` | `3` | Number of placeholder rows |
|
|
376
|
+
| `columns` | `number` | `1` | Columns per row |
|
|
377
|
+
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
431
378
|
|
|
432
|
-
|
|
433
|
-
lock();
|
|
434
|
-
setValue(newValue);
|
|
435
|
-
};
|
|
379
|
+
#### Theming
|
|
436
380
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
381
|
+
```css
|
|
382
|
+
.concertina-warmup-line {
|
|
383
|
+
--concertina-warmup-line-radius: 0.125rem;
|
|
384
|
+
--concertina-warmup-line-color: #e5e7eb;
|
|
385
|
+
--concertina-warmup-line-highlight: #f3f4f6;
|
|
386
|
+
}
|
|
387
|
+
.concertina-warmup-bone {
|
|
388
|
+
--concertina-warmup-bone-gap: 0.125rem;
|
|
389
|
+
--concertina-warmup-bone-padding: 0.375rem 0.5rem;
|
|
390
|
+
}
|
|
391
|
+
.concertina-warmup {
|
|
392
|
+
--concertina-warmup-gap: 0.75rem;
|
|
393
|
+
}
|
|
440
394
|
```
|
|
441
395
|
|
|
442
|
-
|
|
396
|
+
### Composing spatial + temporal
|
|
443
397
|
|
|
444
|
-
|
|
398
|
+
Gigbag and Glide solve different problems and they compose:
|
|
445
399
|
|
|
446
400
|
```tsx
|
|
447
|
-
|
|
401
|
+
{/* a form that animates in/out and doesn't collapse during re-renders */}
|
|
402
|
+
<Glide show={isEditing}>
|
|
403
|
+
<Gigbag axis="height">
|
|
404
|
+
<EditForm />
|
|
405
|
+
</Gigbag>
|
|
406
|
+
</Glide>
|
|
448
407
|
```
|
|
449
408
|
|
|
450
409
|
---
|
|
451
410
|
|
|
452
|
-
##
|
|
411
|
+
## Positional stability
|
|
453
412
|
|
|
454
|
-
|
|
413
|
+
The viewport scrolled unexpectedly. You opened an accordion item and the thing you clicked scrolled off the screen.
|
|
414
|
+
|
|
415
|
+
### Accordion
|
|
416
|
+
|
|
417
|
+
Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`. The accordion components live in their own sub-path so they have a clear namespace:
|
|
455
418
|
|
|
456
419
|
```tsx
|
|
457
|
-
|
|
420
|
+
import * as Accordion from "concertina/accordion";
|
|
421
|
+
import "concertina/styles.css";
|
|
422
|
+
|
|
423
|
+
<Accordion.Root className="my-accordion">
|
|
458
424
|
{items.map((item) => (
|
|
459
|
-
<
|
|
460
|
-
<
|
|
461
|
-
<
|
|
462
|
-
</
|
|
463
|
-
<
|
|
464
|
-
</
|
|
425
|
+
<Accordion.Item key={item.id} value={item.id}>
|
|
426
|
+
<Accordion.Header>
|
|
427
|
+
<Accordion.Trigger>{item.title}</Accordion.Trigger>
|
|
428
|
+
</Accordion.Header>
|
|
429
|
+
<Accordion.Content>{item.body}</Accordion.Content>
|
|
430
|
+
</Accordion.Item>
|
|
465
431
|
))}
|
|
466
|
-
</
|
|
432
|
+
</Accordion.Root>
|
|
467
433
|
```
|
|
468
434
|
|
|
469
435
|
When you switch between items, the new one pins to the top of the scroll container. Animations are suppressed during the switch and restored after paint.
|
|
@@ -471,24 +437,40 @@ When you switch between items, the new one pins to the top of the scroll contain
|
|
|
471
437
|
`useExpanded(id)` is a per-item expansion hook. Only re-renders when this specific item's boolean flips:
|
|
472
438
|
|
|
473
439
|
```tsx
|
|
440
|
+
import { useExpanded } from "concertina/accordion";
|
|
441
|
+
|
|
474
442
|
function MyItem({ item }) {
|
|
475
|
-
const expanded =
|
|
443
|
+
const expanded = useExpanded(item.id);
|
|
476
444
|
// only re-renders when this specific item opens/closes
|
|
477
445
|
}
|
|
478
446
|
```
|
|
479
447
|
|
|
480
|
-
|
|
448
|
+
#### Customizing accordion animation
|
|
449
|
+
|
|
450
|
+
```css
|
|
451
|
+
.concertina-content {
|
|
452
|
+
--concertina-open-duration: 300ms;
|
|
453
|
+
--concertina-close-duration: 200ms;
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to the scroll container.
|
|
458
|
+
|
|
459
|
+
#### Legacy hook API
|
|
481
460
|
|
|
482
461
|
For cases where you need to manage Radix Accordion directly:
|
|
483
462
|
|
|
484
463
|
```tsx
|
|
485
|
-
|
|
464
|
+
import { useConcertina } from "concertina/accordion";
|
|
465
|
+
import * as RadixAccordion from "@radix-ui/react-accordion";
|
|
466
|
+
|
|
467
|
+
const { rootProps, getItemRef } = useConcertina();
|
|
486
468
|
|
|
487
|
-
<
|
|
488
|
-
<
|
|
469
|
+
<RadixAccordion.Root type="single" collapsible {...rootProps}>
|
|
470
|
+
<RadixAccordion.Item value="a" ref={getItemRef("a")}>
|
|
489
471
|
...
|
|
490
|
-
</
|
|
491
|
-
</
|
|
472
|
+
</RadixAccordion.Item>
|
|
473
|
+
</RadixAccordion.Root>
|
|
492
474
|
```
|
|
493
475
|
|
|
494
476
|
| Property | Type | Description |
|
|
@@ -498,16 +480,81 @@ const { rootProps, getItemRef } = Concertina.useConcertina();
|
|
|
498
480
|
| `value` | `string` | Currently expanded item (empty string when collapsed) |
|
|
499
481
|
| `switching` | `boolean` | True during a switch between items |
|
|
500
482
|
|
|
501
|
-
###
|
|
483
|
+
### pinToScrollTop
|
|
502
484
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
485
|
+
Scrolls an element to the top of its nearest scrollable ancestor. Only touches `scrollTop` on that one container — never cascades to the viewport, which matters on mobile where `scrollIntoView` pulls the whole page. Automatically accounts for sticky headers.
|
|
486
|
+
|
|
487
|
+
```tsx
|
|
488
|
+
import { pinToScrollTop } from "concertina";
|
|
489
|
+
|
|
490
|
+
pinToScrollTop(element);
|
|
508
491
|
```
|
|
509
492
|
|
|
510
|
-
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Primitives reference
|
|
496
|
+
|
|
497
|
+
Lower-level hooks extracted from the components above. Use these when you need to compose your own stability solutions.
|
|
498
|
+
|
|
499
|
+
### useTransitionLock
|
|
500
|
+
|
|
501
|
+
Suppresses CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
import { useTransitionLock } from "concertina";
|
|
505
|
+
|
|
506
|
+
const { locked, lock } = useTransitionLock();
|
|
507
|
+
|
|
508
|
+
const handleChange = (newValue) => {
|
|
509
|
+
lock();
|
|
510
|
+
setValue(newValue);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
<div data-locked={locked || undefined}>
|
|
514
|
+
{/* CSS: [data-locked] .animated { transition-duration: 0s } */}
|
|
515
|
+
</div>
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### usePresence
|
|
519
|
+
|
|
520
|
+
Mount/unmount state machine for enter/exit animations. This is what Glide uses internally.
|
|
521
|
+
|
|
522
|
+
```tsx
|
|
523
|
+
import { usePresence } from "concertina";
|
|
524
|
+
|
|
525
|
+
const { mounted, phase, onAnimationEnd } = usePresence(show);
|
|
526
|
+
// phase: "entering" | "entered" | "exiting"
|
|
527
|
+
|
|
528
|
+
{mounted && (
|
|
529
|
+
<div className={`panel panel-${phase}`} onAnimationEnd={onAnimationEnd}>
|
|
530
|
+
{children}
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### useSize
|
|
536
|
+
|
|
537
|
+
Raw border-box size observation via ResizeObserver. Reports every resize — no ratchet, no policy. Use this when you need the actual current size for your own logic (breakpoints, conditional rendering, animations).
|
|
538
|
+
|
|
539
|
+
```tsx
|
|
540
|
+
import { useSize } from "concertina";
|
|
541
|
+
|
|
542
|
+
const { ref, size } = useSize();
|
|
543
|
+
|
|
544
|
+
<div ref={ref}>
|
|
545
|
+
{size.width > 600 ? <WideLayout /> : <NarrowLayout />}
|
|
546
|
+
</div>
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### useScrollPin
|
|
550
|
+
|
|
551
|
+
Runs `pinToScrollTop` inside `useLayoutEffect` — after React commits the DOM but before the browser paints. Use this when a state change moves content and you need to correct scroll position synchronously.
|
|
552
|
+
|
|
553
|
+
```tsx
|
|
554
|
+
import { useScrollPin } from "concertina";
|
|
555
|
+
|
|
556
|
+
useScrollPin(() => itemRefs.get(activeId), [activeId]);
|
|
557
|
+
```
|
|
511
558
|
|
|
512
559
|
---
|
|
513
560
|
|
|
@@ -518,9 +565,26 @@ If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to
|
|
|
518
565
|
| Two variants swap in one slot | StableSlot + Slot |
|
|
519
566
|
| Text changes width unpredictably | useStableSlot (or CSS `tabular-nums` for numbers) |
|
|
520
567
|
| Spinner replaced by loaded content | Gigbag + Warmup |
|
|
521
|
-
| Accordion/table loading skeleton | Stub data through same render path |
|
|
568
|
+
| Accordion/table loading skeleton | Stub data through same render path + WarmupLine |
|
|
522
569
|
| Panel mounts/unmounts conditionally | Glide |
|
|
523
|
-
|
|
|
570
|
+
| Shimmer lines that match text height | WarmupLine (uses `1lh`) |
|
|
571
|
+
| Shimmer-to-content exit animation | useWarmupExit |
|
|
572
|
+
| Accordion with scroll pinning | Accordion.Root + Item + Content |
|
|
573
|
+
| Custom scroll correction | pinToScrollTop or useScrollPin |
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Roadmap
|
|
578
|
+
|
|
579
|
+
Stability problems concertina could address in future versions:
|
|
580
|
+
|
|
581
|
+
- **Scroll anchoring** — When content above a target element changes (items prepended, banners inserted), maintain scroll position relative to the target. CSS `overflow-anchor` is inconsistent across browsers and doesn't cover programmatic insertions.
|
|
582
|
+
|
|
583
|
+
- **Media reservation** — Reserve space for images/video before load via `aspect-ratio`. A thin wrapper that accepts `width`/`height` from an API response and prevents CLS. The browser's native `width`/`height` attributes help but don't cover dynamic aspect ratios or art-directed responsive images.
|
|
584
|
+
|
|
585
|
+
- **Focus stability** — When DOM mutations remove the focused element, trap focus to the nearest surviving ancestor instead of resetting to `<body>`. The `inert` attribute on inactive Slots partially addresses this for StableSlot, but general-purpose focus recovery during list reorders or filtered views is unsolved.
|
|
586
|
+
|
|
587
|
+
These are proposals, not commitments. If any of these would unblock your project, open an issue.
|
|
524
588
|
|
|
525
589
|
## License
|
|
526
590
|
|