@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 +21 -0
- package/README.md +434 -2
- package/dist/chunk-OAWJ54V3.js +765 -0
- package/dist/core.d.ts +114 -0
- package/dist/core.js +14 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +457 -0
- package/dist/styles.css +30 -0
- package/package.json +88 -4
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
|
-
#
|
|
1
|
+
# @silverlila/slider
|
|
2
2
|
|
|
3
|
-
|
|
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
|