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 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>47 tests</b> &middot; 716 lines of source &middot; 1 dependency</p>
15
+ <p align="center"><b>66 tests</b> &middot; ~900 lines of source &middot; 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 400 pixels. An accordion opens — the thing you clicked scrolls off the screen.
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 **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.
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 is a different expression of this one idea:
29
+ Every tool in concertina addresses one of three kinds of instability:
30
30
 
31
- | What changes | Tool | How it works |
32
- |---|---|---|
33
- | Which variant is visible | StableSlot + Slot | Render all variants in one grid cell, toggle visibility |
34
- | Content loading | Gigbag | Container remembers its biggest size, refuses to shrink |
35
- | Data arriving in a table | Stub data pattern | Same render path for loading and loaded — shimmer or content inside the same wrapper |
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
- import * as Concertina from "concertina";
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
- ## StableSlot + Slot
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
- <Concertina.StableSlot axis="width" className="action-slot">
62
- <Concertina.Slot active={!isInCart}>
71
+ import { StableSlot, Slot } from "concertina";
72
+
73
+ <StableSlot axis="width" className="action-slot">
74
+ <Slot active={!isInCart}>
63
75
  <AddButton />
64
- </Concertina.Slot>
65
- <Concertina.Slot active={isInCart}>
76
+ </Slot>
77
+ <Slot active={isInCart}>
66
78
  <QuantityControl />
67
- </Concertina.Slot>
68
- </Concertina.StableSlot>
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
- ### StableSlot props
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
- ### Slot props
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
- ### Rules for correct behavior
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 and the whole thing is pointless. Use `auto`:
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 — don't nest it inside a Slot of the main StableSlot:
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
- <Concertina.StableSlot axis="width">
112
- <Concertina.Slot active={showDeliver}><Button>Deliver</Button></Concertina.Slot>
113
- <Concertina.Slot active={showCharge}><Button>Charge</Button></Concertina.Slot>
114
- </Concertina.StableSlot>
115
- <Concertina.StableSlot>
116
- <Concertina.Slot active={showCharge}>
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
- </Concertina.Slot>
119
- </Concertina.StableSlot>
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
- <Concertina.Gigbag axis="height">
150
+ import { Gigbag, Warmup } from "concertina";
151
+
152
+ <Gigbag axis="height">
143
153
  {loading ? (
144
- <Concertina.Warmup rows={8} columns={3} />
154
+ <Warmup rows={8} columns={3} />
145
155
  ) : (
146
156
  <DataTable data={data} />
147
157
  )}
148
- </Concertina.Gigbag>
158
+ </Gigbag>
149
159
  ```
150
160
 
151
- The Gigbag ratchets to whichever child is taller. On subsequent re-fetches it holds at the table's height instead of collapsing back.
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
- ### Warmup props
168
+ ### useStableSlot
161
169
 
162
- | Prop | Type | Default | Description |
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
- ### Theming Warmup
172
+ ```tsx
173
+ import { useStableSlot } from "concertina";
169
174
 
170
- All dimensions are CSS custom properties:
175
+ const slot = useStableSlot({ axis: "width" });
171
176
 
172
- ```css
173
- .concertina-warmup {
174
- --concertina-warmup-gap: 0.5rem;
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
- ### Shimmer dimensions come from the text styles, not from the shimmer
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
- ? <Concertina.WarmupLine className="text-sm text-stone flex-1" />
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 (`minmax(4rem, auto)`). 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.
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
- ## The stub-data pattern
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. It has to. The structures are different.
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
- ### How it works
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
- The stub rows go through `Root > Item > Trigger > Content` — the exact same components as real rows. The `Content` element exists in the DOM (collapsed, zero height) for both stubs and real data. Every wrapper, every padding, every border is identical. The only difference is what's inside the cells.
264
+ #### The wrapper-once rule
272
265
 
273
- ### The wrapper-once rule
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
- When the wrapper is duplicated across branches, it will drift. Someone adds a class to the live branch, forgets the warmup branch. The heights diverge. Layout shifts. And nobody notices until a user watches their screen jump on every page load.
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
- _warmup: true;
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
- You can't access `row.name` without checking `_warmup` first. A cell renderer that forgets the check fails at build time.
305
+ TypeScript prevents you from forgetting the branch. The wrapper-once pattern prevents you from forgetting the wrapper. Use both.
345
306
 
346
- **What TypeScript does NOT enforce:** that you use the wrapper-once pattern. The compiler can't see inside JSX structure. This compiles without error and shifts layout:
307
+ ### useWarmupExit
347
308
 
348
- ```ts
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
- TypeScript prevents you from forgetting the branch. The wrapper-once pattern prevents you from forgetting the wrapper. Use both.
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
- ## Glide
327
+ ### Glide
359
328
 
360
- `{show && <Panel />}`. The panel is either in the DOM or it's not. When it enters, everything below it gets shoved down in a single frame. When it leaves, everything snaps back up. A light switch that also moves your furniture.
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. The panel slides in, the panel slides out, content around it moves smoothly.
331
+ Glide wraps conditional content with enter/exit CSS animations and delays unmount until the exit animation finishes.
363
332
 
364
333
  ```tsx
365
- <Concertina.Glide show={showPanel}>
334
+ import { Glide } from "concertina";
335
+
336
+ <Glide show={showPanel}>
366
337
  <Panel />
367
- </Concertina.Glide>
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
- ### Glide props
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
- ### Customizing Glide timing
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
- The height variable is a ceiling, not an exact value. `max-height` is how you animate height on auto-height elements. `overflow: hidden` clips any overshoot.
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
- const slot = Concertina.useStableSlot({ axis: "width" });
364
+ import { Gigbag, Warmup } from "concertina";
413
365
 
414
- <div ref={slot.ref} style={slot.style} className="price-amount">
415
- {formattedPrice}
416
- </div>
366
+ <Gigbag axis="height">
367
+ {loading ? <Warmup rows={8} columns={3} /> : <DataTable data={data} />}
368
+ </Gigbag>
417
369
  ```
418
370
 
419
- | Option | Type | Default | Description |
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
- ```tsx
430
- const { locked, lock } = Concertina.useTransitionLock();
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
- const handleChange = (newValue) => {
433
- lock();
434
- setValue(newValue);
435
- };
379
+ #### Theming
436
380
 
437
- <div data-locked={locked || undefined}>
438
- {/* CSS: [data-locked] .animated { transition-duration: 0s } */}
439
- </div>
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
- ## pinToScrollTop
396
+ ### Composing spatial + temporal
443
397
 
444
- Scrolls an element to the top of its nearest scrollable ancestor. Only touches `scrollTop` on that one container. Never cascades to the viewport — no full-page drag on mobile. Accounts for sticky headers automatically.
398
+ Gigbag and Glide solve different problems and they compose:
445
399
 
446
400
  ```tsx
447
- Concertina.pinToScrollTop(element);
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
- ## Accordion
411
+ ## Positional stability
453
412
 
454
- Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`.
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
- <Concertina.Root className="my-accordion">
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
- <Concertina.Item key={item.id} value={item.id}>
460
- <Concertina.Header>
461
- <Concertina.Trigger>{item.title}</Concertina.Trigger>
462
- </Concertina.Header>
463
- <Concertina.Content>{item.body}</Concertina.Content>
464
- </Concertina.Item>
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
- </Concertina.Root>
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 = Concertina.useExpanded(item.id);
443
+ const expanded = useExpanded(item.id);
476
444
  // only re-renders when this specific item opens/closes
477
445
  }
478
446
  ```
479
447
 
480
- ### Legacy hook API
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
- const { rootProps, getItemRef } = Concertina.useConcertina();
464
+ import { useConcertina } from "concertina/accordion";
465
+ import * as RadixAccordion from "@radix-ui/react-accordion";
466
+
467
+ const { rootProps, getItemRef } = useConcertina();
486
468
 
487
- <Accordion.Root type="single" collapsible {...rootProps}>
488
- <Accordion.Item value="a" ref={getItemRef("a")}>
469
+ <RadixAccordion.Root type="single" collapsible {...rootProps}>
470
+ <RadixAccordion.Item value="a" ref={getItemRef("a")}>
489
471
  ...
490
- </Accordion.Item>
491
- </Accordion.Root>
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
- ### Customizing accordion animation
483
+ ### pinToScrollTop
502
484
 
503
- ```css
504
- .concertina-content {
505
- --concertina-open-duration: 300ms;
506
- --concertina-close-duration: 200ms;
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
- If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to the scroll container.
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
- | Accordion with scroll pinning | Root + Item + Content |
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