@ycparak/spring-carousel 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 +188 -2
- package/dist/index.cjs +1342 -0
- package/dist/index.d.cts +158 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +1338 -0
- package/package.json +64 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuf Parak
|
|
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,189 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ycparak/spring-carousel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A composable, spring-driven carousel for React. Slides follow your finger, trackpad and arrow keys, then settle with a physical spring you can tune. A centred tab strip slides along with them.
|
|
4
|
+
|
|
5
|
+
- **Composable.** Namespaced parts you arrange and style yourself, plus hooks for anything custom.
|
|
6
|
+
- **Tunable motion.** One `spring` prop, as physics (`stiffness`, `damping`, `mass`) or feel (`duration`, `bounce`).
|
|
7
|
+
- **No re-renders while moving.** Animation runs outside React; per-frame values reach your code through a hook and CSS variables.
|
|
8
|
+
- **Accessible.** Follows the WAI-ARIA tabbed carousel pattern, with keyboard support and `prefers-reduced-motion`.
|
|
9
|
+
- **SSR-safe.** Renders the right slide on the server and hydrates cleanly. Styles ship with the component.
|
|
10
|
+
- **Zero dependencies.** React 19.2 or later is the only peer.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @ycparak/spring-carousel
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { Carousel } from '@ycparak/spring-carousel'
|
|
22
|
+
|
|
23
|
+
export function Demos({ items }) {
|
|
24
|
+
return (
|
|
25
|
+
<Carousel.Root aria-label="Product demos">
|
|
26
|
+
<Carousel.Viewport>
|
|
27
|
+
<Carousel.Track>
|
|
28
|
+
{items.map((item, index) => (
|
|
29
|
+
<Carousel.Slide key={item.id} index={index}>
|
|
30
|
+
<img src={item.src} alt={item.alt} />
|
|
31
|
+
</Carousel.Slide>
|
|
32
|
+
))}
|
|
33
|
+
</Carousel.Track>
|
|
34
|
+
</Carousel.Viewport>
|
|
35
|
+
|
|
36
|
+
<Carousel.Tabs>
|
|
37
|
+
{items.map((item, index) => (
|
|
38
|
+
<Carousel.Tab key={item.id} index={index}>
|
|
39
|
+
{item.label}
|
|
40
|
+
</Carousel.Tab>
|
|
41
|
+
))}
|
|
42
|
+
</Carousel.Tabs>
|
|
43
|
+
|
|
44
|
+
<Carousel.Prev />
|
|
45
|
+
<Carousel.Next />
|
|
46
|
+
</Carousel.Root>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every part renders one element, forwards `ref` and accepts that element's props.
|
|
52
|
+
|
|
53
|
+
## Parts
|
|
54
|
+
|
|
55
|
+
| Part | Element | Notes |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `Carousel.Root` | `section` | Holds state and options. Give it an `aria-label`. |
|
|
58
|
+
| `Carousel.Viewport` | `div` | The draggable, clipped window onto the track. |
|
|
59
|
+
| `Carousel.Track` | `div` | The row of slides that moves. |
|
|
60
|
+
| `Carousel.Slide` | `div` | One slide. Needs `index`, counting from 0 without gaps. |
|
|
61
|
+
| `Carousel.Tabs` | `div` | The tab strip, centred under the slides, sliding as they move. Optional. |
|
|
62
|
+
| `Carousel.Tab` | `button` | Selects slide `index`. |
|
|
63
|
+
| `Carousel.Prev`, `Carousel.Next` | `button` | Step buttons with a default chevron; pass children to replace it. |
|
|
64
|
+
|
|
65
|
+
### Root props
|
|
66
|
+
|
|
67
|
+
| Prop | Type | Default | |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| `index` | `number` | | The current slide, for a controlled carousel. |
|
|
70
|
+
| `defaultIndex` | `number` | `0` | The starting slide, for an uncontrolled carousel. |
|
|
71
|
+
| `onIndexChange` | `(index) => void` | | Called when a gesture, key, tab or button asks for a new slide. |
|
|
72
|
+
| `spring` | `SpringOptions` | `{ stiffness: 90, damping: 18.1, mass: 0.9 }` | `{ stiffness, damping, mass }` or `{ duration, bounce }` (seconds, −1 to 1). |
|
|
73
|
+
| `keyboard` | `'focus' \| 'global' \| false` | `'focus'` | Where arrow keys work: inside the carousel, anywhere on the page, or nowhere. |
|
|
74
|
+
| `wheel` | `boolean` | `true` | Page through slides with horizontal trackpad swipes. |
|
|
75
|
+
| `draggable` | `boolean` | `true` | Drag slides with a mouse, pen or finger. |
|
|
76
|
+
| `reducedMotion` | `'user' \| 'always' \| 'never'` | `'user'` | `'user'` follows `prefers-reduced-motion`. |
|
|
77
|
+
|
|
78
|
+
A controlled carousel always settles on `index`. If `onIndexChange` ignores a request, the carousel springs back.
|
|
79
|
+
|
|
80
|
+
## Hooks
|
|
81
|
+
|
|
82
|
+
Call these from any component inside `Carousel.Root`.
|
|
83
|
+
|
|
84
|
+
### `useCarousel()`
|
|
85
|
+
|
|
86
|
+
Returns `{ index, count, isDragging, canPrev, canNext, goTo, next, prev, progress }`. It re-renders when the index, slide count or drag state changes, never once per frame.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
function Counter() {
|
|
90
|
+
const { index, count } = useCarousel()
|
|
91
|
+
return <p>{index + 1} / {count}</p>
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`goTo(index, { animate, velocity })`, `next()` and `prev()` accept `{ animate: false }` to move instantly.
|
|
96
|
+
|
|
97
|
+
### `useCarouselProgress(listener)`
|
|
98
|
+
|
|
99
|
+
Calls `listener(progress)` on every frame the carousel moves, and once on mount. `progress` is the position in slides: `1.5` is halfway between the second and third. Use it to drive styles directly, without re-rendering:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
function Caption({ index, children }) {
|
|
103
|
+
const ref = useRef<HTMLParagraphElement>(null)
|
|
104
|
+
useCarouselProgress(progress => {
|
|
105
|
+
const offset = index - progress
|
|
106
|
+
ref.current?.style.setProperty('opacity', `${Math.max(0, 1 - Math.abs(offset) * 2)}`)
|
|
107
|
+
ref.current?.style.setProperty('translate', `${offset * 60}px`)
|
|
108
|
+
})
|
|
109
|
+
return <p ref={ref}>{children}</p>
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `useSlide()`
|
|
114
|
+
|
|
115
|
+
Inside `Carousel.Slide`, returns `{ index, isActive }`. Useful for playing a video only while its slide is showing:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
function SlideVideo(props) {
|
|
119
|
+
const ref = useRef<HTMLVideoElement>(null)
|
|
120
|
+
const { isActive } = useSlide()
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const video = ref.current
|
|
123
|
+
if (!video) return
|
|
124
|
+
if (isActive) video.play().catch(() => {})
|
|
125
|
+
else video.pause()
|
|
126
|
+
}, [isActive])
|
|
127
|
+
return <video ref={ref} muted loop playsInline {...props} />
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Styling
|
|
132
|
+
|
|
133
|
+
The component injects only the structural CSS it needs to lay out and move. Every rule uses `:where()`, so any class you write overrides it. Style the parts with `className` and these hooks:
|
|
134
|
+
|
|
135
|
+
**Data attributes**
|
|
136
|
+
|
|
137
|
+
| Attribute | On |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `data-spring-carousel` and `data-spring-carousel-{viewport,track,slide,tabs,tab,prev,next}` | Each part |
|
|
140
|
+
| `data-active` | The current `Slide` and `Tab` |
|
|
141
|
+
| `data-dragging` | `Root`, while a pointer drags |
|
|
142
|
+
| `data-disabled` | `Prev` at the first slide, `Next` at the last |
|
|
143
|
+
| `data-measured` | `Tabs`, once positioned (it is hidden until then) |
|
|
144
|
+
|
|
145
|
+
**CSS variables you set**
|
|
146
|
+
|
|
147
|
+
| Variable | Default | |
|
|
148
|
+
| --- | --- | --- |
|
|
149
|
+
| `--carousel-slide-size` | `100%` | Slide width. Below 100% the neighbours peek in. |
|
|
150
|
+
| `--carousel-gap` | `0px` | Space between slides. |
|
|
151
|
+
| `--carousel-inactive-opacity` | `0.25` | Opacity of tabs away from the current slide. |
|
|
152
|
+
|
|
153
|
+
**CSS variables the carousel sets every frame**
|
|
154
|
+
|
|
155
|
+
| Variable | On | |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `--carousel-progress` | `Root` | Position in slides. |
|
|
158
|
+
| `--carousel-offset` | each `Slide` and `Tab` | `index − progress`: 0 when current, −1 one slide behind. |
|
|
159
|
+
| `--carousel-presence` | each `Slide` and `Tab` | 1 when current, falling to 0 a slide away. |
|
|
160
|
+
|
|
161
|
+
```css
|
|
162
|
+
.slide {
|
|
163
|
+
scale: calc(0.9 + 0.1 * var(--carousel-presence));
|
|
164
|
+
opacity: calc(0.4 + 0.6 * var(--carousel-presence));
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
With Tailwind, set variables as utilities (`[--carousel-slide-size:80%]`). Part styles are unlayered, so to change a structural property from a utility, mark it important (`overflow-visible!`).
|
|
169
|
+
|
|
170
|
+
To keep an element inside a slide from starting a drag (a range input, a map), give it `data-carousel-no-drag`.
|
|
171
|
+
|
|
172
|
+
## Accessibility
|
|
173
|
+
|
|
174
|
+
- `Root` is a labelled region described as a carousel. Slides are `tabpanel`s labelled by their tab when there are tabs, otherwise `group`s labelled "2 of 5". Both are described as slides.
|
|
175
|
+
- Slides other than the current one are `inert`, so their contents can't be focused or read until shown.
|
|
176
|
+
- Tabs use a roving `tabindex`. Arrow keys move between them and change slide; `Home` and `End` jump to the ends.
|
|
177
|
+
- `Prev` and `Next` use `aria-disabled` rather than `disabled` at the ends, so keyboard focus isn't lost.
|
|
178
|
+
- Arrow keys are ignored in text fields and other arrow-key widgets, and with modifier keys held.
|
|
179
|
+
- With `reducedMotion="user"` (the default), slides move instantly when the visitor prefers reduced motion. Dragging still follows the pointer.
|
|
180
|
+
- The track is a polite live region, so screen readers announce the new slide.
|
|
181
|
+
|
|
182
|
+
## Notes
|
|
183
|
+
|
|
184
|
+
- Requires React 19.2 or later: it uses `useEffectEvent`, ref cleanups and `<style>` hoisting.
|
|
185
|
+
- Left-to-right layouts only for now. There's no looping or autoplay.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|