@silverlila/slider 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Silvi Lila
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,435 @@
1
- # Temporary Holding Version
1
+ # @silverlila/slider
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A slider and carousel library where the browser's own scrolling moves the slides.
4
+
5
+ The slides sit in an ordinary scroll container with CSS scroll-snap. JavaScript measures the
6
+ layout, tracks which slide is active, drives the buttons and dots, and adds what the platform
7
+ lacks: mouse dragging, an infinite loop, autoplay, per-slide effect values and thumbnail sync.
8
+ Because the scroll is real, trackpads, mouse wheels, keyboard arrows, touch momentum,
9
+ find-in-page and focus scrolling all behave the way they do everywhere else on the page, and the
10
+ layout is already correct before JavaScript loads.
11
+
12
+ Scrolling, drag and animation come from [use-scroller](https://www.npmjs.com/package/use-scroller).
13
+ The package has two entry points:
14
+
15
+ - `@silverlila/slider/core`: framework-free. No React anywhere in its import graph.
16
+ - `@silverlila/slider`: React components and hooks. It also re-exports everything in `core`.
17
+
18
+ ESM only. React is an optional peer dependency (`^19`); the core works without it.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ pnpm add @silverlila/slider
24
+ ```
25
+
26
+ Import the stylesheet once, anywhere in your app:
27
+
28
+ ```ts
29
+ import '@silverlila/slider/styles.css'
30
+ ```
31
+
32
+ It makes every `[data-slider-viewport]` a flex scroll container with mandatory snapping, a hidden
33
+ scrollbar and `position: relative`, and sizes the slides from two custom properties. Every
34
+ selector is wrapped in `:where()`, so any rule you write wins over it.
35
+
36
+ ## Quick start
37
+
38
+ ```tsx
39
+ import { Slider } from '@silverlila/slider'
40
+ import '@silverlila/slider/styles.css'
41
+
42
+ export function Gallery({ photos }: { photos: { src: string; alt: string }[] }) {
43
+ return (
44
+ <Slider label="Photos" perView={1} gap={16}>
45
+ <Slider.Viewport>
46
+ {photos.map((photo) => (
47
+ <Slider.Slide key={photo.src}>
48
+ <img src={photo.src} alt={photo.alt} />
49
+ </Slider.Slide>
50
+ ))}
51
+ </Slider.Viewport>
52
+ <Slider.Prev />
53
+ <Slider.Dots />
54
+ <Slider.Next />
55
+ </Slider>
56
+ )
57
+ }
58
+ ```
59
+
60
+ Run `pnpm dev` in this repository for a playground with every feature below.
61
+
62
+ ## Options
63
+
64
+ These go on `<Slider>` as props, into `useSlider(options)`, or (for the engine options) into
65
+ `createSlider(viewport, options)`.
66
+
67
+ | Option | Type | Default | What it does |
68
+ | --- | --- | --- | --- |
69
+ | `axis` | `'x' \| 'y'` | `'x'` | Scroll direction. |
70
+ | `align` | `'start' \| 'center' \| 'end'` | `'start'` | Where a snapped slide sits in the viewport. |
71
+ | `slidesPerMove` | `number \| 'auto'` | `1` | Slides per snap: an integer >= 1, or `'auto'` for as many as fully fit (re-measured on resize). |
72
+ | `loop` | `boolean` | `false` | Infinite loop through clones. See [Loop](#loop). |
73
+ | `rewind` | `boolean` | `false` | Next on the last snap scrolls back to the first, Prev on the first to the last. Ignored with `loop`. |
74
+ | `draggable` | `boolean` | `true` | Mouse and touch dragging through use-scroller's `dragScroll`. With `false`, touch scrolling is native and the mouse cannot drag. |
75
+ | `animation` | `ScrollAnimation` | `{ type: 'tween' }` | How `next`/`prev`/`scrollTo` move: `{ type: 'tween', duration?, easing? }` (450ms by default), `{ type: 'spring', stiffness?, damping?, mass?, velocity? }` or `{ type: 'instant' }`. |
76
+ | `startIndex` | `number` | `0` | Core only. Snap shown at creation, clamped into range. |
77
+
78
+ React-only props and hook options:
79
+
80
+ | Prop | Type | Default | What it does |
81
+ | --- | --- | --- | --- |
82
+ | `defaultIndex` | `number` | `0` | Snap shown first when the index is uncontrolled. |
83
+ | `index` | `number` | none | Controlled snap index. The slider scrolls to it whenever it changes. |
84
+ | `onIndexChange` | `(index: number) => void` | none | Called when the active snap changes, never for the initial one. During a navigation it fires once, with the target. |
85
+ | `autoplay` | `boolean \| { delay?, pauseOnHover?, stopOnInteraction? }` | off | Advance on a timer. See [Autoplay](#autoplay). |
86
+ | `slideProgress` | `boolean` | `false` | Write `--slide-progress` and `--slide-distance` on every slide. See [Effects](#effects). |
87
+ | `perView` | `number \| 'auto'` | `1` | `<Slider>` only. Slides per view, written to `--slider-per-view`. Fractions work (`1.4` shows the neighbours). `'auto'` sizes slides by their content. |
88
+ | `gap` | `number \| string` | none | `<Slider>` only. Space between slides, written to `--slider-gap`. A number is pixels. |
89
+ | `label` | `string` | `'Slides'` | `<Slider>` only. Accessible name of the carousel region. |
90
+
91
+ Changing `axis`, `align`, `slidesPerMove`, `loop`, `rewind`, `draggable` or `animation` re-creates
92
+ the engine and keeps the current slide. `animation` is compared field by field, so an inline
93
+ `animation={{ type: 'spring' }}` does not re-create it on every render.
94
+
95
+ ## Components
96
+
97
+ Every component accepts the props of the element it renders (`className`, `style`, event
98
+ handlers, `ref`).
99
+
100
+ | Component | Renders | Attributes and ARIA |
101
+ | --- | --- | --- |
102
+ | `<Slider>` | `div` | `role="region"`, `aria-roledescription="carousel"`, `aria-label` from `label`, `data-slider`. Sets `--slider-per-view`, `--slider-gap` and `--slider-align` on itself so they inherit to the viewport. |
103
+ | `<Slider.Viewport>` | `div` | The scroll container: `data-slider-viewport`, `data-axis`, a generated `id`, `tabIndex={0}` so arrow keys scroll it. Its children must be `<Slider.Slide>` elements (or components that render one). |
104
+ | `<Slider.Slide>` | `div` | `data-slider-slide`, `role="group"`, `aria-roledescription="slide"`, `aria-label="3 of 8"`. Clones get `data-slider-clone`, `aria-hidden="true"` and `inert` instead, and lose their `id`. |
105
+ | `<Slider.Prev>` / `<Slider.Next>` | `button` | `aria-controls` (the viewport), `aria-label="Previous slide"` / `"Next slide"`, `disabled` at the edges. Content defaults to the text Previous / Next. Calling `event.preventDefault()` in your `onClick` cancels the navigation. |
106
+ | `<Slider.Dots>` | `div` of `button`s | The group: `role="group"`, `aria-label="Choose slide"`, `data-slider-dots`. One button per snap: `data-slider-dot`, `aria-controls`, `aria-label="Go to slide 2"`, and on the active one `aria-current="true"` and `data-selected`. The buttons are empty; style them with CSS. |
107
+ | `<Slider.AutoplayToggle>` | `button` | `aria-controls`, `aria-label="Stop automatic slide show"` / `"Start automatic slide show"`, `data-playing` while playing. Children are a node or `(isPlaying) => node`; default Pause / Play. Renders nothing without `autoplay`. |
108
+ | `<Slider.Thumbs>` | `div` > `div` | `data-slider-thumbs` around its own `data-slider-viewport`. Props: `perView` (default `'auto'`), `gap`, `axis` (default `'x'`). |
109
+ | `<Slider.Thumb>` | `button` | `data-slider-thumb`, `aria-controls` (the main viewport), `aria-label="Go to slide 2"`; the selected one gets `aria-current="true"` and `data-selected`. |
110
+
111
+ There is no `keydown` handler and no live region. A focused scroll container already scrolls and
112
+ snaps with the arrow, Page and Home/End keys, and the DOM never changes on navigation, so there is
113
+ nothing for a live region to announce.
114
+
115
+ ## Hooks
116
+
117
+ ### `useSlider(options)`: headless
118
+
119
+ Attach `viewportRef` to a scroll container and build any markup around it. The engine is created
120
+ once the viewport is mounted.
121
+
122
+ ```tsx
123
+ import { useSlider } from '@silverlila/slider'
124
+
125
+ function Hero({ items }: { items: string[] }) {
126
+ const { viewportRef, rootRef, index, snapCount, canPrev, canNext, prev, next } = useSlider({
127
+ align: 'center',
128
+ })
129
+ return (
130
+ <div ref={rootRef}>
131
+ <div ref={viewportRef} data-slider-viewport="" tabIndex={0}>
132
+ {items.map((item) => (
133
+ <div key={item}>{item}</div>
134
+ ))}
135
+ </div>
136
+ <button type="button" onClick={() => prev()} disabled={!canPrev}>
137
+ Back
138
+ </button>
139
+ <span>
140
+ {index + 1} of {snapCount}
141
+ </span>
142
+ <button type="button" onClick={() => next()} disabled={!canNext}>
143
+ Forward
144
+ </button>
145
+ </div>
146
+ )
147
+ }
148
+ ```
149
+
150
+ The result holds the discrete state (`index`, `snapCount`, `slideCount`, `canPrev`, `canNext`,
151
+ `loopCopies`), `viewportRef`, `rootRef` (optional: autoplay pauses while the pointer or keyboard
152
+ focus is inside that element), `slider` (the engine, `null` until mounted), `autoplay`
153
+ (`{ isPlaying, play, stop }`, or `null` when autoplay is off) and `next`, `prev`,
154
+ `scrollTo(index)`. Each navigation method takes an optional `{ animation }` that overrides the
155
+ option for that call.
156
+
157
+ The headless hook does not render loop clones. With `loop`, render `loopCopies` copies of your
158
+ slides before and after the real ones, each with `data-slider-clone="<real index>"`,
159
+ `aria-hidden="true"` and `inert`. Do not call `renderClones` on DOM that React owns.
160
+
161
+ ### `useSliderState(slider, selector, isEqual?)`
162
+
163
+ Subscribes to a slice of the engine's state, built on `useSyncExternalStore`. The component
164
+ re-renders only when the selected value changes by `isEqual` (default `Object.is`; pass a shallow
165
+ comparison when the selector returns an object). With `slider === null` it reads the initial
166
+ state.
167
+
168
+ This is the only way to read `progress` (0 to 1 along the snaps, following the real scroll
169
+ position). It changes every scroll frame, so it is deliberately left out of the components'
170
+ context: putting it there would re-render the whole slider on every frame.
171
+
172
+ ### `useSliderContext()`
173
+
174
+ Returns the enclosing `<Slider>`'s `useSlider` result plus `viewportId` and `axis`. It throws
175
+ outside a `<Slider>`. Use it to build your own controls:
176
+
177
+ ```tsx
178
+ import { Slider, useSliderContext, useSliderState } from '@silverlila/slider'
179
+
180
+ function ProgressBar() {
181
+ const { slider } = useSliderContext()
182
+ const progress = useSliderState(slider, (state) => state.progress)
183
+ return (
184
+ <div
185
+ aria-hidden="true"
186
+ style={{ height: 4, background: 'currentColor', scale: `${progress} 1`, transformOrigin: 'left' }}
187
+ />
188
+ )
189
+ }
190
+
191
+ // <Slider>…<Slider.Viewport>…</Slider.Viewport><ProgressBar /></Slider>
192
+ ```
193
+
194
+ `slider` here is the core engine. In the React entry the name `Slider` is the component, so when
195
+ you need the engine's type, import it from the core entry:
196
+
197
+ ```ts
198
+ import type { Slider as SliderEngine } from '@silverlila/slider/core'
199
+ ```
200
+
201
+ ## Loop
202
+
203
+ `loop` is off by default because it duplicates DOM. When on, the engine works out how many full
204
+ copies of the slide set it needs on each side (at least two viewports of buffer, minimum one
205
+ copy) and publishes that as `loopCopies`. `<Slider.Viewport>` renders the copies itself; vanilla
206
+ users call `renderClones(slider)`.
207
+
208
+ - Prev, Next, dots and `scrollTo` keep moving in the same direction through the clones.
209
+ - When scrolling comes to rest on a snap inside a clone region, with no pointer down and no
210
+ navigation pending, the engine jumps by whole periods back into the real set. The jump lands on
211
+ an identical snap, so nothing moves on screen. It never jumps mid-gesture, because changing the
212
+ scroll position during momentum stops momentum on iOS.
213
+ - Clones carry `aria-hidden="true"` and `inert`, so screen readers and keyboard users meet each
214
+ slide exactly once, and DOM clones have every `id` removed. The cost: a clone you can see (for
215
+ example the left neighbour of the first slide with `align="center"`) cannot be clicked.
216
+ - React renders each clone as another instance of your slide element. Component state inside a
217
+ slide is not shared with its clones: a video playing in slide 2 is not playing in the copy of
218
+ slide 2.
219
+
220
+ `rewind` is the clone-free option: at the end, Next scrolls back across all the slides to the
221
+ first.
222
+
223
+ ## Autoplay
224
+
225
+ ```tsx
226
+ <Slider autoplay={{ delay: 5000 }}>
227
+ <Slider.Viewport>{slides}</Slider.Viewport>
228
+ <Slider.AutoplayToggle />
229
+ </Slider>
230
+ ```
231
+
232
+ | Option | Default | What it does |
233
+ | --- | --- | --- |
234
+ | `delay` | `4000` | Milliseconds between moves. Must be > 0. |
235
+ | `pauseOnHover` | `true` | Pause while the pointer is over the slider. |
236
+ | `stopOnInteraction` | `true` | Stop for good on `pointerdown`, `wheel` or `keydown` on the viewport. |
237
+
238
+ Autoplay advances one snap at a time and goes back to the first snap after the last. It pauses
239
+ while the pointer is over the `<Slider>` element, while keyboard focus is anywhere inside it (the
240
+ focus a mouse click leaves on a button does not count), while a pointer is pressed on the
241
+ viewport, and while the tab is hidden. Pressing Play overrides the hover and focus pauses, so it
242
+ starts moving at once. It starts stopped under
243
+ `prefers-reduced-motion: reduce`; the user can still start it with the toggle. Navigation
244
+ animations also jump instantly under reduced motion.
245
+
246
+ Render `<Slider.AutoplayToggle>` whenever you use autoplay. WCAG 2.2.2 (Pause, Stop, Hide)
247
+ requires a way to stop content that moves on its own for more than five seconds, and hover only
248
+ pauses it for mouse users.
249
+
250
+ ## Effects
251
+
252
+ With `slideProgress`, every slide and clone gets two custom properties, rewritten each scroll
253
+ frame:
254
+
255
+ - `--slide-progress`: signed distance from the aligned position, in slide steps. `0` when the
256
+ slide is snapped, `1` for the next slide, `-1` for the previous one.
257
+ - `--slide-distance`: its absolute value.
258
+
259
+ The effect itself is plain CSS. Scale and fade the slides away from the centre:
260
+
261
+ ```tsx
262
+ <Slider loop align="center" perView={1.6} slideProgress>
263
+ <Slider.Viewport>
264
+ {items.map((item) => (
265
+ <Slider.Slide key={item.id} className="fade-scale">…</Slider.Slide>
266
+ ))}
267
+ </Slider.Viewport>
268
+ </Slider>
269
+ ```
270
+
271
+ ```css
272
+ .fade-scale {
273
+ scale: calc(1 - min(var(--slide-distance, 0), 1) * 0.15);
274
+ opacity: calc(1 - min(var(--slide-distance, 0), 1) * 0.5);
275
+ }
276
+ ```
277
+
278
+ Keep the default centre `transform-origin` (see [Known limitations](#known-limitations)). The
279
+ engine measures untransformed offsets, so `scale` and `rotate` never distort its snap math.
280
+
281
+ CSS scroll-driven animations (`animation-timeline: view(inline)`) are an alternative that needs
282
+ no JavaScript at all, in browsers that support them. `slideProgress` works everywhere and counts
283
+ in slide steps rather than percentages of the viewport.
284
+
285
+ ## Thumbnails
286
+
287
+ ```tsx
288
+ <Slider label="Gallery">
289
+ <Slider.Viewport>
290
+ {photos.map((photo) => (
291
+ <Slider.Slide key={photo.src}>
292
+ <img src={photo.src} alt={photo.alt} />
293
+ </Slider.Slide>
294
+ ))}
295
+ </Slider.Viewport>
296
+ <Slider.Thumbs perView={5} gap={8}>
297
+ {photos.map((photo) => (
298
+ <Slider.Thumb key={photo.src}>
299
+ <img src={photo.thumb} alt="" />
300
+ </Slider.Thumb>
301
+ ))}
302
+ </Slider.Thumbs>
303
+ </Slider>
304
+ ```
305
+
306
+ The strip is a slider of its own (draggable, aligned to the start). It scrolls to keep up with
307
+ the main slider, and clicking thumbnail `i` moves the main slider to snap `i`. Sync maps
308
+ thumbnail **slide** `i` to main **snap** `i`, so it assumes the main slider shows one slide per
309
+ snap (`slidesPerMove: 1`).
310
+
311
+ ## Responsive layouts
312
+
313
+ Slide size is CSS, not an engine option. `--slider-per-view` (a number, or unset for
314
+ content-sized slides) and `--slider-gap` decide it, and the engine measures whatever layout the
315
+ CSS produced. `perView` and `gap` set them on the `<Slider>` element, so a rule on the viewport
316
+ overrides them:
317
+
318
+ ```css
319
+ .products [data-slider-viewport] {
320
+ --slider-per-view: 1;
321
+ }
322
+ @media (min-width: 640px) {
323
+ .products [data-slider-viewport] {
324
+ --slider-per-view: 2;
325
+ }
326
+ }
327
+ @media (min-width: 1024px) {
328
+ .products [data-slider-viewport] {
329
+ --slider-per-view: 4;
330
+ --slider-gap: 24px;
331
+ }
332
+ }
333
+ ```
334
+
335
+ Combine it with `slidesPerMove="auto"` to move a full page of slides at every width. The engine
336
+ re-measures when the viewport or a slide resizes and when slides are added or removed, and keeps
337
+ the current slide in place when nothing is scrolling.
338
+
339
+ ## Vanilla core
340
+
341
+ ```ts
342
+ import {
343
+ autoplay,
344
+ createSlider,
345
+ renderClones,
346
+ slideProgress,
347
+ syncSliders,
348
+ } from '@silverlila/slider/core'
349
+ import '@silverlila/slider/styles.css'
350
+
351
+ const viewport = document.querySelector<HTMLElement>('#hero')!
352
+ // <div id="hero" data-slider-viewport tabindex="0"> <div>…</div> <div>…</div> … </div>
353
+
354
+ const slider = createSlider(viewport, { loop: true, align: 'center' })
355
+ const stopClones = renderClones(slider)
356
+ const player = autoplay(slider, { delay: 4000 })
357
+ const stopEffects = slideProgress(slider)
358
+
359
+ const counter = document.querySelector('#counter')!
360
+ document.querySelector('#next')!.addEventListener('click', () => slider.next())
361
+ slider.subscribe(({ index, snapCount }) => {
362
+ counter.textContent = `${index + 1} / ${snapCount}`
363
+ })
364
+
365
+ // Later:
366
+ player.destroy()
367
+ stopEffects()
368
+ stopClones()
369
+ slider.destroy()
370
+ ```
371
+
372
+ Every function that sets something up returns a cleanup, or an object with `destroy()`.
373
+
374
+ | Function | Returns | What it does |
375
+ | --- | --- | --- |
376
+ | `createSlider(viewport, options?)` | `Slider` | The engine. Every element child of `viewport` is a slide. |
377
+ | `renderClones(slider)` | cleanup | Renders `loopCopies` copies on each side and re-renders when the count or the slides change. Cleanup removes every clone. |
378
+ | `autoplay(slider, { delay?, root?, pauseOnHover?, stopOnInteraction? })` | `Autoplay` | `root` is the element whose hover and keyboard focus pause it (default the viewport). Returns `{ play(), stop(), isPlaying(), subscribe(listener), destroy() }`. |
379
+ | `slideProgress(slider)` | cleanup | Writes `--slide-progress` and `--slide-distance`. Cleanup removes them. |
380
+ | `syncSliders(main, thumbs)` | cleanup | Thumbnail sync, as above. Cleanup removes `aria-current` and `data-selected`. |
381
+
382
+ The `Slider` engine:
383
+
384
+ | Member | What it does |
385
+ | --- | --- |
386
+ | `viewport`, `options` | The element and the resolved options. |
387
+ | `getState()` | `{ index, snapCount, slideCount, canPrev, canNext, progress, loopCopies }`. |
388
+ | `subscribe(listener)` | Calls `listener(state)` on every change, `progress` included. Returns an unsubscribe. |
389
+ | `next(opts?)`, `prev(opts?)`, `scrollTo(index, opts?)` | Navigate. `opts.animation` overrides the option. Consecutive calls build on the pending target, so three quick `next()` calls move three snaps. |
390
+ | `destroy()` | Stops everything and removes the inline styles it wrote. Calling a method afterwards throws. |
391
+
392
+ While a navigation is in flight, `state.index` is its target; otherwise it is the snap nearest the
393
+ scroll position. `progress` always follows the real position.
394
+
395
+ `createSlider` throws if the viewport is not positioned (the stylesheet sets
396
+ `position: relative`; slide offsets are measured against it), if `slidesPerMove` is not an
397
+ integer >= 1 or `'auto'`, and if `startIndex` is not an integer. For a vertical slider, set
398
+ `data-axis="y"` on the viewport so the stylesheet lays it out as a column, and give the viewport
399
+ a height.
400
+
401
+ ## Styling hooks
402
+
403
+ | Hook | On |
404
+ | --- | --- |
405
+ | `[data-slider]` | The `<Slider>` root. |
406
+ | `[data-slider-viewport]`, `[data-axis="x" \| "y"]` | The scroll container. |
407
+ | `[data-slider-slide]` | Every slide and clone (React). |
408
+ | `[data-slider-clone="<i>"]` | A clone of real slide `i`. |
409
+ | `[data-slider-dots]`, `[data-slider-dot]`, `[data-selected]` | Dots and the active dot. |
410
+ | `[data-slider-thumbs]`, `[data-slider-thumb]`, `[data-selected]` | The thumbnail strip and the selected thumbnail. |
411
+ | `[data-playing]` | The autoplay toggle while playing. |
412
+ | `--slider-per-view`, `--slider-gap` | Slide size and spacing. |
413
+ | `--slider-align` | `scroll-snap-align` before the engine starts; afterwards the engine writes it inline on each snap anchor. |
414
+ | `--slide-progress`, `--slide-distance` | Written by `slideProgress`. |
415
+
416
+ The engine writes inline `scroll-snap-align` on slides (the align value on the slide that defines
417
+ each snap, `none` on the rest), and inline `scroll-snap-type: none` on the viewport while its own
418
+ navigation animates.
419
+
420
+ ## Known limitations
421
+
422
+ - `align: 'center'` with an even `slidesPerMove` centres one slide of the group (the middle one,
423
+ rounded down), not the group itself.
424
+ - Touch dragging and loop recentring have been tested only in headless Chromium. Real-device
425
+ verification on iOS Safari and Android is still pending.
426
+ - Native snap and transformed slides: the engine measures untransformed layout. Whether browsers
427
+ compute snap areas from transformed or untransformed boxes has not been verified. An effect
428
+ that scales slides around a non-centre origin with `align: 'start'` could make native snap and
429
+ the engine disagree by the scale difference. Effects that scale around the centre are
430
+ unaffected with `align: 'center'`.
431
+ - React 19 only (`^19`): clones rely on React 19's support for the `inert` prop.
432
+
433
+ ## License
434
+
435
+ MIT