concertina 0.8.1 → 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 +393 -131
- 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.cjs +63 -30
- package/dist/index.d.cts +34 -67
- package/dist/index.d.ts +34 -67
- package/dist/index.js +94 -1235
- package/dist/styles.css +14 -10
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -12,23 +12,29 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
25
|
-
|
|
25
|
+
## The fix
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Don't swap structures. Swap what's inside them.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Every tool in concertina addresses one of three kinds of instability:
|
|
30
30
|
|
|
31
|
-
|
|
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 |
|
|
36
|
+
|
|
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.
|
|
32
38
|
|
|
33
39
|
## Install
|
|
34
40
|
|
|
@@ -37,23 +43,41 @@ npm install concertina
|
|
|
37
43
|
```
|
|
38
44
|
|
|
39
45
|
```tsx
|
|
40
|
-
|
|
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
|
|
41
53
|
import "concertina/styles.css";
|
|
42
54
|
```
|
|
43
55
|
|
|
44
|
-
|
|
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
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Spatial stability
|
|
61
|
+
|
|
62
|
+
The box changed size. Something swapped, grew, or shrank and everything around it moved.
|
|
45
63
|
|
|
46
|
-
|
|
64
|
+
### StableSlot + Slot
|
|
65
|
+
|
|
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.
|
|
67
|
+
|
|
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.
|
|
47
69
|
|
|
48
70
|
```tsx
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
import { StableSlot, Slot } from "concertina";
|
|
72
|
+
|
|
73
|
+
<StableSlot axis="width" className="action-slot">
|
|
74
|
+
<Slot active={!isInCart}>
|
|
51
75
|
<AddButton />
|
|
52
|
-
</
|
|
53
|
-
<
|
|
76
|
+
</Slot>
|
|
77
|
+
<Slot active={isInCart}>
|
|
54
78
|
<QuantityControl />
|
|
55
|
-
</
|
|
56
|
-
</
|
|
79
|
+
</Slot>
|
|
80
|
+
</StableSlot>
|
|
57
81
|
```
|
|
58
82
|
|
|
59
83
|
How it works:
|
|
@@ -63,7 +87,7 @@ How it works:
|
|
|
63
87
|
- Each Slot uses `display: flex; flex-direction: column` so content stretches to fill the reserved width.
|
|
64
88
|
- Zero JS measurement. Pure CSS. Works on the first frame.
|
|
65
89
|
|
|
66
|
-
|
|
90
|
+
#### StableSlot props
|
|
67
91
|
|
|
68
92
|
| Prop | Type | Default | Description |
|
|
69
93
|
|------|------|---------|-------------|
|
|
@@ -71,7 +95,7 @@ How it works:
|
|
|
71
95
|
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
72
96
|
| `className` | `string` | | Passed to wrapper |
|
|
73
97
|
|
|
74
|
-
|
|
98
|
+
#### Slot props
|
|
75
99
|
|
|
76
100
|
| Prop | Type | Description |
|
|
77
101
|
|------|------|-------------|
|
|
@@ -80,9 +104,9 @@ How it works:
|
|
|
80
104
|
|
|
81
105
|
All other HTML attributes are forwarded on both components.
|
|
82
106
|
|
|
83
|
-
|
|
107
|
+
#### Rules for correct behavior
|
|
84
108
|
|
|
85
|
-
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`:
|
|
86
110
|
|
|
87
111
|
```css
|
|
88
112
|
/* StableSlot can't do its job in here */
|
|
@@ -92,27 +116,25 @@ grid-template-columns: 1fr 10rem;
|
|
|
92
116
|
grid-template-columns: 1fr auto;
|
|
93
117
|
```
|
|
94
118
|
|
|
95
|
-
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:
|
|
96
120
|
|
|
97
121
|
```tsx
|
|
98
122
|
<div className="action-column">
|
|
99
|
-
<
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
</
|
|
103
|
-
<
|
|
104
|
-
<
|
|
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}>
|
|
105
129
|
<button className="undo-link">Undo</button>
|
|
106
|
-
</
|
|
107
|
-
</
|
|
130
|
+
</Slot>
|
|
131
|
+
</StableSlot>
|
|
108
132
|
</div>
|
|
109
133
|
```
|
|
110
134
|
|
|
111
|
-
A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
|
|
135
|
+
A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
|
|
112
136
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
You've seen this a thousand times:
|
|
137
|
+
### Gigbag
|
|
116
138
|
|
|
117
139
|
```jsx
|
|
118
140
|
if (loading) return <Spinner />; // 48px
|
|
@@ -120,74 +142,212 @@ if (empty) return <EmptyMsg />; // 64px
|
|
|
120
142
|
return <BigTable data={data} />; // 500px+
|
|
121
143
|
```
|
|
122
144
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Gigbag is a container that remembers its largest-ever size via ResizeObserver and will not shrink. Will not. 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. The case is the size of the guitar. Always. It also uses `contain: layout style` so internal reflows don't bother the ancestors.
|
|
145
|
+
Three different structures, three different heights. Every transition jumps.
|
|
126
146
|
|
|
127
|
-
|
|
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.
|
|
128
148
|
|
|
129
149
|
```tsx
|
|
130
|
-
|
|
150
|
+
import { Gigbag, Warmup } from "concertina";
|
|
151
|
+
|
|
152
|
+
<Gigbag axis="height">
|
|
131
153
|
{loading ? (
|
|
132
|
-
<
|
|
154
|
+
<Warmup rows={8} columns={3} />
|
|
133
155
|
) : (
|
|
134
156
|
<DataTable data={data} />
|
|
135
157
|
)}
|
|
136
|
-
</
|
|
158
|
+
</Gigbag>
|
|
137
159
|
```
|
|
138
160
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
### Gigbag props
|
|
161
|
+
#### Gigbag props
|
|
142
162
|
|
|
143
163
|
| Prop | Type | Default | Description |
|
|
144
164
|
|------|------|---------|-------------|
|
|
145
165
|
| `axis` | `"width"` \| `"height"` \| `"both"` | `"height"` | Which axis to ratchet |
|
|
146
166
|
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
147
167
|
|
|
148
|
-
###
|
|
168
|
+
### useStableSlot
|
|
149
169
|
|
|
150
|
-
|
|
151
|
-
|------|------|---------|-------------|
|
|
152
|
-
| `rows` | `number` | `3` | Number of placeholder rows |
|
|
153
|
-
| `columns` | `number` | `1` | Columns per row |
|
|
154
|
-
| `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.
|
|
155
171
|
|
|
156
|
-
|
|
172
|
+
```tsx
|
|
173
|
+
import { useStableSlot } from "concertina";
|
|
157
174
|
|
|
158
|
-
|
|
175
|
+
const slot = useStableSlot({ axis: "width" });
|
|
159
176
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
<div ref={slot.ref} style={slot.style} className="price-amount">
|
|
178
|
+
{formattedPrice}
|
|
179
|
+
</div>
|
|
180
|
+
```
|
|
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
|
|
195
|
+
|
|
196
|
+
This is the single most important thing in the library and the easiest to get wrong.
|
|
197
|
+
|
|
198
|
+
A shimmer line replaces text. It needs to be exactly as tall as the text it replaces. For 17 years, CSS had no unit for "one line of text." `em` is font-size, not line-height. `rem` is the root font-size. `px` is absolute. Every shimmer library picked a number — `height: 0.75em`, `height: 12px`, `height: 1rem` — and it was wrong, because text height is determined by line-height, which is determined by the font styles on the element. A shimmer that invents its own height is a shimmer that shifts layout when the real text arrives.
|
|
199
|
+
|
|
200
|
+
CSS now has the `lh` unit. `1lh` resolves to the element's computed line-height. The shimmer uses `height: 1lh`. That's not a magic number — it's a relative unit that derives its value from the element's styles, the same way `100%` derives its value from the container's size.
|
|
201
|
+
|
|
202
|
+
But `1lh` only works if the shimmer has the right styles. A bare `<div className="concertina-warmup-line" />` inherits line-height from its parent. If it's inside a `<span className="text-sm">`, it inherits `text-sm`'s line-height. Correct. But if it's a direct child of a toolbar with no font context, `1lh` resolves against the default line-height. Wrong.
|
|
203
|
+
|
|
204
|
+
**The `WarmupLine` component exists so you can pass the text styles explicitly:**
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
import { WarmupLine } from "concertina";
|
|
208
|
+
|
|
209
|
+
// Toolbar — no parent provides text styles, so pass them directly
|
|
210
|
+
{loading
|
|
211
|
+
? <WarmupLine className="text-sm text-stone flex-1" />
|
|
212
|
+
: <span className="text-sm text-stone">{count} customers</span>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Grid cell — parent wrapper provides text styles via inheritance
|
|
216
|
+
<span className="table-val-primary">
|
|
217
|
+
{row._warmup
|
|
218
|
+
? <div className="concertina-warmup-line" />
|
|
219
|
+
: row.name
|
|
220
|
+
}
|
|
221
|
+
</span>
|
|
222
|
+
```
|
|
223
|
+
|
|
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.
|
|
225
|
+
|
|
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.
|
|
227
|
+
|
|
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.
|
|
235
|
+
|
|
236
|
+
### The stub-data pattern
|
|
237
|
+
|
|
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.
|
|
239
|
+
|
|
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.**
|
|
241
|
+
|
|
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:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
// Stub data — same shape as real rows
|
|
246
|
+
const STUB_ROWS = Array.from({ length: 8 }, (_, i) => ({
|
|
247
|
+
_warmup: true as const,
|
|
248
|
+
id: `warmup-${i}`,
|
|
249
|
+
name: null,
|
|
250
|
+
items: [],
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
// Cell renderer — wrapper defined once, content varies inside it
|
|
254
|
+
cell: ({ row }) => (
|
|
255
|
+
<span className="table-val-primary">
|
|
256
|
+
{row.original._warmup
|
|
257
|
+
? <div className="concertina-warmup-line" />
|
|
258
|
+
: row.original.name
|
|
259
|
+
}
|
|
260
|
+
</span>
|
|
261
|
+
)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### The wrapper-once rule
|
|
265
|
+
|
|
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.
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
// WRONG — wrapper duplicated, will drift apart silently
|
|
270
|
+
if (row.original._warmup) {
|
|
271
|
+
return <span className="table-val-money"><div className="concertina-warmup-line" /></span>;
|
|
272
|
+
}
|
|
273
|
+
return <span className="table-val-money">${total}</span>;
|
|
274
|
+
|
|
275
|
+
// RIGHT — wrapper defined once, content switches inside it
|
|
276
|
+
<span className="table-val-money">
|
|
277
|
+
{row.original._warmup
|
|
278
|
+
? <div className="concertina-warmup-line" />
|
|
279
|
+
: `$${total}`
|
|
280
|
+
}
|
|
281
|
+
</span>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
#### TypeScript enforcement
|
|
285
|
+
|
|
286
|
+
A discriminated union guarantees you check `_warmup` before accessing real data:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
type WarmupRow = { _warmup: true; id: string };
|
|
290
|
+
type RealRow = { _warmup?: never; id: string; name: string; items: Item[] };
|
|
291
|
+
type Row = WarmupRow | RealRow;
|
|
292
|
+
|
|
293
|
+
function renderCell(row: Row) {
|
|
294
|
+
return (
|
|
295
|
+
<span className="table-val-primary">
|
|
296
|
+
{row._warmup
|
|
297
|
+
? <div className="concertina-warmup-line" />
|
|
298
|
+
: row.name // TS narrows to RealRow here
|
|
299
|
+
}
|
|
300
|
+
</span>
|
|
301
|
+
);
|
|
166
302
|
}
|
|
167
303
|
```
|
|
168
304
|
|
|
169
|
-
|
|
305
|
+
TypeScript prevents you from forgetting the branch. The wrapper-once pattern prevents you from forgetting the wrapper. Use both.
|
|
306
|
+
|
|
307
|
+
### useWarmupExit
|
|
308
|
+
|
|
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.
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
import { useWarmupExit } from "concertina";
|
|
313
|
+
|
|
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 |
|
|
326
|
+
|
|
327
|
+
### Glide
|
|
170
328
|
|
|
171
|
-
`{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.
|
|
172
330
|
|
|
173
|
-
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.
|
|
174
332
|
|
|
175
333
|
```tsx
|
|
176
|
-
|
|
334
|
+
import { Glide } from "concertina";
|
|
335
|
+
|
|
336
|
+
<Glide show={showPanel}>
|
|
177
337
|
<Panel />
|
|
178
|
-
</
|
|
338
|
+
</Glide>
|
|
179
339
|
```
|
|
180
340
|
|
|
181
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.
|
|
182
342
|
|
|
183
|
-
|
|
343
|
+
#### Glide props
|
|
184
344
|
|
|
185
345
|
| Prop | Type | Default | Description |
|
|
186
346
|
|------|------|---------|-------------|
|
|
187
347
|
| `show` | `boolean` | | Whether the content is visible |
|
|
188
348
|
| `as` | `ElementType` | `"div"` | HTML element to render |
|
|
189
349
|
|
|
190
|
-
|
|
350
|
+
#### Customizing Glide timing
|
|
191
351
|
|
|
192
352
|
```css
|
|
193
353
|
.concertina-glide {
|
|
@@ -196,79 +356,80 @@ When `show` goes true, children mount with a `concertina-glide-entering` class.
|
|
|
196
356
|
}
|
|
197
357
|
```
|
|
198
358
|
|
|
199
|
-
|
|
359
|
+
### Warmup grid
|
|
200
360
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
Gigbag and Glide solve different problems and they compose:
|
|
361
|
+
For flat containers where the stub-data pattern is overkill. Renders `rows x columns` animated shimmer bones.
|
|
204
362
|
|
|
205
363
|
```tsx
|
|
206
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
</Concertina.Glide>
|
|
364
|
+
import { Gigbag, Warmup } from "concertina";
|
|
365
|
+
|
|
366
|
+
<Gigbag axis="height">
|
|
367
|
+
{loading ? <Warmup rows={8} columns={3} /> : <DataTable data={data} />}
|
|
368
|
+
</Gigbag>
|
|
212
369
|
```
|
|
213
370
|
|
|
214
|
-
|
|
371
|
+
#### Warmup props
|
|
215
372
|
|
|
216
|
-
|
|
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 |
|
|
217
378
|
|
|
218
|
-
|
|
219
|
-
const slot = Concertina.useStableSlot({ axis: "width" });
|
|
379
|
+
#### Theming
|
|
220
380
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
}
|
|
224
394
|
```
|
|
225
395
|
|
|
226
|
-
|
|
227
|
-
|--------|------|---------|-------------|
|
|
228
|
-
| `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to ratchet |
|
|
229
|
-
|
|
230
|
-
Returns `{ ref, style }`. Attach both to the container element.
|
|
231
|
-
|
|
232
|
-
## Animation suppression: useTransitionLock
|
|
396
|
+
### Composing spatial + temporal
|
|
233
397
|
|
|
234
|
-
|
|
398
|
+
Gigbag and Glide solve different problems and they compose:
|
|
235
399
|
|
|
236
400
|
```tsx
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
<div data-locked={locked || undefined}>
|
|
245
|
-
{/* CSS: [data-locked] .animated { transition-duration: 0s } */}
|
|
246
|
-
</div>
|
|
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>
|
|
247
407
|
```
|
|
248
408
|
|
|
249
|
-
|
|
409
|
+
---
|
|
250
410
|
|
|
251
|
-
|
|
411
|
+
## Positional stability
|
|
252
412
|
|
|
253
|
-
|
|
254
|
-
Concertina.pinToScrollTop(element);
|
|
255
|
-
```
|
|
413
|
+
The viewport scrolled unexpectedly. You opened an accordion item and the thing you clicked scrolled off the screen.
|
|
256
414
|
|
|
257
|
-
|
|
415
|
+
### Accordion
|
|
258
416
|
|
|
259
|
-
Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`.
|
|
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:
|
|
260
418
|
|
|
261
419
|
```tsx
|
|
262
|
-
|
|
420
|
+
import * as Accordion from "concertina/accordion";
|
|
421
|
+
import "concertina/styles.css";
|
|
422
|
+
|
|
423
|
+
<Accordion.Root className="my-accordion">
|
|
263
424
|
{items.map((item) => (
|
|
264
|
-
<
|
|
265
|
-
<
|
|
266
|
-
<
|
|
267
|
-
</
|
|
268
|
-
<
|
|
269
|
-
</
|
|
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>
|
|
270
431
|
))}
|
|
271
|
-
</
|
|
432
|
+
</Accordion.Root>
|
|
272
433
|
```
|
|
273
434
|
|
|
274
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.
|
|
@@ -276,24 +437,40 @@ When you switch between items, the new one pins to the top of the scroll contain
|
|
|
276
437
|
`useExpanded(id)` is a per-item expansion hook. Only re-renders when this specific item's boolean flips:
|
|
277
438
|
|
|
278
439
|
```tsx
|
|
440
|
+
import { useExpanded } from "concertina/accordion";
|
|
441
|
+
|
|
279
442
|
function MyItem({ item }) {
|
|
280
|
-
const expanded =
|
|
443
|
+
const expanded = useExpanded(item.id);
|
|
281
444
|
// only re-renders when this specific item opens/closes
|
|
282
445
|
}
|
|
283
446
|
```
|
|
284
447
|
|
|
285
|
-
|
|
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
|
|
286
460
|
|
|
287
461
|
For cases where you need to manage Radix Accordion directly:
|
|
288
462
|
|
|
289
463
|
```tsx
|
|
290
|
-
|
|
464
|
+
import { useConcertina } from "concertina/accordion";
|
|
465
|
+
import * as RadixAccordion from "@radix-ui/react-accordion";
|
|
466
|
+
|
|
467
|
+
const { rootProps, getItemRef } = useConcertina();
|
|
291
468
|
|
|
292
|
-
<
|
|
293
|
-
<
|
|
469
|
+
<RadixAccordion.Root type="single" collapsible {...rootProps}>
|
|
470
|
+
<RadixAccordion.Item value="a" ref={getItemRef("a")}>
|
|
294
471
|
...
|
|
295
|
-
</
|
|
296
|
-
</
|
|
472
|
+
</RadixAccordion.Item>
|
|
473
|
+
</RadixAccordion.Root>
|
|
297
474
|
```
|
|
298
475
|
|
|
299
476
|
| Property | Type | Description |
|
|
@@ -303,16 +480,83 @@ const { rootProps, getItemRef } = Concertina.useConcertina();
|
|
|
303
480
|
| `value` | `string` | Currently expanded item (empty string when collapsed) |
|
|
304
481
|
| `switching` | `boolean` | True during a switch between items |
|
|
305
482
|
|
|
306
|
-
###
|
|
483
|
+
### pinToScrollTop
|
|
307
484
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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);
|
|
313
491
|
```
|
|
314
492
|
|
|
315
|
-
|
|
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
|
+
```
|
|
558
|
+
|
|
559
|
+
---
|
|
316
560
|
|
|
317
561
|
## Picking the right tool
|
|
318
562
|
|
|
@@ -321,8 +565,26 @@ If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to
|
|
|
321
565
|
| Two variants swap in one slot | StableSlot + Slot |
|
|
322
566
|
| Text changes width unpredictably | useStableSlot (or CSS `tabular-nums` for numbers) |
|
|
323
567
|
| Spinner replaced by loaded content | Gigbag + Warmup |
|
|
568
|
+
| Accordion/table loading skeleton | Stub data through same render path + WarmupLine |
|
|
324
569
|
| Panel mounts/unmounts conditionally | Glide |
|
|
325
|
-
|
|
|
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.
|
|
326
588
|
|
|
327
589
|
## License
|
|
328
590
|
|