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 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>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
- ## Why this exists
17
+ ## The problem
18
18
 
19
- Concertina started because accordions in React are broken. You click an item, it expands, and the thing you just clicked scrolls off the screen. The browser shoved everything down to make room and now you're staring at content you didn't ask for while the thing you wanted is somewhere above you. On mobile it's worse `scrollIntoView` grabs the entire viewport and drags it around like a dog with a sock.
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
- So concertina started as an accordion wrapper with scroll pinning. But the deeper we got, the more we realized accordions are just one instance of a bigger problem: things change size and the browser moves everything else to compensate. Swap a button for a stepper. Replace a spinner with a table. Mount a panel. Unmount it. Same disease, every time.
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
- The core idea is almost embarrassingly simple: don't swap things. Render all the variants at the same time, in the same grid cell, stacked on top of each other. The cell sizes itself to the biggest one. You toggle which one is visible. The box never changes size because all the variants are always in there. No measurement, no ResizeObserver, no layout effect. CSS grid figured it out on the first frame because that's what it already does.
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
- That covers the most common source of layout shift. Two cases it doesn't cover:
25
+ ## The fix
26
26
 
27
- 1. **Data loads.** A spinner sits at 48 pixels. The real table shows up at 500. The scroll region has an episode. You can't enumerate all variants upfront because the content is dynamic, so you need a container that remembers its biggest size and refuses to shrink.
27
+ Don't swap structures. Swap what's inside them.
28
28
 
29
- 2. **Conditional content.** A panel mounts or unmounts. Everything below it teleports in a single frame. No transition. No grace. On, off, furniture moved.
29
+ Every tool in concertina addresses one of three kinds of instability:
30
30
 
31
- Concertina has a small primitive for each.
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
- 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
41
53
  import "concertina/styles.css";
42
54
  ```
43
55
 
44
- ## Variant switching: StableSlot + Slot
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
- Your layout shifts when you swap components because the new one is a different size and the browser just rolls with it. The fix: don't swap them. Render all of them at the same time, same grid cell, stacked. The cell sizes to the biggest one. Toggle visibility. The box can't change size. All the variants are always in there.
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
- <Concertina.StableSlot axis="width" className="action-slot">
50
- <Concertina.Slot active={!isInCart}>
71
+ import { StableSlot, Slot } from "concertina";
72
+
73
+ <StableSlot axis="width" className="action-slot">
74
+ <Slot active={!isInCart}>
51
75
  <AddButton />
52
- </Concertina.Slot>
53
- <Concertina.Slot active={isInCart}>
76
+ </Slot>
77
+ <Slot active={isInCart}>
54
78
  <QuantityControl />
55
- </Concertina.Slot>
56
- </Concertina.StableSlot>
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
- ### StableSlot props
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
- ### Slot props
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
- ### Rules for correct behavior
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 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`:
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 — 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:
96
120
 
97
121
  ```tsx
98
122
  <div className="action-column">
99
- <Concertina.StableSlot axis="width">
100
- <Concertina.Slot active={showDeliver}><Button>Deliver</Button></Concertina.Slot>
101
- <Concertina.Slot active={showCharge}><Button>Charge</Button></Concertina.Slot>
102
- </Concertina.StableSlot>
103
- <Concertina.StableSlot>
104
- <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}>
105
129
  <button className="undo-link">Undo</button>
106
- </Concertina.Slot>
107
- </Concertina.StableSlot>
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. This is fine. This is good actually.
135
+ A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
112
136
 
113
- ## Progressive loading: Gigbag + Warmup
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
- The spinner is 48 pixels. The table is 500. When the data arrives, the container quintuples in height and everything the user was looking at gets launched off screen.
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
- 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 looks like the content. Rows. Columns. Pulsing. The browser knows how tall things will be because you told it. With shapes.
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
- <Concertina.Gigbag axis="height">
150
+ import { Gigbag, Warmup } from "concertina";
151
+
152
+ <Gigbag axis="height">
131
153
  {loading ? (
132
- <Concertina.Warmup rows={8} columns={3} />
154
+ <Warmup rows={8} columns={3} />
133
155
  ) : (
134
156
  <DataTable data={data} />
135
157
  )}
136
- </Concertina.Gigbag>
158
+ </Gigbag>
137
159
  ```
138
160
 
139
- The Gigbag ratchets to whichever is taller. On subsequent re-fetches it holds at the table's height instead of collapsing back. The data can come and go. The container does not care.
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
- ### Warmup props
168
+ ### useStableSlot
149
169
 
150
- | Prop | Type | Default | Description |
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
- ### Theming Warmup
172
+ ```tsx
173
+ import { useStableSlot } from "concertina";
157
174
 
158
- All dimensions are CSS custom properties. Override them to match your app:
175
+ const slot = useStableSlot({ axis: "width" });
159
176
 
160
- ```css
161
- .concertina-warmup {
162
- --concertina-warmup-gap: 0.5rem;
163
- --concertina-warmup-bone-height: 2.5rem;
164
- --concertina-warmup-bone-radius: 0.25rem;
165
- --concertina-warmup-bone-color: #e5e7eb;
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
- ## Conditional content: Glide
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 it gets shoved down in a single frame. When it leaves, everything snaps back up. It's 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.
172
330
 
173
- 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.
174
332
 
175
333
  ```tsx
176
- <Concertina.Glide show={showPanel}>
334
+ import { Glide } from "concertina";
335
+
336
+ <Glide show={showPanel}>
177
337
  <Panel />
178
- </Concertina.Glide>
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
- ### Glide props
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
- ### Customizing Glide timing
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
- 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. CSS doesn't give us anything better.
359
+ ### Warmup grid
200
360
 
201
- ## Composing them
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
- {/* a form that animates in/out and doesn't collapse during re-renders */}
207
- <Concertina.Glide show={isEditing}>
208
- <Concertina.Gigbag axis="height">
209
- <EditForm />
210
- </Concertina.Gigbag>
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
- ## Dynamic text: useStableSlot
371
+ #### Warmup props
215
372
 
216
- 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.
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
- ```tsx
219
- const slot = Concertina.useStableSlot({ axis: "width" });
379
+ #### Theming
220
380
 
221
- <div ref={slot.ref} style={slot.style} className="price-amount">
222
- {formattedPrice}
223
- </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
+ }
224
394
  ```
225
395
 
226
- | Option | Type | Default | Description |
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
- Suppresses CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
398
+ Gigbag and Glide solve different problems and they compose:
235
399
 
236
400
  ```tsx
237
- const { locked, lock } = Concertina.useTransitionLock();
238
-
239
- const handleChange = (newValue) => {
240
- lock();
241
- setValue(newValue);
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
- ## Scroll pinning: pinToScrollTop
409
+ ---
250
410
 
251
- 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.
411
+ ## Positional stability
252
412
 
253
- ```tsx
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
- ## Accordion
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
- <Concertina.Root className="my-accordion">
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
- <Concertina.Item key={item.id} value={item.id}>
265
- <Concertina.Header>
266
- <Concertina.Trigger>{item.title}</Concertina.Trigger>
267
- </Concertina.Header>
268
- <Concertina.Content>{item.body}</Concertina.Content>
269
- </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>
270
431
  ))}
271
- </Concertina.Root>
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 = Concertina.useExpanded(item.id);
443
+ const expanded = useExpanded(item.id);
281
444
  // only re-renders when this specific item opens/closes
282
445
  }
283
446
  ```
284
447
 
285
- ### 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
286
460
 
287
461
  For cases where you need to manage Radix Accordion directly:
288
462
 
289
463
  ```tsx
290
- 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();
291
468
 
292
- <Accordion.Root type="single" collapsible {...rootProps}>
293
- <Accordion.Item value="a" ref={getItemRef("a")}>
469
+ <RadixAccordion.Root type="single" collapsible {...rootProps}>
470
+ <RadixAccordion.Item value="a" ref={getItemRef("a")}>
294
471
  ...
295
- </Accordion.Item>
296
- </Accordion.Root>
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
- ### Customizing accordion animation
483
+ ### pinToScrollTop
307
484
 
308
- ```css
309
- .concertina-content {
310
- --concertina-open-duration: 300ms;
311
- --concertina-close-duration: 200ms;
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
- 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
+ ```
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
- | 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.
326
588
 
327
589
  ## License
328
590