Repository navigation
feat: add Carousel component - #5123
andriicallstack wants to merge 4 commits into
Conversation
Adds the Material 3 Expressive Carousel with all four layouts — multi-browse, hero (start- and centre-aligned), uncontained and full-screen. Items are never resized. Every item is measured at the large size for the whole of its life; the apparent size change is an interpolated mask rectangle plus a translation, with items stacked in descending order so a collapsing item passes under the one before it. The corner radius sits on the mask rather than on the item box, so it travels with the visible rectangle. Layout runs on an unmasked axis where every item occupies one item size, and a keyline list maps a position on that axis onto a drawn centre and a masked size. Near either end the keylines blend into permuted states that put the focal range hard against the start or the end, so the first and last items can be focal. Multi-browse and hero resolve their arrangements through a cost-minimising solver; uncontained and full-screen need none. Each layout gets its own fling behaviour: single-advance for hero and full-screen, decay-plus-spring across several items for multi-browse, and no snap at all for uncontained. Where it snaps, the target is the focal keyline offset — a whole number of items on the unmasked axis — not the container edge. renderItem receives the item's live mask as shared values, the equivalent of OnMaskChangedListener and carouselItemDrawInfo.maskRect in the reference implementations. CarouselItemContent pins to the mask's leading edge and fades as the item collapses. Scroll-driven mask interpolation runs on the UI thread through Reanimated shared values; the state layer, focus ring, disabled opacity and hover elevation are not scroll-driven and run as CSS transitions.
Seven of the tokens mirrored from `md.comp.carousel-item.*` had no consumer, which read as if the component themed things it does not. The `label-text.*` group goes: MDC draws an item's label itself, but in Paper the item's content — text included — is rendered by the caller, so there is nothing for a label colour to paint, and disabled dims the content as a whole rather than per-run. The `dragged.*` group goes too: a drag on an item scrolls the strip, and React Native hands the responder to the scroll view, which ends the item's press, so no dragged state is reachable. The focus and pressed elevations stay and are now read: elevation resolves from the interaction state rather than testing hover alone, so the fact that hover is the only state that lifts an item is expressed in the tokens instead of in a branch. A test walks the token table and fails on any entry the component never reads, so this cannot drift back.
The settle was the scroll view's own deceleration curve: `snapToInterval` with `disableIntervalMomentum` for single-advance and plain momentum for multi-item. That behaved roughly right but was the platform's motion, not the spec's, and it left the carousel with no imperative tier at all. Native deceleration is now switched off wherever the carousel snaps, so the scroll view stops when the finger lifts and we own the settle: a shared value sprung with `withSpring(toRawSpring(motion.spring.default .spatial))`, written into the scroll view from the UI thread. A new touch cancels it, which is what makes the settle interruptible. Programmatic `scrollToIndex` moves settle on the same spring, and the spring honours reduce-motion. An uncontained carousel does not snap, so it keeps native momentum. Release velocity is measured from successive scroll offsets rather than read from the drag-end event: the event's units and sign vary between platforms, whereas the offsets do not. The decay projection and the snap target move into `resolveSettleOffset`, a pure worklet, so the three fling behaviours are unit-tested directly instead of inferred from scroll view props — single-advance clamps to one item from where the drag began however hard it is thrown, multi rides the projection across as many items as the fling earned, and both land on a focal keyline and never past either end. The example grows Previous / Next buttons that drive the carousel through its ref, showing the programmatic path uses the same spring.
`main` removed derived testIDs across the library, so the carousel should
not arrive with new ones. `${testID}-scroll-view` and `${testID}-item-N`
are gone; `testID` now lands on the scroll view, which is the element a
caller actually addresses.
That meant the scroll view could no longer be conditional on having been
measured, since it carries the testID and the layout callback. It now
always renders and the items wait for the arrangement instead, which also
removes a layout jump on first paint.
There was a problem hiding this comment.
🟡 Changes recommended
Blocking edge-case hangs, layout-contract violations, callback inconsistencies, accessibility gaps, and native validation concerns remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a Material 3 Carousel component with four layouts, animated masking, snapping, theming, documentation, and examples.
Changes:
- Implements carousel geometry, rendering, interaction, and motion.
- Exposes public components, handles, types, and theme tokens.
- Adds tests, documentation configuration, and an example screen.
File summaries
| File | Description |
|---|---|
src/index.tsx |
Exports the Carousel API. |
src/components/Carousel/utils.ts |
Adds placement and settling utilities. |
src/components/Carousel/types.ts |
Defines public Carousel types. |
src/components/Carousel/tokens.ts |
Defines visual and geometry tokens. |
src/components/Carousel/strategy.ts |
Implements layout strategies and keylines. |
src/components/Carousel/index.ts |
Defines module exports. |
src/components/Carousel/CarouselItemShell.tsx |
Renders masked, interactive items. |
src/components/Carousel/CarouselItem.tsx |
Adds item-content helpers. |
src/components/Carousel/Carousel.tsx |
Implements scrolling, settling, and virtualization. |
src/components/__tests__/Carousel.test.tsx |
Tests layouts, placement, interaction, and tokens. |
example/src/Examples/CarouselExample.tsx |
Demonstrates all layouts and states. |
example/src/ExampleList.tsx |
Registers the example. |
docs/src/data/themeColors.ts |
Documents theme color mappings. |
docs/component-docs.config.ts |
Registers generated component documentation. |
Review details
Suppressed comments (6)
src/components/Carousel/strategy.ts:396
- When there are fewer items than fit, this branch expands them to fill the container. A single requested 160dp item in a 360dp container becomes 360dp, contradicting the public promise that uncontained items retain their requested width/aspect ratio. Return the requested-size items and leave the unused trailing space instead.
if (mediumSize <= 0 || itemCount <= largeCount) {
// No cut-off item, so the large items have to fill the container on their own.
largeSize = available / largeCount;
src/components/Carousel/Carousel.tsx:293
- Ignoring a stale sample without clearing
velocitypreserves the last fast movement. If the user drags quickly, pauses, then releases,onEndDragcan project that old velocity and fling even though release velocity is zero. Reset velocity after stale gaps and also reject a velocity whose final sample is stale at release.
// Ignore stale gaps; a resumed scroll would otherwise read as a fling.
if (elapsed > 0 && elapsed < 100) {
const sample = ((offset - lastOffset.value) / elapsed) * 1000;
velocity.value = velocity.value * 0.7 + sample * 0.3;
src/components/Carousel/Carousel.tsx:352
- The non-animated path reports the requested clamped index rather than the index represented by the clamped target. Near the end,
targetmay bemaxScroll; with multiple focal items, requesting the last item therefore reports a different index than the animated path for the same final offset. ReportMath.round(target / strategy.itemSize)so callback behavior is animation-independent.
} else {
scrollRef.current?.scrollTo({ x: target, animated: false });
reportIndex(clamped);
src/components/Carousel/Carousel.tsx:329
- Despite the preceding comment, this reports momentum-end events for snapping layouts too. If native momentum ends at the release offset before the custom spring reaches its target, consumers receive an intermediate index and then another index from the spring completion, violating “once scrolling settles.” Gate this handler to
strategy.snap === 'none'and update the component test to exercise the appropriate layout.
if (itemSize > 0) {
reportIndex(Math.round(event.nativeEvent.contentOffset.x / itemSize));
}
src/components/Carousel/Carousel.tsx:307
- The new native-critical settle path has no test that drives begin-drag/end-drag, verifies spring handoff, or interrupts an in-flight settle; the current tests only exercise the pure target resolver, and the PR notes that this path has not run on iOS or Android. Add handler-level coverage and complete native validation before merging.
onEndDrag: (event) => {
if (!strategy) return;
src/components/Carousel/strategy.ts:353
- The two-item cap can defeat the advertised hero aspect-ratio cap. For example, a 1200×200 start-aligned carousel computes
(1200 - 56) / 2 = 572, exceedingmaxLargeWidth(400). The wide-container fallback needs to preserve the 2×height limit rather than always redistributing all remaining width across at most two focal items.
Math.ceil(spaceForLarge / Math.max(maxLargeWidth, 1)),
1,
Math.min(HERO_LARGE_COUNT_MAX, Math.max(itemCount - smallCount, 1))
);
const largeSize = spaceForLarge / largeCount;
- Files reviewed: 16/16 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const largeCounts = descendingRange( | ||
| Math.max(1, Math.floor(available / targetLarge)), | ||
| 1 | ||
| ); |
| const lastTimestamp = useSharedValue(0); | ||
|
|
||
| const [containerWidth, setContainerWidth] = React.useState(0); | ||
| const [windowStart, setWindowStart] = React.useState(initialIndex); |
| const { itemSize } = strategy; | ||
| const contentWidth = Math.max(itemSize - spacing, 0); |
| {content} | ||
| {interactive ? ( | ||
| <Pressable | ||
| style={[ | ||
| StyleSheet.absoluteFill, | ||
| Platform.OS === 'web' ? webNoOutline : undefined, | ||
| ]} | ||
| onPress={() => onPress(item, index)} | ||
| onPressIn={() => setPressed(true)} | ||
| onPressOut={() => setPressed(false)} | ||
| onHoverIn={() => setHovered(true)} | ||
| onHoverOut={() => setHovered(false)} | ||
| onFocus={(event) => { | ||
| if (!isKeyboardFocusEvent(event)) return; | ||
| setFocused(true); | ||
| }} | ||
| onBlur={() => setFocused(false)} | ||
| role="button" | ||
| aria-disabled={disabled} | ||
| /> | ||
| ) : null} |
There was a problem hiding this comment.
Leftover from development
|
Review by Claude iOSMulti-browse rests with no large item on the second-to-last index
What happens: the carousel settles with three items of about the same medium width. None of them is large, and all three labels stay half faded. "Focused item" says Chameleon, but no item is focal. What should happen: every resting position shows a large focal item, like index 3 and index 5 do. The cause is in the keyline states, see the inline comment on Items jump out of place for a few frames during a fling
What happens: for 2 to 3 frames in a row, the items shift sideways and leave a large empty gap at one edge of the carousel. These are three consecutive frames from one fling: What should happen: the items always fill the container edge to edge while they move. More detail in the inline comment on AndroidSame frame skipping during a fling
What happens: the same sideways jump with an empty gap on the right, here on the frames just before it settles on Forest: What should happen: the items always fill the container. (via Claude, verify if it makes sense) |
There was a problem hiding this comment.
Review by Claude
Device results for points already raised:
- The
onMomentumScrollEndpoint from the Copilot review: during one fling settle on iOS, "Focused item" changed to City, then Chameleon, then Strawberries. - The unnamed item button: the iOS accessibility tree lists the item buttons with no label.
itemSpacingshrinkingitemWidth: uncontained withitemWidth={260}draws 252 pt items. (via Claude, verify if it makes sense)
| const defaultState = buildState(sizes, focalStart, focalEnd, containerSize); | ||
| const startState = buildState( | ||
| [...focal, ...outward], | ||
| 0, | ||
| focal.length - 1, | ||
| containerSize | ||
| ); | ||
| const endState = buildState( | ||
| [...outward.slice().reverse(), ...focal], | ||
| outward.length, | ||
| outward.length + focal.length - 1, | ||
| containerSize | ||
| ); |
There was a problem hiding this comment.
Each start and end state is a full permutation, and resolveItemPlacement blends from the default state straight into it over endShift. For multi-browse, endShift is 2 items ([L, M, S] to [S, M, L]), so the halfway point is a whole item offset and a valid snap target. At that offset every keyline is a blend of a large and a small size, so no item is large. On device the carousel rests at index n - 2 with three medium items and all labels half faded, and onIndexChange reports an item that is not focal:
Build one intermediate state per item of shift, moving one keyline at a time (like the start and end steps in MDC's KeylineStateList), and interpolate between neighboring steps. Then every whole item offset is a valid arrangement with a large item. (via Claude, verify if it makes sense)
|
|
||
| it('snaps on whole items, so every snap target is a focal keyline', () => { | ||
| const strategy = strategyFor('multi-browse', { itemCount: 10 }); | ||
| for (let index = 0; index < 8; index++) { |
There was a problem hiding this comment.
This loop stops at index 7, which is where the end shift starts (maxScroll is 9 items and endShift is 2). Index 8 is the snap target that breaks. With index < 10 this test fails with "Expected: 200, Received: 120". Cover every index up to itemCount - 1:
| for (let index = 0; index < 8; index++) { | |
| for (let index = 0; index < 10; index++) { |
(via Claude, verify if it makes sense)
| const boxStyle = useAnimatedStyle( | ||
| () => ({ | ||
| // The box is drawn in container coordinates but lives inside the scrolling | ||
| // content, so the scroll offset is added back in. | ||
| transform: [ | ||
| { translateX: scrollX.value + placement.value.center - itemSize / 2 }, | ||
| ], | ||
| }), | ||
| [itemSize] | ||
| ); |
There was a problem hiding this comment.
Items cancel the native scroll by adding scrollX.value back into their transform. Reanimated pauses its commits while a React commit is in progress, so during any re-render the item transforms keep an old scrollX while the native content keeps moving, and the items jump sideways (details in the reply below). A screen recording of a fling shows 2 to 3 frames in a row with a large empty gap, on iOS and on Android:
One fix is to stop moving the content natively. Drive one offset shared value from a Gesture Handler pan (with withDecay or withSpring for the settle) and derive every item transform from it, so the offset and the transforms always change in the same frame. That also removes the dependency on decelerationRate={0}. (via Claude, verify if it makes sense)
There was a problem hiding this comment.
The cause is React commits during the fling. Reanimated pauses its own UI-thread commits while a React commit is in progress (pauseReanimatedCommits in ReanimatedCommitHook.cpp, released in ReanimatedMountHook.cpp). For those frames the item transforms keep their old scrollX, while the native content offset keeps moving, so the items slide sideways and leave the gap. The onScroll worklet itself runs in the same frame as the scroll.
Two things in the PR commit during a fling: setWindowStart from the render window reaction (each time an item boundary passes), and onIndexChange, which the example uses to re-render the whole screen and which fires several times per settle. I counted frames with a gap wider than 70 pt over 6 flings on the iOS simulator:
- PR as is: 11 and 8 frames in two runs (about 40 React commits, 19 of them longer than 30 ms)
- render window updates only: 4 frames
onIndexChangeonly: 11 frames- neither (all items mounted, no
onIndexChange): 1 and 0 frames
So any re-render on the screen while the carousel moves causes the jump, also re-renders that the app does. Driving one shared offset from a Gesture Handler pan, as suggested above, keeps the offset and the transforms in the same commit. Cutting the re-renders reduces the jumps but does not remove them for the app's own renders. (via Claude, verify if it makes sense)
| }} | ||
| onBlur={() => setFocused(false)} | ||
| role="button" | ||
| aria-disabled={disabled} |
There was a problem hiding this comment.
aria-disabled={disabled} is always false here. The Pressable only renders when interactive is true, and interactive already excludes disabled. So with disabled and onItemPress set, the item button disappears from the accessibility tree and is not announced as a disabled button. Render the Pressable whenever onItemPress is set, and pass disabled to it. (via Claude, verify if it makes sense)
| onFocus={(event) => { | ||
| if (!isKeyboardFocusEvent(event)) return; | ||
| setFocused(true); | ||
| }} |
There was a problem hiding this comment.
Keyboard focus does not move the carousel. On web with onItemPress set, Tab moves the focus ring onto the collapsed medium and small items at the edge, and the carousel stays where it is. The focused item is then a narrow strip with its label faded out. Scroll the focused item onto the focal keyline when it gets keyboard focus, for example with a callback from Carousel that calls scrollToIndex(index) from this onFocus. (via Claude, verify if it makes sense)
| /** | ||
| * The item's live mask, from `renderItem`. | ||
| */ | ||
| mask: CarouselItemMask; |
There was a problem hiding this comment.
Callers have to pass mask from renderItem into each CarouselItemContent. The shell already owns the mask, so it can provide it through a context and CarouselItemContent can read it from there. That removes the prop drilling and the chance of passing the mask of another item. (via Claude, verify if it makes sense)
| const animatedStyle = useAnimatedStyle(() => { | ||
| const { left, right } = mask.rect.value; | ||
| return { | ||
| transform: [{ translateX: direction === 'rtl' ? right - width : left }], |
There was a problem hiding this comment.
This RTL branch uses right - width, where width is the full item content width and not the width of this view. The view has no fixed width (left: 0, bottom: 0), so at full expansion the translation is 0 and the content stays at the left edge. As the mask collapses, the value goes negative and moves the content out of the mask. The Carousel docs also say RTL is not handled. Remove the branch until RTL is supported, or measure this view and use its own width. (via Claude, verify if it makes sense)
| keyExtractor?: (item: ItemT, index: number) => string; | ||
| style?: StyleProp<ViewStyle>; | ||
| contentContainerStyle?: StyleProp<ViewStyle>; |
There was a problem hiding this comment.
keyExtractor, style, contentContainerStyle and testID have no description. CONTRIBUTING asks for one on every prop, because the docs pages are generated from them.
contentContainerStyle also exposes an internal view. Its width is computed from the keylines (strategy.contentSize) and the consumer style merges after it, so a caller width or padding changes the scroll range without changing maxScroll or the snap targets. Remove contentContainerStyle, and document the other props. (via Claude, verify if it makes sense)
| it('declares no token the component does not consume', () => { | ||
| const source = [ | ||
| readFileSync( | ||
| join(__dirname, '../Carousel/CarouselItemShell.tsx'), | ||
| 'utf8' | ||
| ), | ||
| readFileSync(join(__dirname, '../Carousel/utils.ts'), 'utf8'), | ||
| ].join('\n'); | ||
|
|
||
| for (const name of Object.keys(CarouselTokens)) { | ||
| expect(source).toContain(name); | ||
| } |
There was a problem hiding this comment.
This test reads the component source files and checks that each token name appears in the text. It tests the implementation, and it still passes when a name only appears in a comment. The project test rules ask for public behavior. Remove it, or assert on rendered output, for example the border color when outlined is set. (via Claude, verify if it makes sense)
| @@ -0,0 +1 @@ | |||
| /Users/andrii.doroshenko/react-native-paper/example/node_modules No newline at end of file | |||
There was a problem hiding this comment.
Same as the root node_modules: this is a symlink to a path on the author's machine. In a fresh checkout both are dangling symlinks, and I had to delete them before yarn install worked. Remove this one too. (via Claude, verify if it makes sense)
satya164
left a comment
There was a problem hiding this comment.
Review by Claude
See the inline comments. Screenshots and steps are in #5123 (comment). (via Claude, verify if it makes sense)
| settle.value = withSpring(target, springConfig, (finished) => { | ||
| if (finished) { | ||
| settling.value = false; | ||
| if (itemSize > 0) { | ||
| runOnJS(reportIndex)(Math.round(target / itemSize)); | ||
| } | ||
| } | ||
| }); | ||
| }, | ||
| [springConfig, itemSize, reportIndex, settle, settling, scrollX] |
There was a problem hiding this comment.
With Reduce Motion on, the carousel never moves. withSpring with ReduceMotion.Always finishes in its first frame, so this callback sets settling back to false before the reaction below runs. The reaction then reads null and never calls scrollTo. Next and Previous report the new index through onIndexChange while the carousel stays where it was, and a fling stops between two keylines. This happens on iOS (Reduce Motion), Android (animations off) and web (prefers-reduced-motion):
Write the target offset when the spring finishes. With this change, Next and flings land on the target item on all three platforms:
| settle.value = withSpring(target, springConfig, (finished) => { | |
| if (finished) { | |
| settling.value = false; | |
| if (itemSize > 0) { | |
| runOnJS(reportIndex)(Math.round(target / itemSize)); | |
| } | |
| } | |
| }); | |
| }, | |
| [springConfig, itemSize, reportIndex, settle, settling, scrollX] | |
| settle.value = withSpring(target, springConfig, (finished) => { | |
| if (finished) { | |
| scrollTo(scrollRef, target, 0, false); | |
| settling.value = false; | |
| if (itemSize > 0) { | |
| runOnJS(reportIndex)(Math.round(target / itemSize)); | |
| } | |
| } | |
| }); | |
| }, | |
| [springConfig, itemSize, reportIndex, settle, settling, scrollX, scrollRef] |
(via Claude, verify if it makes sense)
| onScroll={scrollHandler} | ||
| onMomentumScrollEnd={handleMomentumEnd} | ||
| scrollEventThrottle={16} | ||
| aria-label={ariaLabel} |
There was a problem hiding this comment.
The aria-label does not reach any screen reader. React Native's ScrollView does not map aria-label to accessibilityLabel like View does, so the native scroll view has no label on iOS and an empty content-desc on Android. On web the label lands on a div with no role, and ARIA ignores names on generic elements. With this change the iOS tree shows "Photos" on the scroll view, the Android dump shows content-desc="Photos", and web renders <section role="region" aria-label="Photos">:
| aria-label={ariaLabel} | |
| accessibilityLabel={ariaLabel} | |
| role="region" |
(via Claude, verify if it makes sense)
| const styles = StyleSheet.create({ | ||
| container: { | ||
| overflow: 'hidden', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
On web nobody can scroll the carousel by hand. This style overrides the overflow-x: auto that react-native-web gives a horizontal ScrollView, so the scroll element is overflow-x: hidden. Wheel, trackpad and touch scrolling do nothing, and only scrollToIndex moves it. Native scroll views already clip their content: without overflow: 'hidden' the items still clip at the container edges on iOS and Android.
After you remove it, a second problem shows: wheel and trackpad scrolls on web fire no onScrollBeginDrag or onScrollEndDrag, so the carousel stops between two keylines and onIndexChange never fires. Settle once the scroll events stop on web. A 120 ms debounce on the scroll element's scroll event that calls scrollToIndex(Math.round(scrollLeft / itemSize)) springs it onto the nearest item and reports the index. (via Claude, verify if it makes sense)
| * | ||
| * The item corner radius comes from `theme.shapes.corner.extraLarge`. | ||
| * | ||
| * Note: layout is left-to-right. Right-to-left locales are not handled yet. |
There was a problem hiding this comment.
RTL is worse than left-to-right layout: the carousel renders nothing. The item boxes sit at left: 0 and move by translateX: scrollX + center, which only lines up with a left-to-right scroll view. On iOS only one item shows at first and the carousel is empty after a swipe. On Android it is empty from the start. On web the items bunch up on the left and the hero carousels are empty.
Until RTL is supported, force the scroll view to left-to-right so the note here is true: add direction: 'ltr' to styles.container for iOS and Android, and dir="ltr" for web, because react-native-web ignores the direction style. With both, the carousels render and scroll left to right on all three platforms with RTL on. (via Claude, verify if it makes sense)
|
Found potential problems with the pull request:
|















Motivation
react-native-paperhas no Carousel. It is one of the Material 3 Expressive components that the v6 modernization effort does not have yet. This PR adds it, with the four layouts of the spec: multi-browse, hero, uncontained and full-screen.Two facts about the component are not obvious. They shape the whole implementation:
The component never resizes an item.
Only two layouts use the arrangement solver.
Related issue
None yet. I will create one.
Implementation notes
Read these notes before the diff. The layout model is most of the change.
Keylines.
ihas its centre at(i + 0.5) × itemSize.Geometry. The token table has no geometry, so the values come from the MDC-Android and Compose implementations:
targetSmall = clamp(large / 3, 40, 56);targetMedium = (large + small) / 2;|targetLarge − large| × priority.Motion. The motion uses two tiers on purpose.
withSpring(toRawSpring(theme.motion.spring.default.spatial))moves the scroll position from the UI thread.scrollToIndexuses the same spring. The spring obeys reduce-motion.Three fling behaviours. All three are in
resolveSettleOffset, a pure worklet. Thus unit tests check them directly, not through scroll view props:Content-vs-mask API. The spec tells item content to react to the mask. The two reference implementations make this public:
OnMaskChangedListeneron Android andcarouselItemDrawInfo.maskRectin Compose.renderItemgets the live mask as shared values:CarouselItemis the full-size box for the media.CarouselItemContentstays at the leading edge of the mask and fades as the item collapses. The spec asks this behaviour for the text of an item.useAnimatedStylecauses no re-render.Tokens.
md.comp.carousel-item.*is the whole spec surface of the component. There is nomd.comp.carousel.*, no plural and no.expressive.variant. Thus baseline and Expressive have the same values.tokens.tsholds the component tokens.container.shapeusescorner.extraLarge. State layers usemd.sys.state. The focus indicator uses the system thickness and offset.The component does not mirror two token groups on purpose. A test fails the build if an unused token comes back:
label-text.*: MDC draws the label of an item itself. In Paper, the caller renders the item content, text included, so a label colour has nothing to paint. The disabled state dims all of the content.dragged.*: a drag on an item scrolls the strip. React Native gives the responder to the scroll view, and this ends the press of the item. Thus no interaction remains for a dragged state.Test plan
yarn example ios(orandroid).The screen has each layout, toggles for the outlined and disabled variants, and Previous / Next buttons. The buttons move the carousel through its ref.
Check these by hand:
Automated checks:
renderItemgets;yarn lint,yarn typecheckandyarn testare clean.Screenshots
Multi-browse, during a scroll: one large item, one medium item and one small item. The leading item collapses to a sliver. The mask crops each photo. The component does not resize it:
The solver selects more large items when the container gets wider:
The settle spring, moved through the ref of the component. It goes a little past the focal keyline, then stops on it:
Known gaps
scrollToand the velocity worklet ran onreact-native-web.onScrollBeginDrag/onScrollEndDragwithdecelerationRate={0}) runs only on iOS and Android. It needs a check on the two platforms before merge.docs/public/screenshots/holds real device captures for each platform. A native build must make them.