react-prism-carousel 0.1.0 → 0.1.1
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 +465 -460
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,460 +1,465 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
|
125
|
-
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
transition
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}}
|
|
211
|
-
|
|
212
|
-
start.current
|
|
213
|
-
|
|
214
|
-
setPosition((
|
|
215
|
-
}}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
{
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1
|
+
[](https://www.npmjs.com/package/react-prism-carousel)
|
|
2
|
+
[](https://bundlephobia.com)
|
|
3
|
+
|
|
4
|
+
# react-prism-carousel
|
|
5
|
+
|
|
6
|
+
Check [github pages](https://bluewebber.github.io/react-prism-carousel) for demos.
|
|
7
|
+
|
|
8
|
+
A 3D prism carousel for React. Every child becomes one face of a regular prism, and the geometry is done entirely in CSS: no measuring, no animation loop, no JavaScript per frame.
|
|
9
|
+
|
|
10
|
+
The component only **draws** the prism. Deciding which face is in front, and when and how it turns (buttons, keys, drag, autoplay), is up to you.
|
|
11
|
+
|
|
12
|
+
- [Requirements](#requirements)
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Quick start](#quick-start)
|
|
15
|
+
- [How it works](#how-it-works)
|
|
16
|
+
- [Props](#props)
|
|
17
|
+
- [Sizing](#sizing)
|
|
18
|
+
- [Rotation and animation](#rotation-and-animation)
|
|
19
|
+
- [Camera](#camera)
|
|
20
|
+
- [Styling faces](#styling-faces)
|
|
21
|
+
- [Centerpiece](#centerpiece)
|
|
22
|
+
- [Clicks, focus and accessibility](#clicks-focus-and-accessibility)
|
|
23
|
+
- [Edge cases](#edge-cases)
|
|
24
|
+
- [Overriding styles](#overriding-styles)
|
|
25
|
+
- [TypeScript](#typescript)
|
|
26
|
+
- [Known issues](#known-issues)
|
|
27
|
+
- [Developing](#developing)
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- React 19.
|
|
32
|
+
- A bundler that supports CSS Modules (Vite, Next.js, webpack with `css-loader`, …).
|
|
33
|
+
- A browser that supports `sibling-index()`, `sin()`/`cos()`, container queries (`container-type`, `cqw`/`cqh`), CSS nesting, `@layer`, and the individual `translate`/`rotate` properties. `sibling-index()` is the newest of these, so check its support first.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install react-prism-carousel
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Import the component, and import the stylesheet once anywhere in your app (next to your root component, for example):
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import Carousel from "react-prism-carousel";
|
|
45
|
+
import "react-prism-carousel/style.css";
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The package is ESM-only.
|
|
49
|
+
|
|
50
|
+
### Using an AI coding assistant
|
|
51
|
+
|
|
52
|
+
The package includes a reference written for AI assistants, at `node_modules/react-prism-carousel/FOR-AI.md`, which always matches the version you installed. Assistants don't look inside `node_modules` on their own, so add this line to your project's agent instructions (`AGENTS.md`, `CLAUDE.md`, Cursor rules, …):
|
|
53
|
+
|
|
54
|
+
```md
|
|
55
|
+
Before writing code that uses react-prism-carousel, read node_modules/react-prism-carousel/FOR-AI.md.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It's also online at <https://unpkg.com/react-prism-carousel/FOR-AI.md>.
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { useState } from "react";
|
|
64
|
+
import Carousel from "react-prism-carousel";
|
|
65
|
+
import "react-prism-carousel/style.css";
|
|
66
|
+
|
|
67
|
+
function Featured({ slides }) {
|
|
68
|
+
const [face, setFace] = useState(0);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{/* The carousel fills its parent, so the parent needs a height. */}
|
|
73
|
+
<div style={{ height: 320 }}>
|
|
74
|
+
<Carousel
|
|
75
|
+
orientation="horizontal"
|
|
76
|
+
aria-label="Featured products"
|
|
77
|
+
noChildrenElem={<p>Nothing to show yet.</p>}
|
|
78
|
+
currentFace={face}
|
|
79
|
+
transition="transform 0.6s ease"
|
|
80
|
+
>
|
|
81
|
+
{slides.map((slide) => (
|
|
82
|
+
<Slide key={slide.id} {...slide} />
|
|
83
|
+
))}
|
|
84
|
+
</Carousel>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<button onClick={() => setFace((f) => f - 1)}>Previous</button>
|
|
88
|
+
<button onClick={() => setFace((f) => f + 1)}>Next</button>
|
|
89
|
+
</>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
There are two things to remember:
|
|
95
|
+
|
|
96
|
+
1. **Give it a height.** The carousel is `100%` of its parent's width and height and doesn't grow with its content.
|
|
97
|
+
2. **You own the rotation.** Change `currentFace` however you like. The component turns the prism to match.
|
|
98
|
+
|
|
99
|
+
## How it works
|
|
100
|
+
|
|
101
|
+
With `n` sides (your children plus any `numEmptyFaces`), each child is wrapped in a `<span>` and placed on one side of a regular `n`-sided prism:
|
|
102
|
+
|
|
103
|
+
- **`orientation="horizontal"`** spins around the vertical axis. Faces sit side by side, each as tall as the carousel.
|
|
104
|
+
- **`orientation="vertical"`** spins around the horizontal axis. Faces are stacked, each as wide as the carousel.
|
|
105
|
+
|
|
106
|
+
By default the prism's corners touch the carousel's edges: the radius is half the carousel's width (or height, when vertical), so each face is `width × sin(180° / n)` wide. With 6 faces, each face is half the carousel's width; with 4, about 71%.
|
|
107
|
+
|
|
108
|
+
`currentFace` turns the whole prism so that face is at the front.
|
|
109
|
+
|
|
110
|
+
The rendered DOM:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
div role="region" aria-roledescription="carousel" ← the root: your other props go here
|
|
114
|
+
div ← the prism: prismProps go here
|
|
115
|
+
span role="group" aria-roledescription="slide" ← face wrapper: faceProps go here
|
|
116
|
+
{your child}
|
|
117
|
+
span …
|
|
118
|
+
div ← centerpiece wrapper
|
|
119
|
+
{centerpieceElem}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Props
|
|
123
|
+
|
|
124
|
+
| Prop | Type | Default | |
|
|
125
|
+
|---|---|---|---|
|
|
126
|
+
| `orientation` | `"horizontal" \| "vertical"` | **required** | Which axis the prism spins around. |
|
|
127
|
+
| `noChildrenElem` | `ReactNode` | **required** | Shown, full size, when there are no children. |
|
|
128
|
+
| `children` | `ReactNode` | | One face per child. |
|
|
129
|
+
| `currentFace` | `number` | `0` | The face to bring to the front. Fractions and out-of-range values are allowed (see [Rotation](#rotation-and-animation)). |
|
|
130
|
+
| `numEmptyFaces` | `number` | `0` | Adds empty sides, turning the closed prism into an arc. Rounded down; negative values count as 0. |
|
|
131
|
+
| `faceSize` | `string` (CSS length) | | Each face's width (horizontal) or height (vertical). Sets the prism's radius instead of the default. |
|
|
132
|
+
| `concave` | `boolean` | `false` | Faces point inward, as if you were inside the prism. |
|
|
133
|
+
| `cameraOffset` | `string` (CSS length) | | Moves the camera: positive values bring it closer, negative values move it away. |
|
|
134
|
+
| `transition` | `string` | none | A CSS `transition` value for the prism. |
|
|
135
|
+
| `centerpieceElem` | `ReactNode` | | Content placed on the prism's axis, which the faces circle around. |
|
|
136
|
+
| `faceProps` | `FaceProps` or `(index, isFront) => FaceProps` | | HTML attributes for every face wrapper. |
|
|
137
|
+
| `prismProps` | `PrismProps` | | HTML attributes for the prism element. |
|
|
138
|
+
| any other prop | `HTMLAttributes<HTMLDivElement>` | | Passed to the root element. |
|
|
139
|
+
|
|
140
|
+
**Children.** Arrays are flattened. `null`, `undefined` and booleans are skipped, so `{cond && <Slide/>}` doesn't leave an empty face. A Fragment counts as one face. Children's `key`s are kept.
|
|
141
|
+
|
|
142
|
+
**Root props.** `className` is added to the component's own class. `style`, event handlers, `tabIndex`, `aria-*` and the rest are passed through unchanged. This is where CSS `perspective` and `perspective-origin` go (see [Camera](#camera)).
|
|
143
|
+
|
|
144
|
+
**`prismProps`.** `className` is merged. `style` is merged too, but the component's own CSS variables are applied after yours.
|
|
145
|
+
|
|
146
|
+
**`faceProps`.** Applied after the component's default `role`, `aria-roledescription` and `aria-label`, so you can override them. As a function, it receives the face's index and `isFront`: whether that face is the one at the front right now.
|
|
147
|
+
|
|
148
|
+
## Sizing
|
|
149
|
+
|
|
150
|
+
The carousel is `100%` of its parent's width and height and uses `container-type: size`, which means **its size never depends on its content**. If its parent has no height, the carousel is 0px tall.
|
|
151
|
+
|
|
152
|
+
To make it a specific size, set `width`, `height`, `min-width` or `min-height` on the root with `style` or `className`. It can be bigger than its parent.
|
|
153
|
+
|
|
154
|
+
**`faceSize`** fixes the size of each face instead. The radius is then calculated from it: `radius = faceSize / (2 × sin(180° / n))`. Use it when faces must stay the same size however many sides there are (for example in an [arc](#arcs-and-scrollers)).
|
|
155
|
+
|
|
156
|
+
- Use any length: `px`, `rem`, or `cqw`/`cqh` to size relative to the carousel (`30cqw` is 30% of the carousel's width).
|
|
157
|
+
- **Don't use `%`.** The prism's depth is set with `transform-origin`, which doesn't accept percentages for Z, so the prism collapses. In development, the component warns about it in the console.
|
|
158
|
+
- A big `faceSize` makes a big prism, whose front face can come very close to the camera. Pull the camera back with a negative `cameraOffset`.
|
|
159
|
+
|
|
160
|
+
Anything outside the carousel's box is clipped (`contain: paint`). To show more of a large prism, make the carousel bigger.
|
|
161
|
+
|
|
162
|
+
**Gaps between faces:** add padding through `faceProps`. The wrappers use `box-sizing: border-box`, so padding shrinks your content, not the geometry.
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
faceProps={{ style: { paddingInline: 8 } }}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Rotation and animation
|
|
169
|
+
|
|
170
|
+
### `currentFace`
|
|
171
|
+
|
|
172
|
+
- **It isn't wrapped.** With 6 faces, `currentFace={6}` shows face 0 again and `-1` shows face 5. To loop smoothly, keep counting up (`f + 1`) instead of resetting to 0. Going from 5 back to 0 would spin the prism backwards through every face.
|
|
173
|
+
- **Fractions work.** `2.5` shows the prism halfway between faces 2 and 3, which is what you want for dragging (see below).
|
|
174
|
+
- **With `numEmptyFaces`, it's clamped** to `0 … children − 1`, so an empty side can never come to the front.
|
|
175
|
+
- `isFront` in `faceProps` uses the nearest whole face, wrapped into range.
|
|
176
|
+
|
|
177
|
+
### `transition`
|
|
178
|
+
|
|
179
|
+
A plain CSS `transition` value, applied to the prism. The rotation is its `transform` and `cameraOffset` is its `translate`:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
transition="transform 0.6s ease"
|
|
183
|
+
transition="transform 0.6s ease, translate 0.3s ease"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The default is no transition: changes are instant.
|
|
187
|
+
|
|
188
|
+
For users with `prefers-reduced-motion: reduce`, the transition is switched off. A `transition` you set yourself in `prismProps.style` still applies; that's treated as your explicit choice.
|
|
189
|
+
|
|
190
|
+
### Dragging
|
|
191
|
+
|
|
192
|
+
Drive `currentFace` with a fraction while dragging, turn the transition off during the drag, and round on release:
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
const [position, setPosition] = useState(0);
|
|
196
|
+
const [dragging, setDragging] = useState(false);
|
|
197
|
+
const start = useRef(null);
|
|
198
|
+
|
|
199
|
+
<Carousel
|
|
200
|
+
orientation="horizontal"
|
|
201
|
+
aria-label="Draggable carousel"
|
|
202
|
+
noChildrenElem={null}
|
|
203
|
+
currentFace={position}
|
|
204
|
+
transition={dragging ? undefined : "transform 0.4s ease-out"}
|
|
205
|
+
style={{ touchAction: "pan-y" }}
|
|
206
|
+
onPointerDown={(e) => {
|
|
207
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
208
|
+
start.current = { x: e.clientX, position };
|
|
209
|
+
setDragging(true);
|
|
210
|
+
}}
|
|
211
|
+
onPointerMove={(e) => {
|
|
212
|
+
if (!start.current) return;
|
|
213
|
+
const faceWidth = e.currentTarget.clientWidth / 2; // 6 faces: half the width
|
|
214
|
+
setPosition(start.current.position - (e.clientX - start.current.x) / faceWidth);
|
|
215
|
+
}}
|
|
216
|
+
onPointerUp={() => {
|
|
217
|
+
start.current = null;
|
|
218
|
+
setDragging(false);
|
|
219
|
+
setPosition((p) => Math.round(p));
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
{slides}
|
|
223
|
+
</Carousel>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Arcs and scrollers
|
|
227
|
+
|
|
228
|
+
`numEmptyFaces` adds invisible sides. A few real faces on a prism with many sides form a gentle arc, which works as a scroller with several faces visible at once. Set `faceSize` too, so the faces don't shrink as you add sides:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
<Carousel
|
|
232
|
+
orientation="horizontal"
|
|
233
|
+
aria-label="Products"
|
|
234
|
+
noChildrenElem={null}
|
|
235
|
+
currentFace={face}
|
|
236
|
+
numEmptyFaces={9}
|
|
237
|
+
faceSize="30cqw"
|
|
238
|
+
transition="transform 0.5s ease"
|
|
239
|
+
faceProps={(i) => ({ onClick: () => setFace(i) })}
|
|
240
|
+
>
|
|
241
|
+
{products}
|
|
242
|
+
</Carousel>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Camera
|
|
246
|
+
|
|
247
|
+
Three settings control what the viewer sees. Two of them are plain CSS on the root:
|
|
248
|
+
|
|
249
|
+
| | Set with | |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| Lens (field of view) | CSS `perspective` on the root | Default `1000px`. Smaller values give a wider view and stronger distortion. |
|
|
252
|
+
| Camera position, sideways and up/down | CSS `perspective-origin` on the root | Default `50% 50%`. `"50% -40%"` looks down on the prism from above. |
|
|
253
|
+
| Camera distance | the `cameraOffset` prop | Positive values bring the camera closer, negative values move it away. |
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
<Carousel
|
|
257
|
+
style={{ perspective: "600px", perspectiveOrigin: "50% -40%" }}
|
|
258
|
+
cameraOffset="-200px"
|
|
259
|
+
…
|
|
260
|
+
/>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`perspective-origin` moves the camera, but it keeps looking straight ahead, like a shifted lens. It doesn't turn to aim at the prism.
|
|
264
|
+
|
|
265
|
+
### What the camera can see
|
|
266
|
+
|
|
267
|
+
- **From outside a convex prism**, you see the near half. The far half is turned away from you.
|
|
268
|
+
- **From outside a concave prism**, you see the inside of the far half. The near half is turned away from you.
|
|
269
|
+
- **From inside**, you only see what's in front of the camera. How much of the ring that is depends on `perspective`: at `1000px` on a carousel about 900px wide, the view is about 50° wide, roughly one face out of eight. Anything behind the camera isn't drawn.
|
|
270
|
+
|
|
271
|
+
Faces turned away from the camera are still drawn, mirrored, unless you hide their backs (see [Backfaces](#backfaces-and-card-backs)).
|
|
272
|
+
|
|
273
|
+
### Putting the camera inside the prism
|
|
274
|
+
|
|
275
|
+
With `n` sides and radius `r` (half the carousel's width by default), the distance from the axis to each face is `a = r × cos(180° / n)`. To put the camera exactly on the axis:
|
|
276
|
+
|
|
277
|
+
- **convex:** `cameraOffset = perspective + a`
|
|
278
|
+
- **concave:** `cameraOffset = perspective − a`
|
|
279
|
+
|
|
280
|
+
For example, take 8 concave faces on a 918px-wide carousel: `r = 459px` and `a ≈ 424px`. With `perspective: 300px`, `cameraOffset="-124px"` puts the camera on the axis. A small `perspective` like that gives a wide enough view to see several faces around you.
|
|
281
|
+
|
|
282
|
+
### Concave mode
|
|
283
|
+
|
|
284
|
+
`concave` turns every face inward. Because you're now looking at the inside of the ring, increasing `currentFace` turns the prism the other way on screen than in convex mode.
|
|
285
|
+
|
|
286
|
+
## Styling faces
|
|
287
|
+
|
|
288
|
+
`faceProps` sets attributes on every face wrapper. Use an object for all faces, or a function for per-face values:
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
faceProps={(i, isFront) => ({
|
|
292
|
+
className: isFront ? "front" : "behind",
|
|
293
|
+
"aria-label": slides[i].title,
|
|
294
|
+
onClick: () => setFace(i),
|
|
295
|
+
})}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Dimming faces that aren't at the front:**
|
|
299
|
+
|
|
300
|
+
```css
|
|
301
|
+
.behind > * {
|
|
302
|
+
filter: brightness(0.45);
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
This styles your slide inside the wrapper rather than the wrapper itself. That matters for slides that use 3D themselves (card backs): `filter`, `opacity` and similar properties flatten 3D on the element they're applied to.
|
|
307
|
+
|
|
308
|
+
**Face height** (horizontal) or **width** (vertical) isn't part of the geometry, so you can change it. Faces stay centred:
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
faceProps={{ style: { height: "40%" } }}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Backfaces and card backs
|
|
315
|
+
|
|
316
|
+
The browser draws faces that are turned away as their mirror image. You have three options:
|
|
317
|
+
|
|
318
|
+
- **Hide them completely:** set `backface-visibility: hidden` on your slide.
|
|
319
|
+
- **Show them mirrored:** do nothing.
|
|
320
|
+
- **Show a "card back"**, where the slide's background shows from both sides but its content only from the front. Set `transform-style: preserve-3d` on the slide and `backface-visibility: hidden` on its content:
|
|
321
|
+
|
|
322
|
+
```css
|
|
323
|
+
.card {
|
|
324
|
+
transform-style: preserve-3d;
|
|
325
|
+
}
|
|
326
|
+
.card-content {
|
|
327
|
+
backface-visibility: hidden;
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
<div className="card">
|
|
333
|
+
<span className="card-content">{title}</span>
|
|
334
|
+
</div>
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Centerpiece
|
|
338
|
+
|
|
339
|
+
`centerpieceElem` is placed on the prism's axis, with the faces circling around it. It turns against the prism's rotation, so it always faces the viewer. Faces in front of it cover it, and it covers faces behind it.
|
|
340
|
+
|
|
341
|
+
Its wrapper spans the carousel's whole box, so centre your content yourself:
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
<Carousel
|
|
345
|
+
orientation="horizontal"
|
|
346
|
+
aria-label="Products around a logo"
|
|
347
|
+
noChildrenElem={null}
|
|
348
|
+
currentFace={face}
|
|
349
|
+
faceSize="22cqw"
|
|
350
|
+
style={{ perspectiveOrigin: "50% -40%" }}
|
|
351
|
+
faceProps={{ style: { height: "40%" } }}
|
|
352
|
+
centerpieceElem={
|
|
353
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
|
|
354
|
+
<Logo />
|
|
355
|
+
</div>
|
|
356
|
+
}
|
|
357
|
+
>
|
|
358
|
+
{products}
|
|
359
|
+
</Carousel>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Shorter faces and a raised camera, as in this example, let the centerpiece show above and below the ring.
|
|
363
|
+
|
|
364
|
+
## Clicks, focus and accessibility
|
|
365
|
+
|
|
366
|
+
### Clicks
|
|
367
|
+
|
|
368
|
+
Only what's inside the carousel's box is drawn and clickable (`contain: paint`). Faces that stick out of the box never cover or block neighbouring elements on the page.
|
|
369
|
+
|
|
370
|
+
Faces turned away from the viewer aren't made inert: in a scroller, several faces can be visible and clickable at once. (In 0.1.0, see [Known issues](#known-issues) for clicks that reach the wrong face.) If only the front face should be interactive and focusable:
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
faceProps={(i, isFront) => ({ inert: !isFront })}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Keyboard
|
|
377
|
+
|
|
378
|
+
There's no built-in keyboard handling. Add it on the root:
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
<Carousel
|
|
382
|
+
tabIndex={0}
|
|
383
|
+
onKeyDown={(e) => {
|
|
384
|
+
if (e.key === "ArrowRight") setFace((f) => f + 1);
|
|
385
|
+
if (e.key === "ArrowLeft") setFace((f) => f - 1);
|
|
386
|
+
}}
|
|
387
|
+
…
|
|
388
|
+
/>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### ARIA
|
|
392
|
+
|
|
393
|
+
The component follows the WAI-ARIA carousel pattern:
|
|
394
|
+
|
|
395
|
+
- The root has `role="region"` and `aria-roledescription="carousel"`. **Pass an `aria-label`** (or `aria-labelledby`) naming the carousel.
|
|
396
|
+
- Each face wrapper has `role="group"`, `aria-roledescription="slide"` and `aria-label="1 of 6"`. Override any of them through `faceProps`, for example to use the slide's title as its label.
|
|
397
|
+
|
|
398
|
+
All faces stay in the accessibility tree. Use the `inert` example above to hide all but the front face from screen readers too.
|
|
399
|
+
|
|
400
|
+
## Edge cases
|
|
401
|
+
|
|
402
|
+
| Children | `numEmptyFaces` | Result |
|
|
403
|
+
|---|---|---|
|
|
404
|
+
| 0 | any | `noChildrenElem`, flat and full size. |
|
|
405
|
+
| 1 | 0 | The single face, flat and full size, with no rotation. |
|
|
406
|
+
| 1 | > 0 | One face on an arc. |
|
|
407
|
+
| 2 | 0 | A two-sided card: both faces are full size, back to back, and each turn flips it 180°. Hide backfaces so you don't see the other face mirrored. |
|
|
408
|
+
|
|
409
|
+
## Overriding styles
|
|
410
|
+
|
|
411
|
+
The component's CSS lives in the cascade layer `react-prism-carousel.components`, so any CSS of yours that isn't in a layer overrides it. That includes CSS that breaks the prism. Avoid setting these:
|
|
412
|
+
|
|
413
|
+
- **On the prism and the face wrappers:** `position`, `inset`, `margin`, `transform`, `rotate`, `translate`, `transform-origin`, `transform-style`. On the face wrappers, also the width (horizontal) or height (vertical).
|
|
414
|
+
- **Properties that flatten 3D**, applied to the prism or the face wrappers: `overflow` other than `visible`, `opacity` below 1, `filter`, `clip-path`, `mask`, `mix-blend-mode`, `isolation: isolate`. On the prism, they flatten the whole carousel. On a face wrapper, they only affect slides that use 3D themselves. Put these effects on your slides instead.
|
|
415
|
+
|
|
416
|
+
Inside your slides, `cqw` and `cqh` refer to the carousel's root, since it's the nearest size container.
|
|
417
|
+
|
|
418
|
+
The `--prism-*` custom properties are internal and may change.
|
|
419
|
+
|
|
420
|
+
## TypeScript
|
|
421
|
+
|
|
422
|
+
Types ship with the package:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
import Carousel, { type CarouselProps, type FaceProps, type PrismProps } from "react-prism-carousel";
|
|
426
|
+
// or the named export: import { Carousel } from "react-prism-carousel";
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
- `CarouselProps`: every prop above, plus the root's HTML attributes.
|
|
430
|
+
- `FaceProps`: `React.HTMLAttributes<HTMLSpanElement>`.
|
|
431
|
+
- `PrismProps`: `React.HTMLAttributes<HTMLDivElement>`.
|
|
432
|
+
|
|
433
|
+
Every prop has a doc comment, so your editor shows its description on hover.
|
|
434
|
+
|
|
435
|
+
## Known issues
|
|
436
|
+
|
|
437
|
+
These are known in 0.1.0 and will be fixed in a later version.
|
|
438
|
+
|
|
439
|
+
- **Clicks can reach the wrong face.** In a concave prism, a click on a visible face lands on the wrapper of the face opposite it, so that face's `faceProps` handlers run instead. In a convex prism, clicks on faces angled away from the viewer (every face but the front one) can land on the prism element instead. Clicks on the front face of a convex prism work. As a workaround, give the prism a class and add this unlayered CSS:
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
<Carousel prismProps={{ className: "prism-hit-fix" }} … />
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
```css
|
|
446
|
+
.prism-hit-fix { pointer-events: none; }
|
|
447
|
+
.prism-hit-fix > * { pointer-events: auto; backface-visibility: hidden; }
|
|
448
|
+
/* the centerpiece wrapper is always the prism's last child */
|
|
449
|
+
.prism-hit-fix > :last-child { pointer-events: none; }
|
|
450
|
+
.prism-hit-fix > :last-child > * { pointer-events: auto; }
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
`backface-visibility` here applies only to the face wrappers, not to your slides, so it doesn't change what you see.
|
|
454
|
+
- **With `prefers-reduced-motion`, the centerpiece still animates** its turn while the prism changes instantly. Workaround, with the class above: `@media (prefers-reduced-motion: reduce) { .prism-hit-fix > :last-child { transition: none; } }`.
|
|
455
|
+
- **With exactly one face** (and no `numEmptyFaces`), changing `currentFace` with a transition spins the centerpiece a full turn. Keep `currentFace` at 0 when there's only one face.
|
|
456
|
+
|
|
457
|
+
## Developing
|
|
458
|
+
|
|
459
|
+
```sh
|
|
460
|
+
npm install --legacy-peer-deps # typescript-eslint doesn't accept TypeScript 7 as a peer yet
|
|
461
|
+
npm run dev # the demo page
|
|
462
|
+
npm run build # the package, into dist/
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
The demo page in `demos/` has one section per feature: horizontal and vertical prisms, an arc scroller, dragging, `faceProps`, concave mode and the camera, card backs, the centerpiece, and the 0-, 1- and 2-face edge cases.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-prism-carousel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A 3D prism carousel for React, drawn entirely in CSS. You control the rotation.",
|
|
5
|
+
"homepage": "g",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"react",
|
|
7
8
|
"carousel",
|
|
@@ -17,7 +18,6 @@
|
|
|
17
18
|
"type": "git",
|
|
18
19
|
"url": "git+https://github.com/BlueWebber/react-prism-carousel.git"
|
|
19
20
|
},
|
|
20
|
-
"homepage": "https://github.com/BlueWebber/react-prism-carousel#readme",
|
|
21
21
|
"bugs": {
|
|
22
22
|
"url": "https://github.com/BlueWebber/react-prism-carousel/issues"
|
|
23
23
|
},
|
|
@@ -46,7 +46,10 @@
|
|
|
46
46
|
"build": "tsc -b && vite build && tsc -p tsconfig.lib.json",
|
|
47
47
|
"lint": "eslint .",
|
|
48
48
|
"preview": "vite preview",
|
|
49
|
-
"prepublishOnly": "npm run build"
|
|
49
|
+
"prepublishOnly": "npm run build",
|
|
50
|
+
"build:demos": "tsc -b && vite build --mode demos",
|
|
51
|
+
"predeploy": "npm run build:demos",
|
|
52
|
+
"deploy": "gh-pages -d dist-demos --nojekyll"
|
|
50
53
|
},
|
|
51
54
|
"peerDependencies": {
|
|
52
55
|
"react": "^19.0.0"
|
|
@@ -60,6 +63,7 @@
|
|
|
60
63
|
"eslint": "^10.13.0",
|
|
61
64
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
62
65
|
"eslint-plugin-react-refresh": "^0.5.7",
|
|
66
|
+
"gh-pages": "^6.3.0",
|
|
63
67
|
"globals": "^17.13.0",
|
|
64
68
|
"react": "^19.3.1",
|
|
65
69
|
"react-dom": "^19.3.1",
|