From 46d4bbe52f099483209d5bebf23eb146f3c7157c Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Mon, 12 Jan 2026 17:03:40 +0100 Subject: [PATCH 1/6] fix: added basic support for slide width WIP --- src/components/AnimatedPagedView/index.tsx | 2 +- src/components/AnimatedPagedView/styles.ts | 2 +- src/components/HeroCarousel/index.tsx | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index 1d1c915..6ec5abe 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -90,7 +90,7 @@ export const AnimatedPagedView = forwardRef - + {props.children} diff --git a/src/components/AnimatedPagedView/styles.ts b/src/components/AnimatedPagedView/styles.ts index 8685d74..6320b44 100644 --- a/src/components/AnimatedPagedView/styles.ts +++ b/src/components/AnimatedPagedView/styles.ts @@ -4,7 +4,7 @@ export const styles = StyleSheet.create({ container: { flex: 1, width: '100%', - overflow: 'hidden', + overflow: 'visible', }, contentContainer: { flexDirection: 'row', diff --git a/src/components/HeroCarousel/index.tsx b/src/components/HeroCarousel/index.tsx index 8665fca..00b474b 100644 --- a/src/components/HeroCarousel/index.tsx +++ b/src/components/HeroCarousel/index.tsx @@ -7,6 +7,7 @@ import { useAutoScroll } from '../../hooks/useAutoScroll' import { useInfiniteScroll } from '../../hooks/useInfiniteScroll' import { DEFAULT_INTERVAL } from './index.preset' import { ItemAnimatedView } from '../ItemAnimatedView' +import { useWindowDimensions, View } from 'react-native' export type HeroCarouselProps = { children: React.ReactNode[] @@ -32,6 +33,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { scrollValue, disabled: disableInfiniteScroll, }) + const { width: windowWidth } = useWindowDimensions() const autoScrollEnabled = !userInteracted @@ -49,7 +51,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { }) return ( - <> + { @@ -70,7 +72,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { ))} - + ) } From 9bed1b4598b9922d22cc2097e35e61c03c8ed1f2 Mon Sep 17 00:00:00 2001 From: Gabriel Neutzling Date: Tue, 13 Jan 2026 09:35:17 -0300 Subject: [PATCH 2/6] fix: ts issue on export HeroCarouselProps --- src/components/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/index.ts b/src/components/index.ts index be36b76..59284a0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1 +1 @@ -export { HeroCarousel, HeroCarouselProps } from './HeroCarousel' +export { HeroCarousel, type HeroCarouselProps } from './HeroCarousel' From 48fab74cca79f54c333832f425e5c2d07344a7ee Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 18:18:44 +0200 Subject: [PATCH 3/6] fix: stop worklets capturing react elements The pan gesture, the scroll reaction and the infinite-scroll reaction read from props and the children array inside worklets. Reanimated 4 copies everything a worklet captures to the UI thread and throws on React elements in development (Cannot copy value of type FiberNode), so the carousel crashed on mount. They now capture only the callbacks and the slide count. Co-Authored-By: Claude Opus 5.5 --- src/components/AnimatedPagedView/index.tsx | 13 ++++++++----- src/hooks/useInfiniteScroll.ts | 8 +++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index 1d1c915..ebcf824 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -31,20 +31,23 @@ export const AnimatedPagedView = forwardRef { 'worklet' - return clamp(value, 0, (childrenArray.length - 1) * slideWidth) + return clamp(value, 0, (slideCount - 1) * slideWidth) }, - [childrenArray.length, slideWidth], + [slideCount, slideWidth], ) const gesture = Gesture.Pan() .minDistance(MIN_DISTANCE_THRESHOLD) .onStart(() => { context.value = { x: translateX.value } - runOnJS(props.onScrollBeginDrag)() + runOnJS(onScrollBeginDrag)() }) .onUpdate((event) => { translateX.value = clampValue(context.value.x - event.translationX) @@ -72,9 +75,9 @@ export const AnimatedPagedView = forwardRef translateX.value, (value) => { - props.onScroll?.(value) + onScroll?.(value) }, - [props.onScroll], + [onScroll], ) useImperativeHandle( diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 869c3f5..f90d5d9 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -26,6 +26,8 @@ export const useInfiniteScroll = ({ () => [childrenArray[childrenArray.length - 1], ...childrenArray, childrenArray[0]], [childrenArray], ) + // Worklets must not capture the array of React elements, only its length. + const paddedLength = paddedChildrenArray.length // This handles the infinite scrolling useAnimatedReaction( () => scrollValue.value, @@ -34,16 +36,16 @@ export const useInfiniteScroll = ({ const activeIndex = customRound(offset, ROUNDING_PRECISION) // if we are at the last index we need to switch to the second one without animation // second one because the first one is a clone of the last one - if (activeIndex >= paddedChildrenArray.length - 1) { + if (activeIndex >= paddedLength - 1) { goToPage(1) } // if we are at the first index we need to switch to the next to last one without animation // next to last one because the last one is a clone of the first one if (activeIndex <= 0.01) { - goToPage(paddedChildrenArray.length - 2, 0) + goToPage(paddedLength - 2, 0) } }, - [childrenArray.length, goToPage, paddedChildrenArray.length, slideWidth, scrollValue], + [goToPage, paddedLength, slideWidth, scrollValue], ) return useMemo( From f085dfd5e1f125109d04fd224251110fb8110626 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 18:18:44 +0200 Subject: [PATCH 4/6] perf: add renderWindow to mount only slides near the current one Every slide stayed mounted, so a long carousel paid for all of them on mount and ran every slide's animated styles on each frame. With renderWindow, slides further away render as empty slots of the same width. It's opt-in, since unmounting drops slide state such as video playback. With infinite scroll the window wraps around, so the slide a clone jumps to is already mounted. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/components/HeroCarousel/index.tsx | 26 ++++++++- src/components/HeroCarouselSlide/index.tsx | 8 +-- src/context/CarouselContext/index.tsx | 5 ++ src/utils/__tests__/isInRenderWindow.test.ts | 56 ++++++++++++++++++++ src/utils/isInRenderWindow.ts | 29 ++++++++++ 6 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/isInRenderWindow.test.ts create mode 100644 src/utils/isInRenderWindow.ts diff --git a/README.md b/README.md index 5c027ac..a3ad33f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ The context provider that must wrap your carousel components. **All carousel con | `disableAutoScroll` | `boolean` | `false` | Disable automatic scrolling | | `disableInfiniteScroll` | `boolean` | `false` | Disable infinite scrolling (shows first/last slide boundaries) | | `autoScrollAnimation` | `(to: number, duration: number) => number` | `withTiming` | Custom animation function for auto-scroll transitions | +| `renderWindow` | `number` | all slides | Slides kept mounted on each side of the current one; for long carousels | | `children` | `React.ReactNode` | Required | Carousel content (should contain HeroCarousel component) | #### `HeroCarousel` diff --git a/src/components/HeroCarousel/index.tsx b/src/components/HeroCarousel/index.tsx index 8665fca..1a22e66 100644 --- a/src/components/HeroCarousel/index.tsx +++ b/src/components/HeroCarousel/index.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useState } from 'react' +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated' import { CarouselContextProvider, useCarouselContext } from '../../context/CarouselContext' import { HeroCarouselSlide } from '../HeroCarouselSlide' @@ -7,6 +8,7 @@ import { useAutoScroll } from '../../hooks/useAutoScroll' import { useInfiniteScroll } from '../../hooks/useInfiniteScroll' import { DEFAULT_INTERVAL } from './index.preset' import { ItemAnimatedView } from '../ItemAnimatedView' +import { isInRenderWindow } from '../../utils/isInRenderWindow' export type HeroCarouselProps = { children: React.ReactNode[] @@ -24,6 +26,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { interval, disableAutoScroll, autoScrollAnimation, + renderWindow, } = useCarouselContext() const { paddedChildrenArray } = useInfiniteScroll({ children, @@ -33,6 +36,16 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { disabled: disableInfiniteScroll, }) + // Follows the page React-side only when the rounded page changes, not on every frame. + const [activeIndex, setActiveIndex] = useState(() => Math.round(scrollValue.value)) + + useAnimatedReaction( + () => Math.round(scrollValue.value), + (index, previous) => { + if (index !== previous) runOnJS(setActiveIndex)(index) + }, + ) + const autoScrollEnabled = !userInteracted const { runAutoScroll } = useAutoScroll({ @@ -66,7 +79,16 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { runAutoScroll={runAutoScroll} goToPage={goToPage} > - {child} + {isInRenderWindow({ + index, + activeIndex, + slideCount: children.length, + renderWindow, + isInfinite: !disableInfiniteScroll, + }) + ? child + : // An empty slot of the same width keeps every slide's position. + null} ))} diff --git a/src/components/HeroCarouselSlide/index.tsx b/src/components/HeroCarouselSlide/index.tsx index 0a6d09c..5f4c73f 100644 --- a/src/components/HeroCarouselSlide/index.tsx +++ b/src/components/HeroCarouselSlide/index.tsx @@ -1,10 +1,10 @@ import { View } from 'react-native' import { ItemContext } from '../../context/ItemContext' import { useAutoScroll } from '../../hooks/useAutoScroll' -import { useMemo } from 'react' +import { memo, useMemo } from 'react' import { useManualScroll } from '../../hooks/useManualScroll' -export const HeroCarouselSlide = ({ +export const HeroCarouselSlide = memo(function HeroCarouselSlide({ children, width, index, @@ -18,7 +18,7 @@ export const HeroCarouselSlide = ({ total: number runAutoScroll: ReturnType['runAutoScroll'] goToPage: ReturnType['goToPage'] -}) => { +}) { return ( ) -} +}) diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index 8fac90f..92e95fa 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -39,6 +39,8 @@ type ContextProps = { interval?: number | ((index: number) => number) disableAutoScroll?: boolean autoScrollAnimation?: (to: number, duration: number) => number + /** Slides kept mounted on each side of the current one. Mounts every slide when omitted. */ + renderWindow?: number } export const CarouselContextProvider = ({ @@ -48,6 +50,7 @@ export const CarouselContextProvider = ({ interval = DEFAULT_INTERVAL, disableAutoScroll = false, autoScrollAnimation = DEFAULT_ANIMATION, + renderWindow, ...rest }: ContextProps) => { const userInteracted = useUserInteracted() @@ -69,6 +72,7 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, + renderWindow, }), [ manualScroll, @@ -78,6 +82,7 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, + renderWindow, ], )} > diff --git a/src/utils/__tests__/isInRenderWindow.test.ts b/src/utils/__tests__/isInRenderWindow.test.ts new file mode 100644 index 0000000..718d2cc --- /dev/null +++ b/src/utils/__tests__/isInRenderWindow.test.ts @@ -0,0 +1,56 @@ +import { isInRenderWindow } from '../isInRenderWindow' + +describe('isInRenderWindow', () => { + it('mounts every slide without a render window', () => { + expect( + isInRenderWindow({ + index: 40, + activeIndex: 0, + slideCount: 50, + renderWindow: undefined, + isInfinite: false, + }), + ).toBe(true) + }) + + it('mounts slides within the window around the current one', () => { + const check = (index: number) => + isInRenderWindow({ + index, + activeIndex: 10, + slideCount: 50, + renderWindow: 2, + isInfinite: false, + }) + + expect([7, 8, 9, 10, 11, 12, 13].map(check)).toEqual([ + false, + true, + true, + true, + true, + true, + false, + ]) + }) + + // With infinite scroll and 10 slides, rendered index 0 clones slide 9, 1-10 are the real slides + // and 11 clones slide 0. + const infinite = (index: number, activeIndex: number) => + isInRenderWindow({ index, activeIndex, slideCount: 10, renderWindow: 2, isInfinite: true }) + + it('keeps the first real slide mounted while on the clone at the end', () => { + expect(infinite(1, 11)).toBe(true) + }) + + it('keeps the last real slide mounted while on the clone at the start', () => { + expect(infinite(10, 0)).toBe(true) + }) + + it('wraps the window around the ends', () => { + // On the first real slide, the last two real slides are its neighbours to the left. + expect(infinite(10, 1)).toBe(true) + expect(infinite(9, 1)).toBe(true) + expect(infinite(8, 1)).toBe(false) + }) +}) diff --git a/src/utils/isInRenderWindow.ts b/src/utils/isInRenderWindow.ts new file mode 100644 index 0000000..f661599 --- /dev/null +++ b/src/utils/isInRenderWindow.ts @@ -0,0 +1,29 @@ +/** + * Whether the slide at `index` stays mounted while `activeIndex` is the current one. + * Both are indexes into the rendered slides. With infinite scroll those start and end with a + * clone, so the distance is measured between the real slides and wraps around: the slide a clone + * jumps to is then always mounted. + */ +export const isInRenderWindow = ({ + index, + activeIndex, + slideCount, + renderWindow, + isInfinite, +}: { + index: number + activeIndex: number + /** Number of real slides, clones excluded. */ + slideCount: number + /** Slides kept mounted on each side of the current one. Everything is mounted when omitted. */ + renderWindow: number | undefined + isInfinite: boolean +}) => { + if (renderWindow === undefined) return true + if (!isInfinite) return Math.abs(index - activeIndex) <= renderWindow + + const toReal = (renderedIndex: number) => (renderedIndex - 1 + slideCount) % slideCount + const distance = Math.abs(toReal(index) - toReal(activeIndex)) + + return Math.min(distance, slideCount - distance) <= renderWindow +} From 705fbfd93e232a92d77bb054ae43196b3df1a30d Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 18:47:27 +0200 Subject: [PATCH 5/6] feat: add centerSlide to centre narrower slides with their neighbours peeking in Replaces the window-width margin and the visible overflow. With slideWidth narrower than the carousel, centerSlide offsets the moving row by half the free space, using the carousel's measured width, so the current slide is centred and its neighbours show on both sides inside the carousel's own bounds. The offset sits inside the gesture view, so swipes work on either peek. It's opt-in, so existing carousels don't move, and the pager clips again. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++++++ src/components/AnimatedPagedView/index.tsx | 18 +++++++++++++----- src/components/AnimatedPagedView/styles.ts | 2 +- src/context/CarouselContext/index.tsx | 5 +++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a3ad33f..ff151da 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ The context provider that must wrap your carousel components. **All carousel con | `disableInfiniteScroll` | `boolean` | `false` | Disable infinite scrolling (shows first/last slide boundaries) | | `autoScrollAnimation` | `(to: number, duration: number) => number` | `withTiming` | Custom animation function for auto-scroll transitions | | `renderWindow` | `number` | all slides | Slides kept mounted on each side of the current one; for long carousels | +| `centerSlide` | `boolean` | `false` | Centre the current slide when `slideWidth` is narrower, so neighbours peek in | | `children` | `React.ReactNode` | Required | Carousel content (should contain HeroCarousel component) | #### `HeroCarousel` @@ -355,6 +356,18 @@ Different carousel configurations using the compound pattern: > {slides} + +// Centred slides with the neighbours peeking in on both sides. +// Put the gap between slides inside each slide (half on each side), so the content stays centred. + + + {slides.map((slide) => ( + + + + ))} + + ``` ### Using HeroCarousel.AnimatedView diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index 4bd282b..9c7202d 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -1,4 +1,5 @@ -import React, { forwardRef, useCallback, useImperativeHandle } from 'react' +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' +import { Dimensions, type LayoutChangeEvent } from 'react-native' import Animated, { useSharedValue, useAnimatedStyle, @@ -29,7 +30,11 @@ export const AnimatedPagedView = forwardRef { const translateX = useSharedValue(0) const context = useSharedValue({ x: 0 }) - const { slideWidth } = useCarouselContext() + const { slideWidth, centerSlide } = useCarouselContext() + // The window's width is the best guess until the carousel has been measured. + const [containerWidth, setContainerWidth] = useState(() => Dimensions.get('window').width) + // Centring shifts the whole row, so the neighbours peek in on both sides of the current slide. + const centerOffset = centerSlide ? Math.max(0, (containerWidth - slideWidth) / 2) : 0 const childrenArray = React.Children.toArray(props.children) // Worklets must not capture `props`: it holds React elements, which can't be copied to the UI thread. const { onScroll, onScrollBeginDrag } = props @@ -68,9 +73,9 @@ export const AnimatedPagedView = forwardRef { const clampedTranslateX = clampValue(translateX.value) return { - transform: [{ translateX: -clampedTranslateX }], + transform: [{ translateX: centerOffset - clampedTranslateX }], } - }, []) + }, [clampValue, centerOffset]) useAnimatedReaction( () => translateX.value, @@ -93,7 +98,10 @@ export const AnimatedPagedView = forwardRef - + setContainerWidth(event.nativeEvent.layout.width)} + > {props.children} diff --git a/src/components/AnimatedPagedView/styles.ts b/src/components/AnimatedPagedView/styles.ts index 6320b44..8685d74 100644 --- a/src/components/AnimatedPagedView/styles.ts +++ b/src/components/AnimatedPagedView/styles.ts @@ -4,7 +4,7 @@ export const styles = StyleSheet.create({ container: { flex: 1, width: '100%', - overflow: 'visible', + overflow: 'hidden', }, contentContainer: { flexDirection: 'row', diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index 92e95fa..dfd06ba 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -41,6 +41,8 @@ type ContextProps = { autoScrollAnimation?: (to: number, duration: number) => number /** Slides kept mounted on each side of the current one. Mounts every slide when omitted. */ renderWindow?: number + /** Centres the current slide when `slideWidth` is narrower than the carousel, so its neighbours peek in on both sides. */ + centerSlide?: boolean } export const CarouselContextProvider = ({ @@ -51,6 +53,7 @@ export const CarouselContextProvider = ({ disableAutoScroll = false, autoScrollAnimation = DEFAULT_ANIMATION, renderWindow, + centerSlide = false, ...rest }: ContextProps) => { const userInteracted = useUserInteracted() @@ -73,6 +76,7 @@ export const CarouselContextProvider = ({ disableAutoScroll, autoScrollAnimation, renderWindow, + centerSlide, }), [ manualScroll, @@ -83,6 +87,7 @@ export const CarouselContextProvider = ({ disableAutoScroll, autoScrollAnimation, renderWindow, + centerSlide, ], )} > From 67831fd5f7798108dc731ba387913d10859be860 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 19:10:36 +0200 Subject: [PATCH 6/6] fix: centre slides with layout instead of measuring the carousel The offset came from the window's width until onLayout reported the carousel's own, so a carousel narrower than the window rendered shifted for its first frame. The row now starts at left: 50% less half a slide, which layout resolves against the carousel's real width from the first frame, with no state or measuring. Co-Authored-By: Claude Opus 5.5 --- src/components/AnimatedPagedView/index.tsx | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index 9c7202d..bcd9a5e 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -1,5 +1,4 @@ -import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react' -import { Dimensions, type LayoutChangeEvent } from 'react-native' +import React, { forwardRef, useCallback, useImperativeHandle } from 'react' import Animated, { useSharedValue, useAnimatedStyle, @@ -31,10 +30,9 @@ export const AnimatedPagedView = forwardRef Dimensions.get('window').width) - // Centring shifts the whole row, so the neighbours peek in on both sides of the current slide. - const centerOffset = centerSlide ? Math.max(0, (containerWidth - slideWidth) / 2) : 0 + // Starts the row at the carousel's middle, less half a slide. Layout resolves the 50% against the + // carousel's own width, so the current slide is centred from the first frame. + const centerStyle = centerSlide ? { left: '50%' as const, marginLeft: -slideWidth / 2 } : null const childrenArray = React.Children.toArray(props.children) // Worklets must not capture `props`: it holds React elements, which can't be copied to the UI thread. const { onScroll, onScrollBeginDrag } = props @@ -73,9 +71,9 @@ export const AnimatedPagedView = forwardRef { const clampedTranslateX = clampValue(translateX.value) return { - transform: [{ translateX: centerOffset - clampedTranslateX }], + transform: [{ translateX: -clampedTranslateX }], } - }, [clampValue, centerOffset]) + }, [clampValue]) useAnimatedReaction( () => translateX.value, @@ -98,11 +96,8 @@ export const AnimatedPagedView = forwardRef - setContainerWidth(event.nativeEvent.layout.width)} - > - + + {props.children}