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 +217 -393
- package/dist/accordion.cjs +212 -9
- package/dist/accordion.d.cts +2 -0
- package/dist/accordion.d.ts +2 -0
- package/dist/accordion.js +1 -1
- package/dist/{chunk-XPZ74VND.js → chunk-4DDADLSW.js} +219 -14
- package/dist/index.cjs +346 -92
- package/dist/index.d.cts +74 -18
- package/dist/index.d.ts +74 -18
- package/dist/index.js +130 -73
- package/dist/styles.css +20 -0
- package/package.json +1 -1
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>
|
|
15
|
+
<p align="center"><b>92 tests</b> · ~1200 lines of source · 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
36
|
+
import { Bellows, Slot, Hum, Ensemble } from "concertina";
|
|
37
|
+
// That's it. No CSS import required.
|
|
38
|
+
```
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
<Slot
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
| `
|
|
103
|
-
| `
|
|
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
|
-
|
|
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
|
|
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
|
-
/*
|
|
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
|
|
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
|
-
|
|
171
|
+
---
|
|
138
172
|
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
180
|
+
import { Hum } from "concertina";
|
|
151
181
|
|
|
152
|
-
<
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
166
|
-
| `as` | `ElementType` | `"
|
|
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
|
-
|
|
199
|
+
> `StableText` is an alias for `Hum`.
|
|
169
200
|
|
|
170
|
-
|
|
201
|
+
---
|
|
171
202
|
|
|
172
|
-
|
|
173
|
-
import { useStableSlot } from "concertina";
|
|
203
|
+
## Ensemble: temporal stability for collections
|
|
174
204
|
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
{formattedPrice}
|
|
179
|
-
</div>
|
|
180
|
-
```
|
|
207
|
+
Ensemble composes `Gigbag` (size ratchet) + `Warmup` (shimmer grid) + `useWarmupExit` (fade transition) into a single component.
|
|
181
208
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
234
|
+
> `StableCollection` is an alias for `Ensemble`.
|
|
195
235
|
|
|
196
|
-
|
|
236
|
+
---
|
|
197
237
|
|
|
198
|
-
|
|
238
|
+
## The stability contract
|
|
199
239
|
|
|
200
|
-
|
|
240
|
+
Nothing moves unless you want it to. Three strategies enforce this:
|
|
201
241
|
|
|
202
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
+
## Lower-level tools
|
|
237
259
|
|
|
238
|
-
|
|
260
|
+
The components above compose these building blocks. Use them directly when you need custom behavior.
|
|
239
261
|
|
|
240
|
-
|
|
262
|
+
### Gigbag
|
|
241
263
|
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
+
### WarmupLine
|
|
285
275
|
|
|
286
|
-
|
|
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
|
-
```
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
323
|
-
|
|
324
|
-
| `
|
|
325
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
+
---
|
|
397
330
|
|
|
398
|
-
|
|
331
|
+
## Advanced primitives
|
|
399
332
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 |
|
|
566
|
-
|
|
|
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
|
|
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
|
-
##
|
|
393
|
+
## Browser support
|
|
394
|
+
|
|
395
|
+
Concertina targets modern browsers. The minimum floor is set by `1lh` (CSS line-height unit):
|
|
578
396
|
|
|
579
|
-
|
|
397
|
+
- Chrome 109+
|
|
398
|
+
- Firefox 120+
|
|
399
|
+
- Safari 17.2+
|
|
580
400
|
|
|
581
|
-
|
|
401
|
+
The `inert` attribute shipped before `1lh` in every browser. No polyfills. No fallbacks. No bloat.
|
|
582
402
|
|
|
583
|
-
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Roadmap
|
|
584
406
|
|
|
585
|
-
- **
|
|
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
|
|
411
|
+
These are proposals, not commitments. If any would unblock your project, open an issue.
|
|
588
412
|
|
|
589
413
|
## License
|
|
590
414
|
|