Swipeable pager with snap points and pagination dots.
components/ui/carousel/carousel.tsx@lumioui/core@latestreact-native-reanimated@*Swipeable pager with snap points and pagination dots.
import { Carousel } from "@/components/ui/carousel/carousel";
export function Example() {
return (
<Carousel />
);
} import * as React from "react";
import { forwardRef, useState, useCallback, useMemo } from "react";
import { View, Text, ScrollView, Pressable, PanResponder, type ScrollViewProps, type NativeSyntheticEvent, type NativeScrollEvent, type NativeTouchEvent, type LayoutChangeEvent, } from "react-native";
import Animated, {
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
interpolate,
runOnJS,
} from "react-native-reanimated";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
import { Glass } from "../glass";
/**
* Carousel — horizontal paging carousel with animated dot indicators and
* pinch-to-zoom on each page. Swipe horizontally to page; pinch to zoom the
* active page; double-tap to toggle zoom.
* <Carousel data={items} renderItem={(item) => <Card … />} />
* <Carousel pinchToZoom>{pages.map(p => <View />)}</Carousel>
*/
/** Distance between two touch points — the pinch scale driver. */
function touchDistance(a: NativeTouchEvent, b: NativeTouchEvent): number {
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
}
/** Double-tap window for zoom toggle (ms). */
const DOUBLE_TAP_MS = 300;
interface CarouselDotProps {
active: boolean;
}
function CarouselDot({ active }: CarouselDotProps) {
const reduce = useReducedMotion();
// t: 0 = resting dot, 1 = active pill. Driven here (not inside the
// worklet) so the spring starts on the index change, not on style eval.
const t = useSharedValue(active ? 1 : 0);
React.useEffect(() => {
// Reduced motion: jump to the settled width/opacity, no spring travel.
t.value = reduce ? (active ? 1 : 0) : withSpring(active ? 1 : 0, spring.layout);
}, [active, reduce, t]);
const style = useAnimatedStyle(() => ({
width: 6 + t.value * 10,
opacity: 0.4 + t.value * 0.6,
}));
return (
<Animated.View
style={style}
className={cn("h-1.5 rounded-full", active ? "bg-primary" : "bg-muted-foreground")}
accessibilityElementsHidden
importantForAccessibility="no"
aria-hidden
/>
);
}
export interface CarouselProps<T> extends Omit<ScrollViewProps, "children"> {
/** Items to page through (use with renderItem). */
data?: T[];
/** Render a single page from a data item. */
renderItem?: (item: T, index: number) => React.ReactNode;
/** Show dot indicators below the carousel. Default true. */
showDots?: boolean;
/** Controlled active index. */
index?: number;
/** Called when the active page changes. */
onIndexChange?: (index: number) => void;
/** Fixed page width; defaults to measured container width. */
pageWidth?: number;
/** Enable pinch-to-zoom and double-tap-to-zoom on each page. Default false. */
pinchToZoom?: boolean;
/** Max zoom scale for pinch. Default 3. */
maxZoom?: number;
className?: string;
/** Extra classes applied to each page wrapper. */
pageClassName?: string;
/** Extra classes for the dots row. */
dotsClassName?: string;
/** Liquid-glass pager — dots float inside a frosted pill (iOS gallery idiom). */
glass?: boolean;
/** Static children used as pages when `data` is not provided. */
children?: React.ReactNode;
}
function ZoomablePage({
children,
enabled,
maxZoom,
active,
}: {
children: React.ReactNode;
enabled: boolean;
maxZoom: number;
active: boolean;
}) {
const reduce = useReducedMotion();
const haptics = useHaptic();
const scale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
// PanResponder callbacks run on the JS thread, so gesture bookkeeping
// (pinch baseline, pan origin) lives in refs instead of shared ctx values.
const startDist = React.useRef(0);
const baseScale = React.useRef(1);
const baseX = React.useRef(0);
const baseY = React.useRef(0);
const lastTap = React.useRef(0);
// Reset zoom when page becomes inactive
React.useEffect(() => {
if (!active && scale.value !== 1) {
scale.value = withSpring(1, spring.press);
translateX.value = withSpring(0, spring.press);
translateY.value = withSpring(0, spring.press);
}
}, [active, scale, translateX, translateY]);
const zoomEnabled = enabled && !reduce;
// Pinch via two-touch distance tracking, plus single-finger pan once
// zoomed. responder negotiation runs on the JS thread — react-native's
// PanResponder is the free-tier gesture system (gesture-handler is
// Pro-only), so pinch math pays a JS-thread hop per move event. The
// tradeoff is kept narrow on purpose: the responder is claimed ONLY for
// ≥2 touches (pinch) or on move while already zoomed (pan). A single
// finger at rest never claims start OR move, so the parent ScrollView's
// snap paging is never blocked.
const pan = useMemo(() => {
if (!zoomEnabled) return null;
return PanResponder.create({
// Never claim on touch-start for a single finger — even while zoomed,
// the move hook below decides once the gesture is actually a drag.
onStartShouldSetPanResponder: (e) =>
e.nativeEvent.touches.length >= 2,
// Capture phase so a second finger beats the inner Pressable's claim.
onStartShouldSetPanResponderCapture: (e) => e.nativeEvent.touches.length >= 2,
onMoveShouldSetPanResponder: (e, g) =>
e.nativeEvent.touches.length >= 2 ||
// Single-finger drag only claims while zoomed (it pans the image);
// at scale 1 it always falls through to the ScrollView's paging.
(scale.value > 1 && (Math.abs(g.dx) > 4 || Math.abs(g.dy) > 4)),
// Capture phase so a pinch beats the parent ScrollView's pan claim.
onMoveShouldSetPanResponderCapture: (e) => e.nativeEvent.touches.length >= 2,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (e) => {
const [a, b] = e.nativeEvent.touches;
if (a && b) {
startDist.current = touchDistance(a, b);
baseScale.current = scale.value;
} else {
baseX.current = translateX.value;
baseY.current = translateY.value;
}
},
onPanResponderMove: (e, g) => {
const [a, b] = e.nativeEvent.touches;
if (a && b && startDist.current > 0) {
const ratio = touchDistance(a, b) / startDist.current;
scale.value = Math.min(maxZoom, Math.max(1, baseScale.current * ratio));
} else if (scale.value > 1) {
translateX.value = baseX.current + g.dx;
translateY.value = baseY.current + g.dy;
}
},
onPanResponderRelease: () => {
if (scale.value < 1.05) {
scale.value = withSpring(1, spring.press);
translateX.value = withSpring(0, spring.press);
translateY.value = withSpring(0, spring.press);
} else {
haptics.selection();
}
},
onPanResponderTerminate: () => {
if (scale.value <= 1) {
translateX.value = withSpring(0, spring.press);
translateY.value = withSpring(0, spring.press);
}
},
});
}, [zoomEnabled, maxZoom, scale, translateX, translateY, haptics]);
// Double-tap zoom toggle — timestamp diff onPress (single taps pass
// through to nested content untouched).
const handleTap = () => {
const now = Date.now();
if (now - lastTap.current > DOUBLE_TAP_MS) {
lastTap.current = now;
return;
}
lastTap.current = 0;
if (scale.value > 1.05) {
scale.value = withSpring(1, spring.press);
translateX.value = withSpring(0, spring.press);
translateY.value = withSpring(0, spring.press);
} else {
scale.value = withSpring(2, spring.press);
haptics.selection();
}
};
const style = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
}));
if (!enabled) {
return <>{children}</>;
}
return (
<Animated.View
style={[{ flex: 1 }, style]}
collapsable={false}
{...(pan?.panHandlers ?? {})}
>
<Pressable style={{ flex: 1 }} disabled={!zoomEnabled} onPress={handleTap}>
{children}
</Pressable>
</Animated.View>
);
}
/**
* A single page — while the pager scrolls, off-center pages shrink and dim
* a touch so the settle reads as the page *arriving* in the frame, not just
* sliding past. Continuous interpolation keeps it 1:1 with the finger.
*/
function CarouselPage({
scrollX,
index,
width,
parallax,
className,
children,
}: {
scrollX: { value: number };
index: number;
width: number;
parallax: boolean;
className?: string;
children: React.ReactNode;
}) {
const style = useAnimatedStyle(() => {
if (!parallax || width <= 0) return {};
const p = interpolate(
scrollX.value,
[(index - 1) * width, index * width, (index + 1) * width],
[0, 1, 0],
"clamp"
);
return {
opacity: 0.75 + p * 0.25,
transform: [{ scale: 0.94 + p * 0.06 }],
};
});
return (
<Animated.View
style={[{ width: width > 0 ? width : undefined }, style]}
className={cn(className)}
>
{children}
</Animated.View>
);
}
function CarouselInner<T>(
{
data,
renderItem,
showDots = true,
index: controlledIndex,
onIndexChange,
pageWidth,
pinchToZoom = false,
maxZoom = 3,
className,
pageClassName,
dotsClassName,
glass = false,
children,
onMomentumScrollEnd,
onScroll,
...props
}: CarouselProps<T>,
ref: React.Ref<ScrollView>
) {
const [containerWidth, setContainerWidth] = useState(pageWidth ?? 0);
const [internalIndex, setInternalIndex] = useState(0);
const activeIndex = controlledIndex ?? internalIndex;
const reduce = useReducedMotion();
const haptics = useHaptic();
// Raw pager offset — drives the per-page parallax above.
const scrollX = useSharedValue(0);
const onScrollRef = React.useRef(onScroll);
onScrollRef.current = onScroll;
const scrollHandler = useAnimatedScrollHandler({
onScroll: (e) => {
scrollX.value = e.contentOffset.x;
const cb = onScrollRef.current;
// The worklet event IS the native payload — re-wrap it so consumer
// handlers still read e.nativeEvent.* as usual.
if (cb) runOnJS(cb)({ nativeEvent: e } as unknown as NativeSyntheticEvent<NativeScrollEvent>);
},
});
const pages: React.ReactNode[] =
data != null && renderItem != null
? data.map((item, i) => renderItem(item, i))
: React.Children.toArray(children);
const width = pageWidth ?? containerWidth;
// Merge the forwarded ref with a local one so a controlled `index`
// can drive the scroll position without breaking parent refs.
const scrollRef = React.useRef<ScrollView>(null);
const setScrollRef = useCallback(
(node: ScrollView | null) => {
(scrollRef as React.MutableRefObject<ScrollView | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<ScrollView | null>).current = node;
},
[ref]
);
// Controlled index: scroll the pager when the parent moves it.
React.useEffect(() => {
if (controlledIndex != null && width > 0 && controlledIndex >= 0 && controlledIndex < pages.length) {
scrollRef.current?.scrollTo({ x: controlledIndex * width, animated: !reduce });
}
}, [controlledIndex, width, pages.length, reduce]);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => {
if (pageWidth == null) setContainerWidth(e.nativeEvent.layout.width);
},
[pageWidth]
);
const handleMomentumEnd = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (width > 0) {
const next = Math.round(e.nativeEvent.contentOffset.x / width);
const clamped = Math.max(0, Math.min(next, pages.length - 1));
if (clamped !== activeIndex) {
// Page settled on a new index — a selection tick marks the landing.
haptics.selection();
setInternalIndex(clamped);
onIndexChange?.(clamped);
}
}
onMomentumScrollEnd?.(e);
},
[width, pages.length, activeIndex, onIndexChange, onMomentumScrollEnd, haptics]
);
// Empty state: an honest muted placeholder instead of a blank zero-height view.
if (pages.length === 0) {
return (
<View className={cn("w-full", className)} onLayout={handleLayout}>
<View
className="h-24 items-center justify-center rounded-2xl bg-muted"
accessibilityRole="adjustable"
accessibilityLabel="Empty carousel"
accessibilityValue={{ min: 0, max: 0, now: 0 }}
aria-valuemin={0}
aria-valuemax={0}
aria-valuenow={0}
>
<Text className="text-sm text-muted-foreground">No items yet</Text>
</View>
</View>
);
}
return (
<View className={cn("w-full", className)} onLayout={handleLayout}>
<Animated.ScrollView
ref={setScrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={scrollHandler}
onMomentumScrollEnd={handleMomentumEnd}
accessibilityRole="adjustable"
accessibilityLabel="Carousel"
accessibilityHint="Swipe left or right to browse items"
accessibilityValue={{ min: 1, max: Math.max(pages.length, 1), now: activeIndex + 1 }}
// RNW ignores the accessibilityValue object — mirror it as aria-*
// so the web slider role carries the current page (switch pattern).
aria-valuemin={1}
aria-valuemax={Math.max(pages.length, 1)}
aria-valuenow={activeIndex + 1}
{...props}
>
{pages.map((page, i) => (
<CarouselPage
key={i}
scrollX={scrollX}
index={i}
width={width}
parallax={!reduce}
className={pageClassName}
>
<ZoomablePage enabled={pinchToZoom} maxZoom={maxZoom} active={i === activeIndex}>
{page}
</ZoomablePage>
</CarouselPage>
))}
</Animated.ScrollView>
{showDots && pages.length > 1 && (
<View
className={cn(
"mt-3 flex-row items-center justify-center gap-1.5",
glass && "self-center rounded-full px-3 py-1.5",
dotsClassName
)}
accessibilityElementsHidden
importantForAccessibility="no"
aria-hidden
>
{glass ? (
<Glass
effect="regular"
sheen={0.4}
className="absolute inset-0 rounded-full"
pointerEvents="none"
/>
) : null}
{pages.map((_, i) => (
<CarouselDot key={i} active={i === activeIndex} />
))}
</View>
)}
</View>
);
}
/**
* Generic call signature for Carousel — forwardRef erases the item type
* param, so the component is declared via a callable interface and the
* render function is assigned once (no per-callsite `as` casts).
*/
export interface CarouselComponent {
<T>(props: CarouselProps<T> & React.RefAttributes<ScrollView>): React.ReactElement | null;
displayName?: string;
}
export const Carousel: CarouselComponent = forwardRef(CarouselInner) as CarouselComponent;
Carousel.displayName = "Carousel";