concertina 0.8.1 → 0.9.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
@@ -14,21 +14,29 @@
14
14
 
15
15
  <p align="center"><b>47 tests</b> &middot; 716 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 400 pixels. 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 is a different expression of this one idea:
30
30
 
31
- Concertina has a small primitive for each.
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 |
38
+
39
+ 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
40
 
33
41
  ## Install
34
42
 
@@ -41,9 +49,13 @@ import * as Concertina from "concertina";
41
49
  import "concertina/styles.css";
42
50
  ```
43
51
 
44
- ## Variant switching: StableSlot + Slot
52
+ ---
53
+
54
+ ## StableSlot + Slot
45
55
 
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.
56
+ 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
+
58
+ 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
59
 
48
60
  ```tsx
49
61
  <Concertina.StableSlot axis="width" className="action-slot">
@@ -108,11 +120,11 @@ Every independently appearing element needs its own StableSlot. An Undo link tha
108
120
  </div>
109
121
  ```
110
122
 
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.
123
+ A single Slot inside a StableSlot is valid. It reserves the element's space, showing or hiding it without shift.
112
124
 
113
- ## Progressive loading: Gigbag + Warmup
125
+ ---
114
126
 
115
- You've seen this a thousand times:
127
+ ## Gigbag + Warmup
116
128
 
117
129
  ```jsx
118
130
  if (loading) return <Spinner />; // 48px
@@ -120,11 +132,11 @@ if (empty) return <EmptyMsg />; // 64px
120
132
  return <BigTable data={data} />; // 500px+
121
133
  ```
122
134
 
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.
135
+ Three different structures, three different heights. Every transition jumps.
124
136
 
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.
137
+ 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.
126
138
 
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.
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.
128
140
 
129
141
  ```tsx
130
142
  <Concertina.Gigbag axis="height">
@@ -136,7 +148,7 @@ Warmup is a CSS-only shimmer grid that goes inside the Gigbag while you're loadi
136
148
  </Concertina.Gigbag>
137
149
  ```
138
150
 
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.
151
+ The Gigbag ratchets to whichever child is taller. On subsequent re-fetches it holds at the table's height instead of collapsing back.
140
152
 
141
153
  ### Gigbag props
142
154
 
@@ -155,7 +167,7 @@ The Gigbag ratchets to whichever is taller. On subsequent re-fetches it holds at
155
167
 
156
168
  ### Theming Warmup
157
169
 
158
- All dimensions are CSS custom properties. Override them to match your app:
170
+ All dimensions are CSS custom properties:
159
171
 
160
172
  ```css
161
173
  .concertina-warmup {
@@ -166,9 +178,186 @@ All dimensions are CSS custom properties. Override them to match your app:
166
178
  }
167
179
  ```
168
180
 
169
- ## Conditional content: Glide
181
+ ### Shimmer dimensions come from the text styles, not from the shimmer
182
+
183
+ This is the single most important thing in the library and the easiest to get wrong.
184
+
185
+ 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.
186
+
187
+ 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.
188
+
189
+ 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.
190
+
191
+ **The `WarmupLine` component exists so you can pass the text styles explicitly:**
192
+
193
+ ```tsx
194
+ // Toolbar — no parent provides text styles, so pass them directly
195
+ {loading
196
+ ? <Concertina.WarmupLine className="text-sm text-stone flex-1" />
197
+ : <span className="text-sm text-stone">{count} customers</span>
198
+ }
199
+
200
+ // Grid cell — parent wrapper provides text styles via inheritance
201
+ <span className="table-val-primary">
202
+ {row._warmup
203
+ ? <div className="concertina-warmup-line" />
204
+ : row.name
205
+ }
206
+ </span>
207
+ ```
208
+
209
+ 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
+
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.
220
+
221
+ ---
222
+
223
+ ## The stub-data pattern
224
+
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.
226
+
227
+ 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
+
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:
232
+
233
+ ```tsx
234
+ // Stub data — same shape as real rows
235
+ const STUB_ROWS = Array.from({ length: 8 }, (_, i) => ({
236
+ _warmup: true as const,
237
+ id: `warmup-${i}`,
238
+ name: null,
239
+ items: [],
240
+ }));
241
+
242
+ // Cell renderer — wrapper defined once, content varies inside it
243
+ cell: ({ row }) => (
244
+ <span className="table-val-primary">
245
+ {row.original._warmup
246
+ ? <div className="concertina-warmup-line" />
247
+ : row.original.name
248
+ }
249
+ </span>
250
+ )
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
+ ```
270
+
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.
272
+
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.
276
+
277
+ ```tsx
278
+ // WRONG — wrapper duplicated, will drift apart silently
279
+ if (row.original._warmup) {
280
+ return <span className="table-val-money"><div className="concertina-warmup-line" /></span>;
281
+ }
282
+ return <span className="table-val-money">${total}</span>;
283
+
284
+ // RIGHT — wrapper defined once, content switches inside it
285
+ <span className="table-val-money">
286
+ {row.original._warmup
287
+ ? <div className="concertina-warmup-line" />
288
+ : `$${total}`
289
+ }
290
+ </span>
291
+ ```
292
+
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.
170
294
 
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.
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)
306
+
307
+ A discriminated union guarantees you check `_warmup` before accessing real data:
308
+
309
+ ```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
+
322
+ type Row = WarmupRow | RealRow;
323
+ ```
324
+
325
+ The compiler forces the branch:
326
+
327
+ ```ts
328
+ 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
+ return (
334
+ <span className="table-val-primary">
335
+ {row._warmup
336
+ ? <div className="concertina-warmup-line" />
337
+ : row.name // TS narrows to RealRow here
338
+ }
339
+ </span>
340
+ );
341
+ }
342
+ ```
343
+
344
+ You can't access `row.name` without checking `_warmup` first. A cell renderer that forgets the check fails at build time.
345
+
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:
347
+
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
+ ```
353
+
354
+ TypeScript prevents you from forgetting the branch. The wrapper-once pattern prevents you from forgetting the wrapper. Use both.
355
+
356
+ ---
357
+
358
+ ## Glide
359
+
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.
172
361
 
173
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.
174
363
 
@@ -196,7 +385,9 @@ When `show` goes true, children mount with a `concertina-glide-entering` class.
196
385
  }
197
386
  ```
198
387
 
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.
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.
389
+
390
+ ---
200
391
 
201
392
  ## Composing them
202
393
 
@@ -211,7 +402,9 @@ Gigbag and Glide solve different problems and they compose:
211
402
  </Concertina.Glide>
212
403
  ```
213
404
 
214
- ## Dynamic text: useStableSlot
405
+ ---
406
+
407
+ ## useStableSlot
215
408
 
216
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.
217
410
 
@@ -229,7 +422,7 @@ const slot = Concertina.useStableSlot({ axis: "width" });
229
422
 
230
423
  Returns `{ ref, style }`. Attach both to the container element.
231
424
 
232
- ## Animation suppression: useTransitionLock
425
+ ## useTransitionLock
233
426
 
234
427
  Suppresses CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
235
428
 
@@ -246,7 +439,7 @@ const handleChange = (newValue) => {
246
439
  </div>
247
440
  ```
248
441
 
249
- ## Scroll pinning: pinToScrollTop
442
+ ## pinToScrollTop
250
443
 
251
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.
252
445
 
@@ -254,6 +447,8 @@ Scrolls an element to the top of its nearest scrollable ancestor. Only touches `
254
447
  Concertina.pinToScrollTop(element);
255
448
  ```
256
449
 
450
+ ---
451
+
257
452
  ## Accordion
258
453
 
259
454
  Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`.
@@ -314,6 +509,8 @@ const { rootProps, getItemRef } = Concertina.useConcertina();
314
509
 
315
510
  If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to the scroll container.
316
511
 
512
+ ---
513
+
317
514
  ## Picking the right tool
318
515
 
319
516
  | Problem | Tool |
@@ -321,6 +518,7 @@ If items near the bottom can't scroll to the top, add `padding-bottom: 50vh` to
321
518
  | Two variants swap in one slot | StableSlot + Slot |
322
519
  | Text changes width unpredictably | useStableSlot (or CSS `tabular-nums` for numbers) |
323
520
  | Spinner replaced by loaded content | Gigbag + Warmup |
521
+ | Accordion/table loading skeleton | Stub data through same render path |
324
522
  | Panel mounts/unmounts conditionally | Glide |
325
523
  | Accordion with scroll pinning | Root + Item + Content |
326
524
 
package/dist/index.cjs CHANGED
@@ -42,6 +42,7 @@ __export(index_exports, {
42
42
  StableSlot: () => StableSlot,
43
43
  Trigger: () => Trigger2,
44
44
  Warmup: () => Warmup,
45
+ WarmupLine: () => WarmupLine,
45
46
  pinToScrollTop: () => pinToScrollTop,
46
47
  useConcertina: () => useConcertina,
47
48
  useExpanded: () => useExpanded,
@@ -49,7 +50,8 @@ __export(index_exports, {
49
50
  useScrollPin: () => useScrollPin,
50
51
  useSize: () => useSize,
51
52
  useStableSlot: () => useStableSlot,
52
- useTransitionLock: () => useTransitionLock
53
+ useTransitionLock: () => useTransitionLock,
54
+ useWarmupExit: () => useWarmupExit
53
55
  });
54
56
  module.exports = __toCommonJS(index_exports);
55
57
 
@@ -1306,34 +1308,41 @@ var Warmup = (0, import_react15.forwardRef)(
1306
1308
  function Warmup2({ rows = 3, columns = 1, as: Tag = "div", className, children, ...props }, ref) {
1307
1309
  const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
1308
1310
  const cells = Array.from({ length: rows * columns }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "concertina-warmup-bone", children: [
1309
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line concertina-warmup-line-short" }),
1310
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line concertina-warmup-line-long" })
1311
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line" }),
1312
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "concertina-warmup-line" })
1311
1313
  ] }, i));
1312
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
1314
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
1313
1315
  Tag,
1314
1316
  {
1315
1317
  ref,
1316
1318
  className: merged,
1317
1319
  style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
1318
1320
  ...props,
1319
- children: [
1320
- children,
1321
- cells
1322
- ]
1321
+ children: cells
1323
1322
  }
1324
1323
  );
1325
1324
  }
1326
1325
  );
1327
1326
 
1327
+ // src/components/warmup-line.tsx
1328
+ var import_react16 = require("react");
1329
+ var import_jsx_runtime16 = require("react/jsx-runtime");
1330
+ var WarmupLine = (0, import_react16.forwardRef)(
1331
+ function WarmupLine2({ as: Tag = "div", className, ...props }, ref) {
1332
+ const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
1333
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Tag, { ref, className: merged, ...props });
1334
+ }
1335
+ );
1336
+
1328
1337
  // src/components/glide.tsx
1329
- var import_react17 = require("react");
1338
+ var import_react18 = require("react");
1330
1339
 
1331
1340
  // src/primitives/use-presence.ts
1332
- var import_react16 = require("react");
1341
+ var import_react17 = require("react");
1333
1342
  function usePresence2(show) {
1334
- const [mounted, setMounted] = (0, import_react16.useState)(show);
1335
- const [phase, setPhase] = (0, import_react16.useState)(show ? "entered" : "exiting");
1336
- (0, import_react16.useEffect)(() => {
1343
+ const [mounted, setMounted] = (0, import_react17.useState)(show);
1344
+ const [phase, setPhase] = (0, import_react17.useState)(show ? "entered" : "exiting");
1345
+ (0, import_react17.useEffect)(() => {
1337
1346
  if (show) {
1338
1347
  setMounted(true);
1339
1348
  setPhase("entering");
@@ -1341,7 +1350,7 @@ function usePresence2(show) {
1341
1350
  setPhase("exiting");
1342
1351
  }
1343
1352
  }, [show]);
1344
- const onAnimationEnd = (0, import_react16.useCallback)(
1353
+ const onAnimationEnd = (0, import_react17.useCallback)(
1345
1354
  (e) => {
1346
1355
  if (e.target !== e.currentTarget) return;
1347
1356
  if (phase === "entering") setPhase("entered");
@@ -1353,14 +1362,14 @@ function usePresence2(show) {
1353
1362
  }
1354
1363
 
1355
1364
  // src/components/glide.tsx
1356
- var import_jsx_runtime16 = require("react/jsx-runtime");
1357
- var Glide = (0, import_react17.forwardRef)(
1365
+ var import_jsx_runtime17 = require("react/jsx-runtime");
1366
+ var Glide = (0, import_react18.forwardRef)(
1358
1367
  function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
1359
1368
  const { mounted, phase, onAnimationEnd } = usePresence2(show);
1360
1369
  if (!mounted) return null;
1361
1370
  const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
1362
1371
  const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
1363
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1372
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
1364
1373
  Tag,
1365
1374
  {
1366
1375
  ref,
@@ -1374,11 +1383,11 @@ var Glide = (0, import_react17.forwardRef)(
1374
1383
  );
1375
1384
 
1376
1385
  // src/primitives/use-size.ts
1377
- var import_react18 = require("react");
1386
+ var import_react19 = require("react");
1378
1387
  function useSize() {
1379
- const [size, setSize] = (0, import_react18.useState)({ width: 0, height: 0 });
1380
- const observerRef = (0, import_react18.useRef)(null);
1381
- const ref = (0, import_react18.useCallback)((el) => {
1388
+ const [size, setSize] = (0, import_react19.useState)({ width: 0, height: 0 });
1389
+ const observerRef = (0, import_react19.useRef)(null);
1390
+ const ref = (0, import_react19.useCallback)((el) => {
1382
1391
  if (observerRef.current) {
1383
1392
  observerRef.current.disconnect();
1384
1393
  observerRef.current = null;
@@ -1406,13 +1415,35 @@ function useSize() {
1406
1415
  return { ref, size };
1407
1416
  }
1408
1417
 
1418
+ // src/primitives/use-warmup-exit.ts
1419
+ var import_react20 = require("react");
1420
+ function useWarmupExit(loading, duration = 150) {
1421
+ const [exiting, setExiting] = (0, import_react20.useState)(false);
1422
+ const prevLoading = (0, import_react20.useRef)(loading);
1423
+ (0, import_react20.useEffect)(() => {
1424
+ if (prevLoading.current && !loading) {
1425
+ setExiting(true);
1426
+ const id = setTimeout(() => setExiting(false), duration);
1427
+ prevLoading.current = loading;
1428
+ return () => clearTimeout(id);
1429
+ }
1430
+ prevLoading.current = loading;
1431
+ }, [loading, duration]);
1432
+ return {
1433
+ /** True during loading AND during exit animation — use for data selection */
1434
+ showWarmup: loading || exiting,
1435
+ /** True only during the exit animation — use for CSS class */
1436
+ exiting
1437
+ };
1438
+ }
1439
+
1409
1440
  // src/accordion/use-concertina.ts
1410
- var import_react19 = require("react");
1441
+ var import_react21 = require("react");
1411
1442
  function useConcertina() {
1412
- const [value, setValue] = (0, import_react19.useState)("");
1413
- const [switching, setSwitching] = (0, import_react19.useState)(false);
1414
- const itemRefs = (0, import_react19.useRef)({});
1415
- const onValueChange = (0, import_react19.useCallback)(
1443
+ const [value, setValue] = (0, import_react21.useState)("");
1444
+ const [switching, setSwitching] = (0, import_react21.useState)(false);
1445
+ const itemRefs = (0, import_react21.useRef)({});
1446
+ const onValueChange = (0, import_react21.useCallback)(
1416
1447
  (newValue) => {
1417
1448
  if (!newValue) {
1418
1449
  setSwitching(false);
@@ -1424,14 +1455,14 @@ function useConcertina() {
1424
1455
  },
1425
1456
  [value]
1426
1457
  );
1427
- (0, import_react19.useLayoutEffect)(() => {
1458
+ (0, import_react21.useLayoutEffect)(() => {
1428
1459
  if (!value) return;
1429
1460
  pinToScrollTop(itemRefs.current[value]);
1430
1461
  }, [value]);
1431
- (0, import_react19.useEffect)(() => {
1462
+ (0, import_react21.useEffect)(() => {
1432
1463
  if (switching) setSwitching(false);
1433
1464
  }, [switching]);
1434
- const getItemRef = (0, import_react19.useCallback)(
1465
+ const getItemRef = (0, import_react21.useCallback)(
1435
1466
  (id) => (el) => {
1436
1467
  itemRefs.current[id] = el;
1437
1468
  },
@@ -1458,6 +1489,7 @@ function useConcertina() {
1458
1489
  StableSlot,
1459
1490
  Trigger,
1460
1491
  Warmup,
1492
+ WarmupLine,
1461
1493
  pinToScrollTop,
1462
1494
  useConcertina,
1463
1495
  useExpanded,
@@ -1465,5 +1497,6 @@ function useConcertina() {
1465
1497
  useScrollPin,
1466
1498
  useSize,
1467
1499
  useStableSlot,
1468
- useTransitionLock
1500
+ useTransitionLock,
1501
+ useWarmupExit
1469
1502
  });
package/dist/index.d.cts CHANGED
@@ -105,14 +105,28 @@ interface WarmupProps extends HTMLAttributes<HTMLElement> {
105
105
  * dimensions of the real content. Pair with <Gigbag> so the
106
106
  * container ratchets to the larger of placeholder vs real content.
107
107
  *
108
- * Pass children to inject structure before the generated bones
109
- * (e.g. a toolbar placeholder that spans all grid columns).
110
- *
111
108
  * All dimensions are CSS custom properties — consuming apps theme
112
109
  * without forking.
113
110
  */
114
111
  declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
115
112
 
113
+ interface WarmupLineProps extends HTMLAttributes<HTMLElement> {
114
+ /** HTML element to render. Default: "div". */
115
+ as?: ElementType;
116
+ }
117
+ /**
118
+ * Single shimmer line — CSS-aware placeholder for text.
119
+ *
120
+ * Sizes itself from inherited text styles (font-size, line-height)
121
+ * via an invisible `::before` character. No explicit height — the
122
+ * shimmer IS one line of text in whatever font context it lives in.
123
+ *
124
+ * Pass `className` to apply the same text styles as the content
125
+ * this shimmer stands in for. Width fills the container by default
126
+ * (block element).
127
+ */
128
+ declare const WarmupLine: react.ForwardRefExoticComponent<WarmupLineProps & react.RefAttributes<HTMLElement>>;
129
+
116
130
  interface GlideProps extends HTMLAttributes<HTMLElement> {
117
131
  /** Whether the content is visible. */
118
132
  show: boolean;
@@ -234,6 +248,23 @@ declare function useTransitionLock(): {
234
248
  */
235
249
  declare function pinToScrollTop(el: HTMLElement | null): void;
236
250
 
251
+ /**
252
+ * Manages the warmup → content transition for stub-data tables.
253
+ *
254
+ * When `loading` transitions from true to false, holds stub data
255
+ * for one animation cycle so warmup lines can fade out before
256
+ * real content mounts.
257
+ *
258
+ * @param loading - Whether data is still loading
259
+ * @param duration - Exit animation duration in ms (default 150, matches CSS)
260
+ */
261
+ declare function useWarmupExit(loading: boolean, duration?: number): {
262
+ /** True during loading AND during exit animation — use for data selection */
263
+ showWarmup: boolean;
264
+ /** True only during the exit animation — use for CSS class */
265
+ exiting: boolean;
266
+ };
267
+
237
268
  interface ConcertinaRootProps {
238
269
  value: string;
239
270
  onValueChange: (value: string) => void;
@@ -263,4 +294,4 @@ interface UseConcertinaReturn {
263
294
  */
264
295
  declare function useConcertina(): UseConcertinaReturn;
265
296
 
266
- export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock };
297
+ export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useWarmupExit };
package/dist/index.d.ts CHANGED
@@ -105,14 +105,28 @@ interface WarmupProps extends HTMLAttributes<HTMLElement> {
105
105
  * dimensions of the real content. Pair with <Gigbag> so the
106
106
  * container ratchets to the larger of placeholder vs real content.
107
107
  *
108
- * Pass children to inject structure before the generated bones
109
- * (e.g. a toolbar placeholder that spans all grid columns).
110
- *
111
108
  * All dimensions are CSS custom properties — consuming apps theme
112
109
  * without forking.
113
110
  */
114
111
  declare const Warmup: react.ForwardRefExoticComponent<WarmupProps & react.RefAttributes<HTMLElement>>;
115
112
 
113
+ interface WarmupLineProps extends HTMLAttributes<HTMLElement> {
114
+ /** HTML element to render. Default: "div". */
115
+ as?: ElementType;
116
+ }
117
+ /**
118
+ * Single shimmer line — CSS-aware placeholder for text.
119
+ *
120
+ * Sizes itself from inherited text styles (font-size, line-height)
121
+ * via an invisible `::before` character. No explicit height — the
122
+ * shimmer IS one line of text in whatever font context it lives in.
123
+ *
124
+ * Pass `className` to apply the same text styles as the content
125
+ * this shimmer stands in for. Width fills the container by default
126
+ * (block element).
127
+ */
128
+ declare const WarmupLine: react.ForwardRefExoticComponent<WarmupLineProps & react.RefAttributes<HTMLElement>>;
129
+
116
130
  interface GlideProps extends HTMLAttributes<HTMLElement> {
117
131
  /** Whether the content is visible. */
118
132
  show: boolean;
@@ -234,6 +248,23 @@ declare function useTransitionLock(): {
234
248
  */
235
249
  declare function pinToScrollTop(el: HTMLElement | null): void;
236
250
 
251
+ /**
252
+ * Manages the warmup → content transition for stub-data tables.
253
+ *
254
+ * When `loading` transitions from true to false, holds stub data
255
+ * for one animation cycle so warmup lines can fade out before
256
+ * real content mounts.
257
+ *
258
+ * @param loading - Whether data is still loading
259
+ * @param duration - Exit animation duration in ms (default 150, matches CSS)
260
+ */
261
+ declare function useWarmupExit(loading: boolean, duration?: number): {
262
+ /** True during loading AND during exit animation — use for data selection */
263
+ showWarmup: boolean;
264
+ /** True only during the exit animation — use for CSS class */
265
+ exiting: boolean;
266
+ };
267
+
237
268
  interface ConcertinaRootProps {
238
269
  value: string;
239
270
  onValueChange: (value: string) => void;
@@ -263,4 +294,4 @@ interface UseConcertinaReturn {
263
294
  */
264
295
  declare function useConcertina(): UseConcertinaReturn;
265
296
 
266
- export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock };
297
+ export { type Axis, ConcertinaContext, type ConcertinaRootProps, ConcertinaStore, Content, Gigbag, type GigbagProps, Glide, type GlideProps, Item, type Phase, Root, type Size, Slot, type SlotProps, StableSlot, type StableSlotProps, type UseConcertinaReturn, type UsePresenceReturn, Warmup, WarmupLine, type WarmupLineProps, type WarmupProps, pinToScrollTop, useConcertina, useExpanded, usePresence, useScrollPin, useSize, useStableSlot, useTransitionLock, useWarmupExit };
package/dist/index.js CHANGED
@@ -1266,28 +1266,35 @@ var Warmup = forwardRef10(
1266
1266
  function Warmup2({ rows = 3, columns = 1, as: Tag = "div", className, children, ...props }, ref) {
1267
1267
  const merged = className ? `concertina-warmup ${className}` : "concertina-warmup";
1268
1268
  const cells = Array.from({ length: rows * columns }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "concertina-warmup-bone", children: [
1269
- /* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line concertina-warmup-line-short" }),
1270
- /* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line concertina-warmup-line-long" })
1269
+ /* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line" }),
1270
+ /* @__PURE__ */ jsx14("div", { className: "concertina-warmup-line" })
1271
1271
  ] }, i));
1272
- return /* @__PURE__ */ jsxs(
1272
+ return /* @__PURE__ */ jsx14(
1273
1273
  Tag,
1274
1274
  {
1275
1275
  ref,
1276
1276
  className: merged,
1277
1277
  style: { gridTemplateColumns: `repeat(${columns}, 1fr)` },
1278
1278
  ...props,
1279
- children: [
1280
- children,
1281
- cells
1282
- ]
1279
+ children: cells
1283
1280
  }
1284
1281
  );
1285
1282
  }
1286
1283
  );
1287
1284
 
1285
+ // src/components/warmup-line.tsx
1286
+ import { forwardRef as forwardRef11 } from "react";
1287
+ import { jsx as jsx15 } from "react/jsx-runtime";
1288
+ var WarmupLine = forwardRef11(
1289
+ function WarmupLine2({ as: Tag = "div", className, ...props }, ref) {
1290
+ const merged = className ? `concertina-warmup-line ${className}` : "concertina-warmup-line";
1291
+ return /* @__PURE__ */ jsx15(Tag, { ref, className: merged, ...props });
1292
+ }
1293
+ );
1294
+
1288
1295
  // src/components/glide.tsx
1289
1296
  import {
1290
- forwardRef as forwardRef11
1297
+ forwardRef as forwardRef12
1291
1298
  } from "react";
1292
1299
 
1293
1300
  // src/primitives/use-presence.ts
@@ -1319,14 +1326,14 @@ function usePresence2(show) {
1319
1326
  }
1320
1327
 
1321
1328
  // src/components/glide.tsx
1322
- import { jsx as jsx15 } from "react/jsx-runtime";
1323
- var Glide = forwardRef11(
1329
+ import { jsx as jsx16 } from "react/jsx-runtime";
1330
+ var Glide = forwardRef12(
1324
1331
  function Glide2({ show, as: Tag = "div", className, children, ...props }, ref) {
1325
1332
  const { mounted, phase, onAnimationEnd } = usePresence2(show);
1326
1333
  if (!mounted) return null;
1327
1334
  const phaseClass = phase === "entering" ? "concertina-glide-entering" : phase === "exiting" ? "concertina-glide-exiting" : "";
1328
1335
  const merged = ["concertina-glide", phaseClass, className].filter(Boolean).join(" ");
1329
- return /* @__PURE__ */ jsx15(
1336
+ return /* @__PURE__ */ jsx16(
1330
1337
  Tag,
1331
1338
  {
1332
1339
  ref,
@@ -1372,18 +1379,40 @@ function useSize() {
1372
1379
  return { ref, size };
1373
1380
  }
1374
1381
 
1382
+ // src/primitives/use-warmup-exit.ts
1383
+ import { useState as useState9, useEffect as useEffect7, useRef as useRef8 } from "react";
1384
+ function useWarmupExit(loading, duration = 150) {
1385
+ const [exiting, setExiting] = useState9(false);
1386
+ const prevLoading = useRef8(loading);
1387
+ useEffect7(() => {
1388
+ if (prevLoading.current && !loading) {
1389
+ setExiting(true);
1390
+ const id = setTimeout(() => setExiting(false), duration);
1391
+ prevLoading.current = loading;
1392
+ return () => clearTimeout(id);
1393
+ }
1394
+ prevLoading.current = loading;
1395
+ }, [loading, duration]);
1396
+ return {
1397
+ /** True during loading AND during exit animation — use for data selection */
1398
+ showWarmup: loading || exiting,
1399
+ /** True only during the exit animation — use for CSS class */
1400
+ exiting
1401
+ };
1402
+ }
1403
+
1375
1404
  // src/accordion/use-concertina.ts
1376
1405
  import {
1377
- useState as useState9,
1406
+ useState as useState10,
1378
1407
  useCallback as useCallback11,
1379
- useRef as useRef8,
1408
+ useRef as useRef9,
1380
1409
  useLayoutEffect as useLayoutEffect4,
1381
- useEffect as useEffect7
1410
+ useEffect as useEffect8
1382
1411
  } from "react";
1383
1412
  function useConcertina() {
1384
- const [value, setValue] = useState9("");
1385
- const [switching, setSwitching] = useState9(false);
1386
- const itemRefs = useRef8({});
1413
+ const [value, setValue] = useState10("");
1414
+ const [switching, setSwitching] = useState10(false);
1415
+ const itemRefs = useRef9({});
1387
1416
  const onValueChange = useCallback11(
1388
1417
  (newValue) => {
1389
1418
  if (!newValue) {
@@ -1400,7 +1429,7 @@ function useConcertina() {
1400
1429
  if (!value) return;
1401
1430
  pinToScrollTop(itemRefs.current[value]);
1402
1431
  }, [value]);
1403
- useEffect7(() => {
1432
+ useEffect8(() => {
1404
1433
  if (switching) setSwitching(false);
1405
1434
  }, [switching]);
1406
1435
  const getItemRef = useCallback11(
@@ -1429,6 +1458,7 @@ export {
1429
1458
  StableSlot,
1430
1459
  Trigger2 as Trigger,
1431
1460
  Warmup,
1461
+ WarmupLine,
1432
1462
  pinToScrollTop,
1433
1463
  useConcertina,
1434
1464
  useExpanded,
@@ -1436,5 +1466,6 @@ export {
1436
1466
  useScrollPin,
1437
1467
  useSize,
1438
1468
  useStableSlot,
1439
- useTransitionLock
1469
+ useTransitionLock,
1470
+ useWarmupExit
1440
1471
  };
package/dist/styles.css CHANGED
@@ -87,6 +87,7 @@
87
87
  }
88
88
 
89
89
  .concertina-warmup-line {
90
+ height: 1lh;
90
91
  border-radius: var(--concertina-warmup-line-radius, 0.125rem);
91
92
  background: linear-gradient(
92
93
  90deg,
@@ -98,21 +99,13 @@
98
99
  animation: concertina-shimmer 1.5s ease-in-out infinite;
99
100
  }
100
101
 
101
- .concertina-warmup-line-short {
102
- height: var(--concertina-warmup-line-short, 0.5rem);
103
- width: 40%;
104
- }
105
-
106
- .concertina-warmup-line-long {
107
- height: var(--concertina-warmup-line-long, 0.75rem);
108
- width: 75%;
109
- }
110
102
 
111
103
  @keyframes concertina-shimmer {
112
104
  0% { background-position: 200% 0; }
113
105
  100% { background-position: -200% 0; }
114
106
  }
115
107
 
108
+
116
109
  /* Glide — enter/exit animation wrapper. */
117
110
  .concertina-glide {
118
111
  --concertina-glide-duration: 200ms;
@@ -136,6 +129,16 @@
136
129
  to { opacity: 0; max-height: 0; overflow: hidden; }
137
130
  }
138
131
 
132
+ /* Warmup exit — fade out shimmer lines before content mounts.
133
+ Applied by useWarmupExit() via className on the grid container. */
134
+ .concertina-warmup-exiting .concertina-warmup-line {
135
+ animation: concertina-warmup-exit var(--concertina-close-duration, 150ms) ease-out forwards;
136
+ }
137
+
138
+ @keyframes concertina-warmup-exit {
139
+ to { opacity: 0; }
140
+ }
141
+
139
142
  /* Respect reduced-motion preferences.
140
143
  Disables all animations — accordion open/close, shimmer, and glide enter/exit.
141
144
  Layout changes still happen instantly so functionality is preserved. */
@@ -144,7 +147,8 @@
144
147
  .concertina-content[data-state="closed"],
145
148
  .concertina-glide-entering,
146
149
  .concertina-glide-exiting,
147
- .concertina-warmup-line {
150
+ .concertina-warmup-line,
151
+ .concertina-warmup-exiting .concertina-warmup-line {
148
152
  animation-duration: 0s !important;
149
153
  }
150
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concertina",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "React toolkit for layout stability.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",