Arc List
Items laid out along a curved arc with proximity scale/opacity, snap-to-focus, and a haptic tick on snap.
Web preview — components render natively on iOS & Android
import { ArcList, ArcListItem, ArcListLabel } from "@/components/ui/arc-list";
const days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
<ArcList itemHeight={56} height={280} onValueChange={(index) => console.log(index)}>
{days.map((day) => (
<ArcListItem key={day}>
<ArcListLabel>{day}</ArcListLabel>
</ArcListItem>
))}
</ArcList>Installation#
npx @aniui/cli add arc-listUsage#
import { ArcList, ArcListItem, ArcListLabel } from "@/components/ui/arc-list";
const days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
<ArcList itemHeight={56} height={280} onValueChange={(index) => console.log(index)}>
{days.map((day) => (
<ArcListItem key={day}>
<ArcListLabel>{day}</ArcListLabel>
</ArcListItem>
))}
</ArcList>Source#
components/ui/arc-list.tsx
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Pressable,
Text,
View,
type LayoutChangeEvent,
} from "react-native";
import * as Haptics from "expo-haptics";
import Animated, {
Extrapolation,
interpolate,
interpolateColor,
runOnJS,
useAnimatedReaction,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { useThemeColors } from "@/components/ui/theme-provider";
import { cn } from "@/lib/utils";
/**
* A vertical list whose items are projected onto a curved arc (dial/wheel
* picker), scaling and fading by distance from the focused center, snapping
* to the nearest item on release with a haptic tick.
*/
export type ArcListSide = "left" | "right";
const DEFAULT_ITEM_HEIGHT = 56;
const DEFAULT_SWEEP = 40;
const MIN_SWEEP = 1;
const MAX_SWEEP = 80;
const MIN_RADIUS = 1;
const HORIZON = Math.PI / 2;
const DEFAULT_MIN_OPACITY = 0.3;
const DEFAULT_MIN_SCALE = 0.82;
const PLATEAU_FRACTION = 0.45;
const PLATEAU_SCALE = 0.98;
const PROXIMITY_SPAN = 1;
const CENTER_Z = 1000;
const Z_BAND_LIMIT = 3;
const UNKNOWN_INDEX = -1;
function radiusFromSweep(halfHeight: number, sweepDeg: number): number {
"worklet";
const clamped = Math.min(Math.max(sweepDeg, MIN_SWEEP), MAX_SWEEP);
const rad = (clamped * Math.PI) / 180;
return Math.max(halfHeight / Math.sin(rad), MIN_RADIUS);
}
function offsetForIndex(index: number, itemHeight: number): number {
"worklet";
return index * itemHeight;
}
function signedOffset(
index: number,
itemHeight: number,
scrollY: number,
): number {
"worklet";
return index * itemHeight - scrollY;
}
function arcPlacement(offset: number, radius: number, direction: 1 | -1) {
"worklet";
const r = Math.max(radius, MIN_RADIUS);
const theta = Math.max(-HORIZON, Math.min(HORIZON, offset / r));
const arcY = r * Math.sin(theta);
return {
bulge: r * (1 - Math.cos(theta)) * direction,
lift: arcY - offset,
tilt: (-theta * 180 * direction) / Math.PI,
distance: Math.abs(arcY),
onArc: Math.abs(offset / r) < HORIZON,
};
}
function nearestIndex(
scrollY: number,
itemHeight: number,
count: number,
): number {
"worklet";
if (count <= 0) return 0;
return Math.max(0, Math.min(count - 1, Math.round(scrollY / itemHeight)));
}
interface ArcListContextValue {
scrollY: SharedValue<number>;
count: SharedValue<number>;
itemHeight: number;
radius: number;
halfHeight: number;
direction: 1 | -1;
minOpacity: number;
minScale: number;
scrollToIndex: (index: number, animated?: boolean) => void;
}
interface ArcListItemContextValue {
index: number;
proximity: SharedValue<number>;
}
const ArcListContext = createContext<ArcListContextValue | null>(null);
const ArcListItemContext = createContext<ArcListItemContextValue | null>(
null,
);
const ArcListIndexContext = createContext<number | null>(null);
export interface ArcListProps
extends React.ComponentPropsWithoutRef<typeof View> {
itemHeight?: number;
height?: number;
sweep?: number;
radius?: number;
side?: ArcListSide;
snap?: boolean;
value?: number;
defaultValue?: number;
onValueChange?: (index: number) => void;
minOpacity?: number;
minScale?: number;
haptics?: boolean;
className?: string;
}
/** Scrollable arc/dial container. Children are `ArcListItem`s — index is auto-assigned by position. */
export function ArcList({
children,
itemHeight = DEFAULT_ITEM_HEIGHT,
height,
sweep = DEFAULT_SWEEP,
radius: radiusProp,
side = "left",
snap = true,
value,
defaultValue = 0,
onValueChange,
minOpacity = DEFAULT_MIN_OPACITY,
minScale = DEFAULT_MIN_SCALE,
haptics = true,
className,
style,
...props
}: ArcListProps) {
const initialIndex = value ?? defaultValue;
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollY = useSharedValue(offsetForIndex(initialIndex, itemHeight));
const count = useSharedValue(0);
const [measuredHeight, setMeasuredHeight] = useState(0);
const [activeIndex, setActiveIndex] = useState(initialIndex);
const didRestore = useRef(false);
const viewportHeight = height ?? measuredHeight;
const halfHeight = viewportHeight / 2;
const radius = radiusProp ?? radiusFromSweep(halfHeight, sweep);
const direction: 1 | -1 = side === "right" ? -1 : 1;
const handleLayout = useCallback(
(e: LayoutChangeEvent) => {
if (height !== undefined) return;
setMeasuredHeight(e.nativeEvent.layout.height);
},
[height],
);
const scrollToIndex = useCallback(
(index: number, animated = true) => {
scrollRef.current?.scrollTo({
y: offsetForIndex(index, itemHeight),
animated,
});
},
[itemHeight, scrollRef],
);
const handleFocus = useCallback(
(index: number) => {
setActiveIndex(index);
onValueChange?.(index);
if (haptics) Haptics.selectionAsync();
},
[onValueChange, haptics],
);
useAnimatedReaction(
() =>
count.value > 0
? nearestIndex(scrollY.value, itemHeight, count.value)
: UNKNOWN_INDEX,
(current, previous) => {
if (previous === null || previous === UNKNOWN_INDEX) return;
if (current === UNKNOWN_INDEX || current === previous) return;
runOnJS(handleFocus)(current);
},
[itemHeight, handleFocus],
);
useEffect(() => {
if (value === undefined || value === activeIndex) return;
scrollToIndex(value);
}, [value, activeIndex, scrollToIndex]);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (e) => {
scrollY.value = e.contentOffset.y;
},
});
const total = React.Children.count(children);
useEffect(() => {
count.value = total;
}, [total, count]);
const handleContentSizeChange = useCallback(() => {
if (didRestore.current) return;
didRestore.current = true;
if (initialIndex <= 0) return;
scrollRef.current?.scrollTo({
y: offsetForIndex(initialIndex, itemHeight),
animated: false,
});
}, [initialIndex, itemHeight, scrollRef]);
const contentPadding = Math.max(halfHeight - itemHeight / 2, 0);
const rows = React.Children.map(children, (child, index) => (
<ArcListIndexContext.Provider key={index} value={index}>
{child}
</ArcListIndexContext.Provider>
));
const contextValue = useMemo<ArcListContextValue>(
() => ({
scrollY,
count,
itemHeight,
radius,
halfHeight,
direction,
minOpacity,
minScale,
scrollToIndex,
}),
[
scrollY,
count,
itemHeight,
radius,
halfHeight,
direction,
minOpacity,
minScale,
scrollToIndex,
],
);
return (
<View
className={cn("overflow-hidden", className)}
style={[{ height }, style]}
onLayout={handleLayout}
accessibilityRole="adjustable"
{...props}
>
{viewportHeight > 0 ? (
<ArcListContext.Provider value={contextValue}>
<Animated.ScrollView
ref={scrollRef}
onScroll={scrollHandler}
onContentSizeChange={handleContentSizeChange}
scrollEventThrottle={1}
showsVerticalScrollIndicator={false}
snapToInterval={snap ? itemHeight : undefined}
decelerationRate={snap ? "fast" : "normal"}
contentContainerStyle={{ paddingVertical: contentPadding }}
>
{rows}
</Animated.ScrollView>
</ArcListContext.Provider>
) : null}
</View>
);
}
export interface ArcListItemProps
extends Omit<React.ComponentPropsWithoutRef<typeof Pressable>, "onPress"> {
onPress?: (index: number) => void;
className?: string;
}
/** A single arc-projected, snap-to row. Wrap label/indicator content as children. */
export function ArcListItem({
children,
disabled = false,
onPress,
className,
style,
...props
}: ArcListItemProps) {
const ctx = useContext(ArcListContext);
if (!ctx) {
throw new Error("ArcListItem must be rendered inside an ArcList");
}
const injectedIndex = useContext(ArcListIndexContext);
const index = injectedIndex ?? 0;
const rItem = useAnimatedStyle(() => {
const offset = signedOffset(index, ctx.itemHeight, ctx.scrollY.value);
const placement = arcPlacement(offset, ctx.radius, ctx.direction);
const plateau = ctx.halfHeight * PLATEAU_FRACTION;
const opacity = placement.onArc
? interpolate(
placement.distance,
[0, plateau, ctx.halfHeight],
[1, 1, ctx.minOpacity],
Extrapolation.CLAMP,
)
: 0;
const scale = interpolate(
placement.distance,
[0, plateau, ctx.halfHeight],
[1, PLATEAU_SCALE, ctx.minScale],
Extrapolation.CLAMP,
);
return {
opacity,
zIndex:
CENTER_Z -
Math.min(Math.round(placement.distance / ctx.itemHeight), Z_BAND_LIMIT),
transform: [
{ translateX: placement.bulge },
{ translateY: placement.lift },
{ rotate: `${placement.tilt}deg` },
{ scale },
],
};
}, [index, ctx]);
const proximity = useDerivedValue(() => {
const offset = signedOffset(index, ctx.itemHeight, ctx.scrollY.value);
return interpolate(
Math.abs(offset),
[0, ctx.itemHeight * PROXIMITY_SPAN],
[1, 0],
Extrapolation.CLAMP,
);
}, [index, ctx]);
const itemContextValue = useMemo<ArcListItemContextValue>(
() => ({ index, proximity }),
[index, proximity],
);
const handlePress = useCallback(() => {
ctx.scrollToIndex(index);
onPress?.(index);
}, [ctx, index, onPress]);
return (
<ArcListItemContext.Provider value={itemContextValue}>
<Animated.View
style={[{ height: ctx.itemHeight, justifyContent: "center" }, rItem]}
className={ctx.direction === -1 ? "items-end" : "items-start"}
>
<Pressable
onPress={handlePress}
disabled={disabled}
accessible={true}
accessibilityRole="button"
className={cn(
"min-h-12 min-w-12 flex-1 flex-row items-center justify-center gap-2 px-3",
className,
)}
style={style}
{...props}
>
{children}
</Pressable>
</Animated.View>
</ArcListItemContext.Provider>
);
}
export interface ArcListLabelProps
extends React.ComponentPropsWithoutRef<typeof Text> {
activeColor?: string;
className?: string;
}
/** Convenience label whose color tints toward `activeColor` as its `ArcListItem` nears the center. */
export function ArcListLabel({
children,
activeColor,
className,
style,
numberOfLines = 1,
...props
}: ArcListLabelProps) {
const itemCtx = useContext(ArcListItemContext);
if (!itemCtx) {
throw new Error("ArcListLabel must be rendered inside an ArcListItem");
}
const colors = useThemeColors();
const color = colors.mutedForeground;
const tint = activeColor ?? colors.foreground;
const rLabel = useAnimatedStyle(
() => ({
color: interpolateColor(itemCtx.proximity.value, [0, 1], [color, tint]),
}),
[color, tint],
);
return (
<Animated.Text
numberOfLines={numberOfLines}
style={[rLabel, style]}
className={cn("text-lg font-semibold", className)}
{...props}
>
{children}
</Animated.Text>
);
}

