concertina 0.10.0 → 0.11.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>66 tests</b> &middot; ~900 lines of source &middot; 1 dependency</p>
15
+ <p align="center"><b>92 tests</b> &middot; ~1200 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. 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,309 +26,275 @@ 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 addresses one of three kinds of instability:
30
-
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.
38
-
39
- ## Install
29
+ Concertina gives you three high-level components: **Bellows**, **Hum**, and **Ensemble**. They handle the math so you can focus on the music. CSS is auto-injected on first render. No manual imports needed.
40
30
 
41
31
  ```bash
42
32
  npm install concertina
43
33
  ```
44
34
 
45
35
  ```tsx
46
- // Stability primitives
47
- import { StableSlot, Slot, Gigbag, WarmupLine, Glide, useWarmupExit } from "concertina";
36
+ import { Bellows, Slot, Hum, Ensemble } from "concertina";
37
+ // That's it. No CSS import required.
38
+ ```
48
39
 
49
- // Accordion (Radix integration) separate namespace
50
- import * as Accordion from "concertina/accordion";
40
+ > SSR users: keep `import "concertina/styles.css"` in your server entry. Auto-injection runs on first client render, but SSR needs the styles before that.
41
+
42
+ ---
51
43
 
52
- // Styles
44
+ ## Before & after
45
+
46
+ v0.11.0 replaces manual wiring with musical composition.
47
+
48
+ **Before (v0.10)**: manual CSS import, boolean wiring, separate warmup plumbing.
49
+
50
+ ```tsx
51
+ import { StableSlot, Slot, Gigbag, Warmup, useWarmupExit } from "concertina";
53
52
  import "concertina/styles.css";
53
+
54
+ function Tabs({ activeTab }) {
55
+ return (
56
+ <StableSlot>
57
+ <Slot active={activeTab === "profile"}>
58
+ <ProfilePanel />
59
+ </Slot>
60
+ <Slot active={activeTab === "settings"}>
61
+ <SettingsPanel />
62
+ </Slot>
63
+ </StableSlot>
64
+ );
65
+ }
66
+
67
+ function UserList({ users, loading }) {
68
+ const { showWarmup, exiting } = useWarmupExit(loading, 150);
69
+ return (
70
+ <Gigbag axis="height">
71
+ {showWarmup ? (
72
+ <Warmup
73
+ rows={5}
74
+ className={exiting ? "concertina-warmup-exiting" : undefined}
75
+ />
76
+ ) : (
77
+ <div>{users.map(u => <UserCard key={u.id} user={u} />)}</div>
78
+ )}
79
+ </Gigbag>
80
+ );
81
+ }
54
82
  ```
55
83
 
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.
84
+ **After (v0.11.0)**: named notes, no CSS import, no plumbing.
57
85
 
58
- ---
86
+ ```tsx
87
+ import { Bellows, Slot, Ensemble } from "concertina";
59
88
 
60
- ## Spatial stability
89
+ function Tabs({ activeTab }) {
90
+ return (
91
+ <Bellows activeNote={activeTab}>
92
+ <Slot note="profile"><ProfilePanel /></Slot>
93
+ <Slot note="settings"><SettingsPanel /></Slot>
94
+ </Bellows>
95
+ );
96
+ }
61
97
 
62
- The box changed size. Something swapped, grew, or shrank and everything around it moved.
98
+ function UserList({ users, loading }) {
99
+ return (
100
+ <Ensemble
101
+ items={users}
102
+ loading={loading}
103
+ stubCount={5}
104
+ exitDuration={150}
105
+ renderItem={(u, i) => <UserCard key={u.id} user={u} />}
106
+ />
107
+ );
108
+ }
109
+ ```
110
+
111
+ Same stability guarantees. Half the code. Zero configuration.
112
+
113
+ ---
63
114
 
64
- ### StableSlot + Slot
115
+ ## Bellows: spatial stability
65
116
 
66
117
  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
118
 
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.
119
+ The fix: render both at the same time, in the same grid cell, stacked. The cell sizes to the bigger one. Toggle which one is visible via named notes.
69
120
 
70
121
  ```tsx
71
- import { StableSlot, Slot } from "concertina";
72
-
73
- <StableSlot axis="width" className="action-slot">
74
- <Slot active={!isInCart}>
75
- <AddButton />
76
- </Slot>
77
- <Slot active={isInCart}>
78
- <QuantityControl />
79
- </Slot>
80
- </StableSlot>
122
+ import { Bellows, Slot } from "concertina";
123
+
124
+ <Bellows activeNote={isInCart ? "stepper" : "add"} axis="width">
125
+ <Slot note="add"><AddButton /></Slot>
126
+ <Slot note="stepper"><QuantityControl /></Slot>
127
+ </Bellows>
81
128
  ```
82
129
 
83
130
  How it works:
84
131
 
85
- - `display: grid` on the container, `grid-area: 1/1` on all Slots. Everything overlaps in one cell.
86
- - Inactive Slots get `visibility: hidden` (invisible, still in layout flow) and `inert` (no focus, no clicks, no screen reader).
132
+ - `display: grid` on the container, `grid-area: 1/1` on all Slots. Everything overlaps in one cell (the **chamber**).
133
+ - Inactive Slots get the `inert` attribute: no focus, no clicks, no screen reader. CSS handles `visibility: hidden` and `opacity: 0` via the `[inert]` selector.
87
134
  - Each Slot uses `display: flex; flex-direction: column` so content stretches to fill the reserved width.
88
135
  - Zero JS measurement. Pure CSS. Works on the first frame.
89
136
 
90
- #### StableSlot props
137
+ The `note` prop identifies a Slot. The parent `activeNote` determines which one is visible. Explicit `active={true|false}` overrides context when you need manual control. A bare `<Slot>` with neither prop defaults to visible.
138
+
139
+ #### Bellows props
91
140
 
92
141
  | Prop | Type | Default | Description |
93
142
  |------|------|---------|-------------|
143
+ | `activeNote` | `string` | | Which Slot to activate by `note` |
94
144
  | `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to stabilize |
95
145
  | `as` | `ElementType` | `"div"` | HTML element to render |
96
- | `className` | `string` | | Passed to wrapper |
97
146
 
98
147
  #### Slot props
99
148
 
100
- | Prop | Type | Description |
101
- |------|------|-------------|
102
- | `active` | `boolean` | Controls visibility |
103
- | `as` | `ElementType` | HTML element to render. Default `"div"`. |
149
+ | Prop | Type | Default | Description |
150
+ |------|------|---------|-------------|
151
+ | `note` | `string` | | Identifier matched against `activeNote` |
152
+ | `active` | `boolean` | | Manual override (takes precedence over `note`) |
153
+ | `as` | `ElementType` | `"div"` | HTML element to render |
104
154
 
105
- All other HTML attributes are forwarded on both components.
155
+ > `StableSlot` is an alias for `Bellows`. `SlotProps.active` is now optional.
106
156
 
107
157
  #### Rules for correct behavior
108
158
 
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`:
159
+ Parent containers must allow content-based sizing. A Bellows inside `grid-template-columns: 1fr 10rem` is trapped; the fixed column clips it. Use `auto`:
110
160
 
111
161
  ```css
112
- /* StableSlot can't do its job in here */
162
+ /* Bellows can't do its job in here */
113
163
  grid-template-columns: 1fr 10rem;
114
164
 
115
165
  /* now it can size itself */
116
166
  grid-template-columns: 1fr auto;
117
167
  ```
118
168
 
119
- Every independently appearing element needs its own StableSlot. An Undo link that only shows up in one state gets its own wrapper:
120
-
121
- ```tsx
122
- <div className="action-column">
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}>
129
- <button className="undo-link">Undo</button>
130
- </Slot>
131
- </StableSlot>
132
- </div>
133
- ```
134
-
135
- A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
169
+ Every independently appearing element needs its own Bellows. A single Slot inside a Bellows is valid. It reserves the element's space, showing or hiding it without shift.
136
170
 
137
- ### Gigbag
171
+ ---
138
172
 
139
- ```jsx
140
- if (loading) return <Spinner />; // 48px
141
- if (empty) return <EmptyMsg />; // 64px
142
- return <BigTable data={data} />; // 500px+
143
- ```
173
+ ## Hum: temporal stability for text
144
174
 
145
- Three different structures, three different heights. Every transition jumps.
175
+ A line of text loads from an API. You want a shimmer that's the exact width of the text it replaces. Not `100%`, not a guess.
146
176
 
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.
177
+ Hum uses the **Inert Ghost** strategy: it renders your children inside the shimmer but marks them `inert`. The ghost text is invisible but present in the layout, giving the shimmer its intrinsic width. When loading finishes, the ghost is replaced by the real content. No width changes. No shift.
148
178
 
149
179
  ```tsx
150
- import { Gigbag, Warmup } from "concertina";
180
+ import { Hum } from "concertina";
151
181
 
152
- <Gigbag axis="height">
153
- {loading ? (
154
- <Warmup rows={8} columns={3} />
155
- ) : (
156
- <DataTable data={data} />
157
- )}
158
- </Gigbag>
182
+ <h2>
183
+ <Hum loading={!user} className="text-xl font-bold">
184
+ {user?.name}
185
+ </Hum>
186
+ </h2>
159
187
  ```
160
188
 
161
- #### Gigbag props
189
+ The `className` is passed through to the shimmer so `1lh` inherits the correct font metrics. The shimmer is exactly as tall as the text it replaces because `1lh` resolves to the element's computed line-height. Not font-size, not a token, not a guess.
190
+
191
+ #### Hum props
162
192
 
163
193
  | Prop | Type | Default | Description |
164
194
  |------|------|---------|-------------|
165
- | `axis` | `"width"` \| `"height"` \| `"both"` | `"height"` | Which axis to ratchet |
166
- | `as` | `ElementType` | `"div"` | HTML element to render |
195
+ | `loading` | `boolean` | | Show shimmer (true) or children (false) |
196
+ | `as` | `ElementType` | `"span"` | HTML element to render |
197
+ | `className` | `string` | | Applied to both shimmer and content states |
167
198
 
168
- ### useStableSlot
199
+ > `StableText` is an alias for `Hum`.
169
200
 
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.
201
+ ---
171
202
 
172
- ```tsx
173
- import { useStableSlot } from "concertina";
203
+ ## Ensemble: temporal stability for collections
174
204
 
175
- const slot = useStableSlot({ axis: "width" });
205
+ A list loads from an API. You want shimmer rows while loading, then a smooth transition to real items, and the container must never collapse during the swap.
176
206
 
177
- <div ref={slot.ref} style={slot.style} className="price-amount">
178
- {formattedPrice}
179
- </div>
180
- ```
207
+ Ensemble composes `Gigbag` (size ratchet) + `Warmup` (shimmer grid) + `useWarmupExit` (fade transition) into a single component.
181
208
 
182
- | Option | Type | Default | Description |
183
- |--------|------|---------|-------------|
184
- | `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to ratchet |
209
+ ```tsx
210
+ import { Ensemble } from "concertina";
211
+
212
+ <Ensemble
213
+ items={orders}
214
+ loading={isLoading}
215
+ stubCount={5}
216
+ exitDuration={150}
217
+ renderItem={(order, i) => <OrderRow key={order.id} order={order} />}
218
+ />
219
+ ```
185
220
 
186
- Returns `{ ref, style }`. Attach both to the container element.
221
+ The Gigbag ratchet remembers the largest-ever height. When shimmer rows fade out and real items mount, the container never shrinks below its high-water mark. No jump.
187
222
 
188
- ---
223
+ #### Ensemble props
189
224
 
190
- ## Temporal stability
191
-
192
- The content loaded or animated. Something appeared, disappeared, or transitioned between states and the layout jumped during the change.
225
+ | Prop | Type | Description |
226
+ |------|------|-------------|
227
+ | `items` | `T[]` | Data items to render |
228
+ | `loading` | `boolean` | Show shimmer stubs when true |
229
+ | `renderItem` | `(item: T, index: number) => ReactNode` | Render function for each item |
230
+ | `stubCount` | `number` | Number of shimmer placeholder rows |
231
+ | `exitDuration` | `number` | Exit animation duration in ms (match `--concertina-close-duration`) |
232
+ | `as` | `ElementType` | HTML element to render. Default `"div"` |
193
233
 
194
- ### WarmupLine shimmer that inherits text metrics
234
+ > `StableCollection` is an alias for `Ensemble`.
195
235
 
196
- This is the single most important thing in the library and the easiest to get wrong.
236
+ ---
197
237
 
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.
238
+ ## The stability contract
199
239
 
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.
240
+ Nothing moves unless you want it to. Three strategies enforce this:
201
241
 
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.
242
+ **Inert Ghost** (Hum): children render inside the shimmer but are marked `inert`. The ghost provides intrinsic width. CSS hides it via `.concertina-warmup-line > [inert] { visibility: hidden }`. The shimmer is exactly as wide as the content it replaces.
203
243
 
204
- **The `WarmupLine` component exists so you can pass the text styles explicitly:**
244
+ **Chamber** (Bellows): all Slots occupy the same CSS grid cell (`grid-area: 1/1`). The grid auto-sizes to the largest child. Inactive Slots are hidden via `[inert]` but remain in the layout flow, contributing their dimensions. The cell never shrinks.
205
245
 
206
- ```tsx
207
- import { WarmupLine } from "concertina";
246
+ **Ratchet** (Gigbag / Ensemble): a ResizeObserver tracks the maximum-ever size and applies it as `min-height` / `min-width`. The container can grow but never shrinks. Swap a spinner for a table; the container stays at the table's height.
208
247
 
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
- }
248
+ ---
214
249
 
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
- ```
250
+ ## Zero configuration
223
251
 
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.
252
+ CSS is auto-injected via `useInsertionEffect` on first render. A `<style data-concertina>` tag is added to `<head>` once, idempotently. No build plugin, no import statement, no configuration.
225
253
 
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.
254
+ The injection is SSR-safe: it checks `typeof document` and no-ops on the server. For SSR/SSG, keep `import "concertina/styles.css"` in your entry point so styles exist before hydration.
227
255
 
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.
256
+ ---
235
257
 
236
- ### The stub-data pattern
258
+ ## Lower-level tools
237
259
 
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.
260
+ The components above compose these building blocks. Use them directly when you need custom behavior.
239
261
 
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.**
262
+ ### Gigbag
241
263
 
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:
264
+ Size-reserving container. Remembers its largest-ever height (or width, or both) and never shrinks. Uses `contain: layout style` to isolate internal reflow.
243
265
 
244
266
  ```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
+ import { Gigbag, Warmup } from "concertina";
267
268
 
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>
269
+ <Gigbag axis="height">
270
+ {loading ? <Warmup rows={8} columns={3} /> : <DataTable data={data} />}
271
+ </Gigbag>
282
272
  ```
283
273
 
284
- #### TypeScript enforcement
274
+ ### WarmupLine
285
275
 
286
- A discriminated union guarantees you check `_warmup` before accessing real data:
276
+ Single shimmer line. Uses `height: 1lh`, where the CSS `lh` unit resolves to the element's computed line-height. Pass `className` to apply the same text styles as the content this shimmer stands in for.
287
277
 
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;
278
+ ```tsx
279
+ import { WarmupLine } from "concertina";
292
280
 
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
- );
302
- }
281
+ <span className="text-sm text-stone">
282
+ {loading ? <WarmupLine className="text-sm text-stone" /> : `${count} items`}
283
+ </span>
303
284
  ```
304
285
 
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.
286
+ ### Warmup
310
287
 
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
- ```
288
+ Shimmer grid. Renders `rows` (x `columns`) animated bones.
321
289
 
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 |
290
+ | Prop | Type | Description |
291
+ |------|------|-------------|
292
+ | `rows` | `number` | Number of placeholder rows (required) |
293
+ | `columns` | `number` | Columns per row (optional) |
326
294
 
327
295
  ### Glide
328
296
 
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.
330
-
331
- Glide wraps conditional content with enter/exit CSS animations and delays unmount until the exit animation finishes.
297
+ Enter/exit animation wrapper. Delays unmount until the exit animation finishes.
332
298
 
333
299
  ```tsx
334
300
  import { Glide } from "concertina";
@@ -338,45 +304,9 @@ import { Glide } from "concertina";
338
304
  </Glide>
339
305
  ```
340
306
 
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.
342
-
343
- #### Glide props
344
-
345
- | Prop | Type | Default | Description |
346
- |------|------|---------|-------------|
347
- | `show` | `boolean` | | Whether the content is visible |
348
- | `as` | `ElementType` | `"div"` | HTML element to render |
307
+ ### Theming
349
308
 
350
- #### Customizing Glide timing
351
-
352
- ```css
353
- .concertina-glide {
354
- --concertina-glide-duration: 300ms;
355
- --concertina-glide-height: 2000px; /* max-height ceiling for the animation */
356
- }
357
- ```
358
-
359
- ### Warmup grid
360
-
361
- For flat containers where the stub-data pattern is overkill. Renders `rows x columns` animated shimmer bones.
362
-
363
- ```tsx
364
- import { Gigbag, Warmup } from "concertina";
365
-
366
- <Gigbag axis="height">
367
- {loading ? <Warmup rows={8} columns={3} /> : <DataTable data={data} />}
368
- </Gigbag>
369
- ```
370
-
371
- #### Warmup props
372
-
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 |
378
-
379
- #### Theming
309
+ All visual properties are CSS custom properties:
380
310
 
381
311
  ```css
382
312
  .concertina-warmup-line {
@@ -384,41 +314,45 @@ import { Gigbag, Warmup } from "concertina";
384
314
  --concertina-warmup-line-color: #e5e7eb;
385
315
  --concertina-warmup-line-highlight: #f3f4f6;
386
316
  }
387
- .concertina-warmup-bone {
388
- --concertina-warmup-bone-gap: 0.125rem;
389
- --concertina-warmup-bone-padding: 0.375rem 0.5rem;
390
- }
391
317
  .concertina-warmup {
392
318
  --concertina-warmup-gap: 0.75rem;
393
319
  }
320
+ .concertina-glide {
321
+ --concertina-glide-duration: 300ms;
322
+ }
323
+ .concertina-content {
324
+ --concertina-open-duration: 200ms;
325
+ --concertina-close-duration: 150ms;
326
+ }
394
327
  ```
395
328
 
396
- ### Composing spatial + temporal
329
+ ---
397
330
 
398
- Gigbag and Glide solve different problems and they compose:
331
+ ## Advanced primitives
399
332
 
400
- ```tsx
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>
407
- ```
333
+ > These hooks are **deprecated** in favor of the components above. They remain exported for power users who need direct control.
334
+
335
+ | Hook | Use component instead |
336
+ |------|-----------------------|
337
+ | `useStableSlot` | `<Gigbag>` or `<Bellows>` |
338
+ | `useWarmupExit` | `<Ensemble>` |
339
+ | `usePresence` | `<Glide>` |
340
+ | `useTransitionLock` | `<Root>` (accordion) |
341
+ | `useSize` | `<Gigbag>` |
342
+ | `useConcertina` | `<Root>` (accordion) |
343
+
344
+ All hooks are still importable from `"concertina"`. They have `@deprecated` JSDoc tags so your editor will show strikethrough.
408
345
 
409
346
  ---
410
347
 
411
348
  ## Positional stability
412
349
 
413
- The viewport scrolled unexpectedly. You opened an accordion item and the thing you clicked scrolled off the screen.
414
-
415
350
  ### Accordion
416
351
 
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:
352
+ Wraps Radix Accordion with scroll pinning and animation suppression. Lives in its own sub-path:
418
353
 
419
354
  ```tsx
420
355
  import * as Accordion from "concertina/accordion";
421
- import "concertina/styles.css";
422
356
 
423
357
  <Accordion.Root className="my-accordion">
424
358
  {items.map((item) => (
@@ -434,127 +368,11 @@ import "concertina/styles.css";
434
368
 
435
369
  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.
436
370
 
437
- `useExpanded(id)` is a per-item expansion hook. Only re-renders when this specific item's boolean flips:
438
-
439
- ```tsx
440
- import { useExpanded } from "concertina/accordion";
441
-
442
- function MyItem({ item }) {
443
- const expanded = useExpanded(item.id);
444
- // only re-renders when this specific item opens/closes
445
- }
446
- ```
447
-
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
460
-
461
- For cases where you need to manage Radix Accordion directly:
462
-
463
- ```tsx
464
- import { useConcertina } from "concertina/accordion";
465
- import * as RadixAccordion from "@radix-ui/react-accordion";
466
-
467
- const { rootProps, getItemRef } = useConcertina();
468
-
469
- <RadixAccordion.Root type="single" collapsible {...rootProps}>
470
- <RadixAccordion.Item value="a" ref={getItemRef("a")}>
471
- ...
472
- </RadixAccordion.Item>
473
- </RadixAccordion.Root>
474
- ```
475
-
476
- | Property | Type | Description |
477
- |---|---|---|
478
- | `rootProps` | `object` | Spread onto `Accordion.Root`. Contains `value`, `onValueChange`, `data-switching`. |
479
- | `getItemRef` | `(id: string) => RefCallback` | Attach to each `Accordion.Item` |
480
- | `value` | `string` | Currently expanded item (empty string when collapsed) |
481
- | `switching` | `boolean` | True during a switch between items |
371
+ `useExpanded(id)` is a per-item expansion hook. It only re-renders when this specific item's boolean flips.
482
372
 
483
373
  ### pinToScrollTop
484
374
 
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);
491
- ```
492
-
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
- ```
375
+ Scrolls an element to the top of its nearest scrollable ancestor. Only touches `scrollTop` on that one container. Never cascades to the viewport. Automatically accounts for sticky headers.
558
376
 
559
377
  ---
560
378
 
@@ -562,29 +380,35 @@ useScrollPin(() => itemRefs.get(activeId), [activeId]);
562
380
 
563
381
  | Problem | Tool |
564
382
  |---------|------|
565
- | Two variants swap in one slot | StableSlot + Slot |
566
- | Text changes width unpredictably | useStableSlot (or CSS `tabular-nums` for numbers) |
383
+ | Two variants swap in one slot | Bellows + Slot |
384
+ | Line of text loading from API | Hum |
385
+ | List loading from API | Ensemble |
567
386
  | Spinner replaced by loaded content | Gigbag + Warmup |
568
- | Accordion/table loading skeleton | Stub data through same render path + WarmupLine |
387
+ | Accordion/table shimmer rows | Stub data + WarmupLine (wrapper-once pattern) |
569
388
  | Panel mounts/unmounts conditionally | Glide |
570
- | Shimmer lines that match text height | WarmupLine (uses `1lh`) |
571
- | Shimmer-to-content exit animation | useWarmupExit |
572
389
  | Accordion with scroll pinning | Accordion.Root + Item + Content |
573
- | Custom scroll correction | pinToScrollTop or useScrollPin |
574
390
 
575
391
  ---
576
392
 
577
- ## Roadmap
393
+ ## Browser support
394
+
395
+ Concertina targets modern browsers. The minimum floor is set by `1lh` (CSS line-height unit):
578
396
 
579
- Stability problems concertina could address in future versions:
397
+ - Chrome 109+
398
+ - Firefox 120+
399
+ - Safari 17.2+
580
400
 
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.
401
+ The `inert` attribute shipped before `1lh` in every browser. No polyfills. No fallbacks. No bloat.
582
402
 
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.
403
+ ---
404
+
405
+ ## Roadmap
584
406
 
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.
407
+ - **Scroll anchoring**: maintain scroll position when content above a target changes.
408
+ - **Media reservation**: reserve space for images/video via `aspect-ratio` before load.
409
+ - **Focus stability**: trap focus to nearest surviving ancestor when DOM mutations remove the focused element.
586
410
 
587
- These are proposals, not commitments. If any of these would unblock your project, open an issue.
411
+ These are proposals, not commitments. If any would unblock your project, open an issue.
588
412
 
589
413
  ## License
590
414