overlapping-cards-scroll 0.1.0 → 0.1.2

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.
@@ -0,0 +1,943 @@
1
+ import {
2
+ Children,
3
+ Fragment,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import type React from 'react'
13
+ import type { ComponentProps, ComponentType, CSSProperties, ReactElement, ReactNode } from 'react'
14
+ import './OverlappingCardsScroll.css'
15
+
16
+ export interface CardItem {
17
+ name: string
18
+ id: string | number
19
+ jsx: ReactElement
20
+ }
21
+
22
+ export interface OverlappingCardsScrollTabProps {
23
+ name: string
24
+ index: number
25
+ position: 'above' | 'below'
26
+ isPrincipal: boolean
27
+ influence: number
28
+ animate: {
29
+ opacity: number
30
+ }
31
+ className: string
32
+ style: CSSProperties
33
+ ariaLabel: string
34
+ ariaCurrent?: 'page'
35
+ onClick: () => void
36
+ }
37
+
38
+ export interface OverlappingCardsScrollTabsContainerProps {
39
+ children: ReactNode
40
+ position: 'above' | 'below'
41
+ className: string
42
+ style: CSSProperties
43
+ ariaLabel: string
44
+ cardNames: string[]
45
+ activeIndex: number
46
+ progress: number
47
+ }
48
+
49
+ type SharedProps = {
50
+ className?: string
51
+ cardHeight?: number | string
52
+ cardWidth?: number | string
53
+ cardWidthRatio?: number
54
+ basePeek?: number
55
+ minPeek?: number
56
+ maxPeek?: number
57
+ showPageDots?: boolean
58
+ pageDotsPosition?: 'above' | 'below' | 'overlay'
59
+ pageDotsOffset?: number | string
60
+ pageDotsBehavior?: 'smooth' | 'auto'
61
+ pageDotsClassName?: string
62
+ cardContainerClassName?: string
63
+ cardContainerStyle?: CSSProperties
64
+ snapToCardOnRelease?: boolean
65
+ snapReleaseDelay?: number
66
+ focusTransitionDuration?: number
67
+ ariaLabel?: string
68
+ showTabs?: boolean
69
+ tabsPosition?: 'above' | 'below'
70
+ tabsOffset?: number | string
71
+ tabsBehavior?: 'smooth' | 'auto'
72
+ tabsClassName?: string
73
+ tabsComponent?: ComponentType<OverlappingCardsScrollTabProps>
74
+ tabsContainerComponent?: ComponentType<OverlappingCardsScrollTabsContainerProps>
75
+ }
76
+
77
+ type WithChildren = SharedProps & {
78
+ children: ReactNode
79
+ items?: never
80
+ }
81
+
82
+ type WithItems = SharedProps & {
83
+ items: CardItem[]
84
+ children?: never
85
+ }
86
+
87
+ type OverlappingCardsScrollProps = WithChildren | WithItems
88
+
89
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
90
+
91
+ const toCssDimension = (value) => (typeof value === 'number' ? `${value}px` : value)
92
+ const PAGE_DOT_POSITIONS = new Set(['above', 'below', 'overlay'])
93
+
94
+ const normalizePageDotsPosition = (value) =>
95
+ PAGE_DOT_POSITIONS.has(value) ? value : 'below'
96
+
97
+ const TAB_POSITIONS = new Set(['above', 'below'])
98
+
99
+ const normalizeTabsPosition = (value) =>
100
+ TAB_POSITIONS.has(value) ? value : 'above'
101
+
102
+ function DefaultTabsContainerComponent({
103
+ children,
104
+ className,
105
+ style,
106
+ ariaLabel,
107
+ }: OverlappingCardsScrollTabsContainerProps) {
108
+ return (
109
+ <nav className={className} style={style} aria-label={ariaLabel}>
110
+ {children}
111
+ </nav>
112
+ )
113
+ }
114
+
115
+ function DefaultTabsComponent({
116
+ name,
117
+ className,
118
+ style,
119
+ ariaLabel,
120
+ ariaCurrent,
121
+ onClick,
122
+ }: OverlappingCardsScrollTabProps) {
123
+ return (
124
+ <button
125
+ type="button"
126
+ className={className}
127
+ aria-label={ariaLabel}
128
+ aria-current={ariaCurrent}
129
+ onClick={onClick}
130
+ style={style}
131
+ >
132
+ {name}
133
+ </button>
134
+ )
135
+ }
136
+
137
+ const resolveCardX = (index, principalIndex, transitionProgress, layout) => {
138
+ if (index <= principalIndex) {
139
+ return index * layout.peek
140
+ }
141
+
142
+ let cardX =
143
+ principalIndex * layout.peek +
144
+ layout.cardWidth +
145
+ (index - principalIndex - 1) * layout.peek
146
+
147
+ if (index === principalIndex + 1) {
148
+ cardX -= transitionProgress * (layout.cardWidth - layout.peek)
149
+ }
150
+
151
+ return cardX
152
+ }
153
+
154
+ const OverlappingCardsScrollControllerContext = createContext(null)
155
+ const OverlappingCardsScrollCardIndexContext = createContext(null)
156
+
157
+ function useOverlappingCardsScrollCardControl() {
158
+ const controller = useContext(OverlappingCardsScrollControllerContext)
159
+ const cardIndex = useContext(OverlappingCardsScrollCardIndexContext)
160
+
161
+ const canFocus = controller !== null && cardIndex !== null
162
+ const focusCard = useCallback(
163
+ (options: { behavior?: string; transitionMode?: string; duration?: number } = {}) => {
164
+ if (!canFocus) {
165
+ return
166
+ }
167
+ controller.focusCard(cardIndex, options)
168
+ },
169
+ [canFocus, cardIndex, controller],
170
+ )
171
+
172
+ return {
173
+ cardIndex,
174
+ canFocus,
175
+ focusCard,
176
+ }
177
+ }
178
+
179
+ export interface OverlappingCardsScrollFocusTriggerProps
180
+ extends Omit<ComponentProps<'button'>, 'onClick'> {
181
+ children?: ReactNode
182
+ className?: string
183
+ behavior?: 'smooth' | 'auto'
184
+ transitionMode?: 'swoop' | 'instant'
185
+ onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
186
+ }
187
+
188
+ export function OverlappingCardsScrollFocusTrigger({
189
+ children = 'Make principal',
190
+ className = '',
191
+ behavior = 'smooth',
192
+ transitionMode = 'swoop',
193
+ onClick = undefined,
194
+ ...buttonProps
195
+ }: OverlappingCardsScrollFocusTriggerProps) {
196
+ const { canFocus, focusCard } = useOverlappingCardsScrollCardControl()
197
+
198
+ const handleClick = (event) => {
199
+ onClick?.(event)
200
+
201
+ if (!event.defaultPrevented) {
202
+ focusCard({ behavior, transitionMode })
203
+ }
204
+ }
205
+
206
+ const buttonClassName = className
207
+ ? `ocs-focus-trigger ${className}`
208
+ : 'ocs-focus-trigger'
209
+
210
+ return (
211
+ <button type="button" className={buttonClassName} disabled={!canFocus} onClick={handleClick} {...buttonProps}>
212
+ {children}
213
+ </button>
214
+ )
215
+ }
216
+
217
+ const resolveCardWidth = (cardWidth, viewportWidth, fallbackRatio) => {
218
+ if (typeof cardWidth === 'number' && Number.isFinite(cardWidth) && cardWidth > 0) {
219
+ return cardWidth
220
+ }
221
+
222
+ if (typeof cardWidth === 'string') {
223
+ const value = cardWidth.trim()
224
+ if (value.endsWith('%')) {
225
+ const percent = Number.parseFloat(value.slice(0, -1))
226
+ if (Number.isFinite(percent) && percent > 0) {
227
+ return (viewportWidth * percent) / 100
228
+ }
229
+ }
230
+
231
+ const numeric = Number.parseFloat(value)
232
+ if (Number.isFinite(numeric) && numeric > 0) {
233
+ return numeric
234
+ }
235
+ }
236
+
237
+ return viewportWidth * fallbackRatio
238
+ }
239
+
240
+ export function OverlappingCardsScroll(props: OverlappingCardsScrollProps) {
241
+ const {
242
+ className = '',
243
+ cardHeight = 300,
244
+ cardWidth = undefined,
245
+ cardWidthRatio = 1 / 3,
246
+ basePeek = 64,
247
+ minPeek = 10,
248
+ maxPeek = 84,
249
+ showPageDots = false,
250
+ pageDotsPosition = 'below',
251
+ pageDotsOffset = 10,
252
+ pageDotsBehavior = 'smooth',
253
+ pageDotsClassName = '',
254
+ cardContainerClassName = '',
255
+ cardContainerStyle = {},
256
+ snapToCardOnRelease = true,
257
+ snapReleaseDelay = 800,
258
+ focusTransitionDuration = 420,
259
+ ariaLabel = 'Overlapping cards scroll',
260
+ showTabs = false,
261
+ tabsPosition = 'above',
262
+ tabsOffset = 10,
263
+ tabsBehavior = 'smooth',
264
+ tabsClassName = '',
265
+ tabsComponent: TabsComponent = DefaultTabsComponent,
266
+ tabsContainerComponent: TabsContainerComponent = DefaultTabsContainerComponent,
267
+ } = props
268
+
269
+ const hasItems = 'items' in props && Array.isArray(props.items)
270
+ const hasChildren = 'children' in props && props.children != null
271
+
272
+ useEffect(() => {
273
+ if (hasItems && hasChildren) {
274
+ console.warn(
275
+ 'OverlappingCardsScroll: Both `items` and `children` were provided. `items` takes precedence.'
276
+ )
277
+ }
278
+ }, [hasItems, hasChildren])
279
+
280
+ const itemsProp = hasItems ? props.items : null
281
+ const childrenProp = hasChildren ? props.children : null
282
+
283
+ const cards = useMemo(() => {
284
+ if (itemsProp) {
285
+ return itemsProp.map((item) => (
286
+ <Fragment key={item.id}>{item.jsx}</Fragment>
287
+ ))
288
+ }
289
+ return Children.toArray(childrenProp) as ReactElement[]
290
+ }, [itemsProp, childrenProp])
291
+
292
+ const cardNames: string[] | null = useMemo(() => {
293
+ if (itemsProp) {
294
+ return itemsProp.map((item) => item.name)
295
+ }
296
+ return null
297
+ }, [itemsProp])
298
+
299
+ const cardCount = cards.length
300
+
301
+ const containerRef = useRef(null)
302
+ const scrollRef = useRef(null)
303
+ const touchStateRef = useRef(null)
304
+ const snapTimeoutRef = useRef(null)
305
+ const shouldSnapOnMouseMoveRef = useRef(false)
306
+ const focusTransitionTimeoutRef = useRef(null)
307
+
308
+ const [viewportWidth, setViewportWidth] = useState(1)
309
+ const [scrollLeft, setScrollLeft] = useState(0)
310
+ const [focusTransition, setFocusTransition] = useState(null)
311
+
312
+ const clearSnapTimeout = useCallback(() => {
313
+ if (snapTimeoutRef.current !== null) {
314
+ clearTimeout(snapTimeoutRef.current)
315
+ snapTimeoutRef.current = null
316
+ }
317
+ }, [])
318
+
319
+ const clearFocusTransitionTimeout = useCallback(() => {
320
+ if (focusTransitionTimeoutRef.current !== null) {
321
+ clearTimeout(focusTransitionTimeoutRef.current)
322
+ focusTransitionTimeoutRef.current = null
323
+ }
324
+ }, [])
325
+
326
+ const cancelFocusTransition = useCallback(() => {
327
+ clearFocusTransitionTimeout()
328
+ setFocusTransition(null)
329
+ }, [clearFocusTransitionTimeout])
330
+
331
+ useEffect(() => {
332
+ const containerElement = containerRef.current
333
+ const scrollElement = scrollRef.current
334
+ if (!containerElement || !scrollElement) {
335
+ return undefined
336
+ }
337
+
338
+ const syncScroll = () => {
339
+ setScrollLeft(scrollElement.scrollLeft)
340
+ }
341
+
342
+ const resizeObserver = new ResizeObserver((entries) => {
343
+ const entry = entries[0]
344
+ const width = entry?.contentRect?.width ?? 1
345
+ setViewportWidth(Math.max(1, width))
346
+ syncScroll()
347
+ })
348
+
349
+ resizeObserver.observe(containerElement)
350
+ setViewportWidth(Math.max(1, containerElement.getBoundingClientRect().width || 1))
351
+ syncScroll()
352
+
353
+ scrollElement.addEventListener('scroll', syncScroll, { passive: true })
354
+
355
+ return () => {
356
+ resizeObserver.disconnect()
357
+ scrollElement.removeEventListener('scroll', syncScroll)
358
+ }
359
+ }, [])
360
+
361
+ useEffect(() => () => clearSnapTimeout(), [clearSnapTimeout])
362
+ useEffect(() => () => clearFocusTransitionTimeout(), [clearFocusTransitionTimeout])
363
+
364
+ useEffect(() => {
365
+ if (snapToCardOnRelease && cardCount > 1) {
366
+ return
367
+ }
368
+
369
+ clearSnapTimeout()
370
+ shouldSnapOnMouseMoveRef.current = false
371
+ cancelFocusTransition()
372
+ }, [cancelFocusTransition, cardCount, clearSnapTimeout, snapToCardOnRelease])
373
+
374
+ useEffect(() => {
375
+ if (cardCount > 1) {
376
+ return
377
+ }
378
+
379
+ cancelFocusTransition()
380
+ }, [cancelFocusTransition, cardCount])
381
+
382
+ const layout = useMemo(() => {
383
+ const safeWidth = Math.max(1, viewportWidth)
384
+ const safeRatio = clamp(cardWidthRatio, 0.2, 0.95)
385
+ const width = Math.max(1, resolveCardWidth(cardWidth, safeWidth, safeRatio))
386
+
387
+ if (cardCount < 2) {
388
+ return {
389
+ cardWidth: width,
390
+ peek: 0,
391
+ stepDistance: 1,
392
+ scrollRange: 0,
393
+ trackWidth: safeWidth,
394
+ }
395
+ }
396
+
397
+ const availableStackWidth = Math.max(0, safeWidth - width)
398
+ const maxVisiblePeek = availableStackWidth / (cardCount - 1)
399
+ const preferredPeek = clamp(basePeek, minPeek, maxPeek)
400
+ const peek = Math.min(preferredPeek, maxVisiblePeek)
401
+
402
+ const stepDistance = Math.max(1, width - peek)
403
+ const scrollRange = stepDistance * (cardCount - 1)
404
+ const trackWidth = safeWidth + scrollRange
405
+
406
+ return {
407
+ cardWidth: width,
408
+ peek,
409
+ stepDistance,
410
+ scrollRange,
411
+ trackWidth,
412
+ }
413
+ }, [basePeek, cardCount, cardWidth, cardWidthRatio, maxPeek, minPeek, viewportWidth])
414
+
415
+ useEffect(() => {
416
+ const scrollElement = scrollRef.current
417
+ if (!scrollElement) {
418
+ return
419
+ }
420
+
421
+ if (scrollElement.scrollLeft > layout.scrollRange) {
422
+ scrollElement.scrollLeft = layout.scrollRange
423
+ setScrollLeft(layout.scrollRange)
424
+ }
425
+ }, [layout.scrollRange])
426
+
427
+ const progress = cardCount > 1 ? clamp(scrollLeft / layout.stepDistance, 0, cardCount - 1) : 0
428
+ const activeIndex = Math.floor(progress)
429
+ const transitionProgress = progress - activeIndex
430
+
431
+ const snapToNearestCard = useCallback(
432
+ (options: { behavior?: string } = {}) => {
433
+ if (!snapToCardOnRelease || cardCount < 2) {
434
+ return
435
+ }
436
+
437
+ const scrollElement = scrollRef.current
438
+ if (!scrollElement) {
439
+ return
440
+ }
441
+
442
+ const currentScrollLeft = clamp(scrollElement.scrollLeft, 0, layout.scrollRange)
443
+ const nearestIndex = clamp(
444
+ Math.round(currentScrollLeft / layout.stepDistance),
445
+ 0,
446
+ cardCount - 1,
447
+ )
448
+ const targetScrollLeft = clamp(
449
+ nearestIndex * layout.stepDistance,
450
+ 0,
451
+ layout.scrollRange,
452
+ )
453
+
454
+ if (Math.abs(targetScrollLeft - currentScrollLeft) < 1) {
455
+ return
456
+ }
457
+
458
+ const behavior = options.behavior ?? 'smooth'
459
+ if (typeof scrollElement.scrollTo === 'function') {
460
+ scrollElement.scrollTo({
461
+ left: targetScrollLeft,
462
+ behavior: behavior as ScrollBehavior,
463
+ })
464
+ } else {
465
+ scrollElement.scrollLeft = targetScrollLeft
466
+ }
467
+
468
+ if (behavior === 'auto') {
469
+ setScrollLeft(targetScrollLeft)
470
+ }
471
+ },
472
+ [cardCount, layout.scrollRange, layout.stepDistance, snapToCardOnRelease],
473
+ )
474
+
475
+ const scheduleSnapToNearestCard = useCallback(
476
+ (delay = snapReleaseDelay) => {
477
+ if (!snapToCardOnRelease || cardCount < 2) {
478
+ return
479
+ }
480
+
481
+ const safeDelay = Number.isFinite(delay) ? Math.max(0, delay) : 800
482
+ clearSnapTimeout()
483
+ snapTimeoutRef.current = setTimeout(() => {
484
+ snapTimeoutRef.current = null
485
+ shouldSnapOnMouseMoveRef.current = false
486
+ snapToNearestCard({ behavior: 'smooth' })
487
+ }, safeDelay)
488
+ },
489
+ [cardCount, clearSnapTimeout, snapReleaseDelay, snapToCardOnRelease, snapToNearestCard],
490
+ )
491
+
492
+ const markSnapCandidateFromScroll = useCallback(() => {
493
+ if (!snapToCardOnRelease || cardCount < 2) {
494
+ return
495
+ }
496
+
497
+ shouldSnapOnMouseMoveRef.current = true
498
+ scheduleSnapToNearestCard()
499
+ }, [cardCount, scheduleSnapToNearestCard, snapToCardOnRelease])
500
+
501
+ useEffect(() => {
502
+ if (typeof window === 'undefined' || !snapToCardOnRelease || cardCount < 2) {
503
+ return undefined
504
+ }
505
+
506
+ const handleMouseMove = () => {
507
+ if (!shouldSnapOnMouseMoveRef.current) {
508
+ return
509
+ }
510
+
511
+ shouldSnapOnMouseMoveRef.current = false
512
+ clearSnapTimeout()
513
+ snapToNearestCard({ behavior: 'smooth' })
514
+ }
515
+
516
+ window.addEventListener('mousemove', handleMouseMove, { passive: true })
517
+ return () => {
518
+ window.removeEventListener('mousemove', handleMouseMove)
519
+ }
520
+ }, [cardCount, clearSnapTimeout, snapToCardOnRelease, snapToNearestCard])
521
+
522
+ const focusCard = useCallback(
523
+ (
524
+ targetIndex: number,
525
+ options: { behavior?: string; transitionMode?: string; duration?: number } = {},
526
+ ) => {
527
+ const scrollElement = scrollRef.current
528
+ if (!scrollElement || cardCount === 0) {
529
+ return
530
+ }
531
+
532
+ clearSnapTimeout()
533
+ shouldSnapOnMouseMoveRef.current = false
534
+ cancelFocusTransition()
535
+
536
+ const safeIndex = clamp(Math.round(targetIndex), 0, cardCount - 1)
537
+ const nextScrollLeft = clamp(safeIndex * layout.stepDistance, 0, layout.scrollRange)
538
+ const transitionMode = options.transitionMode ?? 'swoop'
539
+
540
+ if (transitionMode === 'swoop') {
541
+ const duration = Number.isFinite(options.duration)
542
+ ? Math.max(0, options.duration)
543
+ : focusTransitionDuration
544
+
545
+ clearFocusTransitionTimeout()
546
+ setFocusTransition({ duration })
547
+
548
+ scrollElement.scrollLeft = nextScrollLeft
549
+ setScrollLeft(nextScrollLeft)
550
+
551
+ if (duration <= 0) {
552
+ setFocusTransition(null)
553
+ return
554
+ }
555
+
556
+ focusTransitionTimeoutRef.current = setTimeout(() => {
557
+ focusTransitionTimeoutRef.current = null
558
+ setFocusTransition(null)
559
+ }, duration + 40)
560
+ return
561
+ }
562
+
563
+ if (typeof scrollElement.scrollTo === 'function') {
564
+ scrollElement.scrollTo({
565
+ left: nextScrollLeft,
566
+ behavior: (options.behavior ?? 'smooth') as ScrollBehavior,
567
+ })
568
+ } else {
569
+ scrollElement.scrollLeft = nextScrollLeft
570
+ }
571
+
572
+ if ((options.behavior ?? 'smooth') === 'auto') {
573
+ setScrollLeft(nextScrollLeft)
574
+ }
575
+ },
576
+ [
577
+ cardCount,
578
+ cancelFocusTransition,
579
+ clearFocusTransitionTimeout,
580
+ clearSnapTimeout,
581
+ focusTransitionDuration,
582
+ layout.scrollRange,
583
+ layout.stepDistance,
584
+ ],
585
+ )
586
+
587
+ const controllerContextValue = useMemo(
588
+ () => ({
589
+ focusCard,
590
+ }),
591
+ [focusCard],
592
+ )
593
+
594
+ const setControllerScroll = useCallback(
595
+ (nextValue) => {
596
+ const scrollElement = scrollRef.current
597
+ if (!scrollElement) {
598
+ return
599
+ }
600
+
601
+ const nextScrollLeft = clamp(nextValue, 0, layout.scrollRange)
602
+ if (scrollElement.scrollLeft !== nextScrollLeft) {
603
+ scrollElement.scrollLeft = nextScrollLeft
604
+ }
605
+ setScrollLeft(nextScrollLeft)
606
+ },
607
+ [layout.scrollRange],
608
+ )
609
+
610
+ const applyScrollDelta = useCallback(
611
+ (delta) => {
612
+ const scrollElement = scrollRef.current
613
+ if (!scrollElement) {
614
+ return
615
+ }
616
+
617
+ setControllerScroll(scrollElement.scrollLeft + delta)
618
+ },
619
+ [setControllerScroll],
620
+ )
621
+
622
+ const handleWheel = useCallback(
623
+ (event: WheelEvent) => {
624
+ if (cardCount < 2) {
625
+ return
626
+ }
627
+
628
+ const absX = Math.abs(event.deltaX)
629
+ const absY = Math.abs(event.deltaY)
630
+
631
+ if (absX === 0 && absY === 0) {
632
+ return
633
+ }
634
+
635
+ // Let vertical-dominant scrolls pass through to child scrollables
636
+ if (absY > absX) {
637
+ return
638
+ }
639
+
640
+ event.preventDefault()
641
+ cancelFocusTransition()
642
+ applyScrollDelta(event.deltaX)
643
+ markSnapCandidateFromScroll()
644
+ },
645
+ [cardCount, cancelFocusTransition, applyScrollDelta, markSnapCandidateFromScroll],
646
+ )
647
+
648
+ const handleTouchStart = (event) => {
649
+ if (cardCount < 2) {
650
+ return
651
+ }
652
+
653
+ const scrollElement = scrollRef.current
654
+ const touch = event.touches[0]
655
+ if (!scrollElement || !touch) {
656
+ return
657
+ }
658
+
659
+ cancelFocusTransition()
660
+
661
+ touchStateRef.current = {
662
+ startX: touch.clientX,
663
+ startScrollLeft: scrollElement.scrollLeft,
664
+ }
665
+ }
666
+
667
+ const handleTouchMove = (event) => {
668
+ const touchState = touchStateRef.current
669
+ const touch = event.touches[0]
670
+ if (!touchState || !touch) {
671
+ return
672
+ }
673
+
674
+ const delta = touchState.startX - touch.clientX
675
+ if (Math.abs(delta) < 2) {
676
+ return
677
+ }
678
+
679
+ event.preventDefault()
680
+ setControllerScroll(touchState.startScrollLeft + delta)
681
+ markSnapCandidateFromScroll()
682
+ }
683
+
684
+ const handleTouchEnd = () => {
685
+ if (touchStateRef.current && snapToCardOnRelease && cardCount > 1) {
686
+ scheduleSnapToNearestCard(80)
687
+ }
688
+ touchStateRef.current = null
689
+ }
690
+
691
+ const stageRef = useRef(null)
692
+
693
+ useEffect(() => {
694
+ const stageElement = stageRef.current
695
+ if (!stageElement) {
696
+ return undefined
697
+ }
698
+
699
+ stageElement.addEventListener('wheel', handleWheel, { passive: false })
700
+ return () => {
701
+ stageElement.removeEventListener('wheel', handleWheel)
702
+ }
703
+ }, [handleWheel])
704
+
705
+ const containerClassName = className
706
+ ? `overlapping-cards-scroll ${className}`
707
+ : 'overlapping-cards-scroll'
708
+ const resolvedPageDotsPosition = normalizePageDotsPosition(pageDotsPosition)
709
+ const showNavigationDots = showPageDots && cardCount > 1
710
+
711
+ const resolvedTabsPosition = normalizeTabsPosition(tabsPosition)
712
+ const showNavigationTabs = showTabs && cardCount > 1 && cardNames !== null
713
+
714
+ useEffect(() => {
715
+ if (showTabs && cardNames === null) {
716
+ console.warn(
717
+ 'OverlappingCardsScroll: `showTabs` requires the `items` prop to provide card names. Tabs will not render.'
718
+ )
719
+ }
720
+ }, [showTabs, cardNames])
721
+
722
+ const renderTabs = (position: 'above' | 'below') => {
723
+ if (!showNavigationTabs || cardNames === null) {
724
+ return null
725
+ }
726
+
727
+ const containerClassName = tabsClassName
728
+ ? `ocs-tabs ocs-tabs--${position} ${tabsClassName}`
729
+ : `ocs-tabs ocs-tabs--${position}`
730
+
731
+ const containerStyle =
732
+ position === 'above'
733
+ ? { marginBottom: toCssDimension(tabsOffset) }
734
+ : { marginTop: toCssDimension(tabsOffset) }
735
+
736
+ return (
737
+ <TabsContainerComponent
738
+ position={position}
739
+ className={containerClassName}
740
+ style={containerStyle}
741
+ ariaLabel="Card tabs"
742
+ cardNames={cardNames}
743
+ activeIndex={activeIndex}
744
+ progress={progress}
745
+ >
746
+ {cardNames.map((name, index) => {
747
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1)
748
+ const isPrincipal = influence > 0.98
749
+ const animate = {
750
+ opacity: 0.45 + influence * 0.55,
751
+ }
752
+ const className = isPrincipal ? 'ocs-tab ocs-tab--active' : 'ocs-tab'
753
+ const style = { opacity: animate.opacity }
754
+
755
+ return (
756
+ <TabsComponent
757
+ key={`ocs-tab-${position}-${index}`}
758
+ name={name}
759
+ index={index}
760
+ position={position}
761
+ isPrincipal={isPrincipal}
762
+ influence={influence}
763
+ animate={animate}
764
+ className={className}
765
+ style={style}
766
+ ariaLabel={`Go to ${name}`}
767
+ ariaCurrent={isPrincipal ? 'page' : undefined}
768
+ onClick={() =>
769
+ focusCard(index, {
770
+ behavior: tabsBehavior,
771
+ transitionMode: 'swoop',
772
+ })
773
+ }
774
+ />
775
+ )
776
+ })}
777
+ </TabsContainerComponent>
778
+ )
779
+ }
780
+
781
+ return (
782
+ <OverlappingCardsScrollControllerContext.Provider value={controllerContextValue}>
783
+ <section className={containerClassName} aria-label={ariaLabel} ref={containerRef}>
784
+ {resolvedTabsPosition === 'above' ? renderTabs('above') : null}
785
+ {showNavigationDots && resolvedPageDotsPosition === 'above' ? (
786
+ <nav
787
+ className={
788
+ pageDotsClassName
789
+ ? `ocs-page-dots ocs-page-dots--above ${pageDotsClassName}`
790
+ : 'ocs-page-dots ocs-page-dots--above'
791
+ }
792
+ style={{ marginBottom: toCssDimension(pageDotsOffset) }}
793
+ aria-label="Card pages"
794
+ >
795
+ {cards.map((_, index) => {
796
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1)
797
+ const opacity = 0.25 + influence * 0.75
798
+ const scale = 0.9 + influence * 0.22
799
+
800
+ return (
801
+ <button
802
+ key={`ocs-page-dot-above-${index}`}
803
+ type="button"
804
+ className="ocs-page-dot"
805
+ aria-label={`Go to card ${index + 1}`}
806
+ aria-current={influence > 0.98 ? 'page' : undefined}
807
+ onClick={() =>
808
+ focusCard(index, {
809
+ behavior: pageDotsBehavior,
810
+ transitionMode: 'swoop',
811
+ })
812
+ }
813
+ style={{ opacity, transform: `scale(${scale})` }}
814
+ />
815
+ )
816
+ })}
817
+ </nav>
818
+ ) : null}
819
+ <div className="ocs-stage-frame">
820
+ <div
821
+ className="ocs-stage"
822
+ ref={stageRef}
823
+ style={{
824
+ minHeight: toCssDimension(cardHeight),
825
+ }}
826
+ onTouchStart={handleTouchStart}
827
+ onTouchMove={handleTouchMove}
828
+ onTouchEnd={handleTouchEnd}
829
+ onTouchCancel={handleTouchEnd}
830
+ >
831
+ <div
832
+ className="ocs-track"
833
+ >
834
+ {cards.map((card, index) => {
835
+ const cardX = resolveCardX(index, activeIndex, transitionProgress, layout)
836
+
837
+ return (
838
+ <div
839
+ key={card.key ?? `ocs-card-${index}`}
840
+ className={
841
+ cardContainerClassName
842
+ ? `${focusTransition ? 'ocs-card ocs-card--focus-transition' : 'ocs-card'} ${cardContainerClassName}`
843
+ : focusTransition
844
+ ? 'ocs-card ocs-card--focus-transition'
845
+ : 'ocs-card'
846
+ }
847
+ style={{
848
+ width: `${layout.cardWidth}px`,
849
+ transform: `translate3d(${cardX}px, 0, 0)`,
850
+ transitionDuration: focusTransition ? `${focusTransition.duration}ms` : undefined,
851
+ ...cardContainerStyle,
852
+ }}
853
+ >
854
+ <OverlappingCardsScrollCardIndexContext.Provider value={index}>
855
+ {card}
856
+ </OverlappingCardsScrollCardIndexContext.Provider>
857
+ </div>
858
+ )
859
+ })}
860
+ </div>
861
+ <div className="ocs-scroll-region" ref={scrollRef}>
862
+ <div
863
+ className="ocs-scroll-spacer"
864
+ style={{
865
+ width: `${layout.trackWidth}px`,
866
+ }}
867
+ />
868
+ </div>
869
+ </div>
870
+ {showNavigationDots && resolvedPageDotsPosition === 'overlay' ? (
871
+ <nav
872
+ className={
873
+ pageDotsClassName
874
+ ? `ocs-page-dots ocs-page-dots--overlay ${pageDotsClassName}`
875
+ : 'ocs-page-dots ocs-page-dots--overlay'
876
+ }
877
+ style={{ bottom: toCssDimension(pageDotsOffset) }}
878
+ aria-label="Card pages"
879
+ >
880
+ {cards.map((_, index) => {
881
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1)
882
+ const opacity = 0.25 + influence * 0.75
883
+ const scale = 0.9 + influence * 0.22
884
+
885
+ return (
886
+ <button
887
+ key={`ocs-page-dot-overlay-${index}`}
888
+ type="button"
889
+ className="ocs-page-dot"
890
+ aria-label={`Go to card ${index + 1}`}
891
+ aria-current={influence > 0.98 ? 'page' : undefined}
892
+ onClick={() =>
893
+ focusCard(index, {
894
+ behavior: pageDotsBehavior,
895
+ transitionMode: 'swoop',
896
+ })
897
+ }
898
+ style={{ opacity, transform: `scale(${scale})` }}
899
+ />
900
+ )
901
+ })}
902
+ </nav>
903
+ ) : null}
904
+ </div>
905
+ {showNavigationDots && resolvedPageDotsPosition === 'below' ? (
906
+ <nav
907
+ className={
908
+ pageDotsClassName
909
+ ? `ocs-page-dots ocs-page-dots--below ${pageDotsClassName}`
910
+ : 'ocs-page-dots ocs-page-dots--below'
911
+ }
912
+ style={{ marginTop: toCssDimension(pageDotsOffset) }}
913
+ aria-label="Card pages"
914
+ >
915
+ {cards.map((_, index) => {
916
+ const influence = clamp(1 - Math.abs(progress - index), 0, 1)
917
+ const opacity = 0.25 + influence * 0.75
918
+ const scale = 0.9 + influence * 0.22
919
+
920
+ return (
921
+ <button
922
+ key={`ocs-page-dot-below-${index}`}
923
+ type="button"
924
+ className="ocs-page-dot"
925
+ aria-label={`Go to card ${index + 1}`}
926
+ aria-current={influence > 0.98 ? 'page' : undefined}
927
+ onClick={() =>
928
+ focusCard(index, {
929
+ behavior: pageDotsBehavior,
930
+ transitionMode: 'swoop',
931
+ })
932
+ }
933
+ style={{ opacity, transform: `scale(${scale})` }}
934
+ />
935
+ )
936
+ })}
937
+ </nav>
938
+ ) : null}
939
+ {resolvedTabsPosition === 'below' ? renderTabs('below') : null}
940
+ </section>
941
+ </OverlappingCardsScrollControllerContext.Provider>
942
+ )
943
+ }