navigation
Elastic Segment Rail
An adaptive segmented control with a draggable indicator that reshapes across labels.
iOSAndroidExpo Go
hapticsgesture handlerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add expo-haptics react-native-gesture-handler react-native-reanimatedInstall tailwind variants, uniwind
pnpm add tailwind-variants uniwindUsage
ElasticSegmentRailExample.tsximport { ElasticSegmentRail } from "@animations/ui/components/navigation/elastic-segment-rail/uniwind";const periods = [ { label: "Today", value: "today" }, { label: "This week", value: "week" }, { label: "All time", value: "all" },];export function ElasticSegmentRailExample() { return ( <ElasticSegmentRail className="w-full" classNames={{ indicator: "bg-violet-600", label: "text-xs", }} defaultValue="today" options={periods} /> );}elastic-segment-rail/config.tsexport const ELASTIC_SEGMENT_RAIL_CONFIG = { minimumPanDistance: 4, velocityProjection: 0.08, spring: { damping: 18, mass: 0.72, overshootClamping: true, stiffness: 210, },} as const;elastic-segment-rail/types.tsexport type ElasticSegmentRailOption = { accessibilityLabel?: string; label: string; value: string;};export type ElasticSegmentRailBaseProps = { accessibilityLabel?: string; defaultValue?: string; disabled?: boolean; haptics?: boolean; onValueChange?: (value: string) => void; options: readonly ElasticSegmentRailOption[]; value?: string;};elastic-segment-rail/uniwind/index.tsximport { Pressable, Text, View, type LayoutChangeEvent,} from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import type { ElasticSegmentRailBaseProps } from "../types";import { useElasticSegmentRail } from "../use-segment-rail";import { elasticSegmentRailVariants, type ElasticSegmentRailSlot,} from "./variants";export type ElasticSegmentRailProps = ElasticSegmentRailBaseProps & { className?: string; classNames?: SlotsToClasses<ElasticSegmentRailSlot>; };export function ElasticSegmentRail({ accessibilityLabel, className, classNames, defaultValue, disabled, haptics, onValueChange, options, value,}: ElasticSegmentRailProps) { const { gesture, indicatorAnimatedStyle, onSegmentLayout, selectedIndex, selectIndex, } = useElasticSegmentRail({ defaultValue, disabled, haptics, onValueChange, options, value, }); const { container, indicator, rail, segment } = elasticSegmentRailVariants({ disabled }); function handleSegmentLayout( index: number, event: LayoutChangeEvent, ) { onSegmentLayout(index, event); } return ( <GestureDetector gesture={gesture}> <View accessibilityLabel={accessibilityLabel} accessibilityRole="tablist" className={container({ className: [className, classNames?.container], })} > <View className={rail({ className: classNames?.rail })}> <Animated.View className={indicator({ className: classNames?.indicator, })} pointerEvents="none" style={indicatorAnimatedStyle} /> {options.map((option, index) => { const isSelected = selectedIndex === index; const { label } = elasticSegmentRailVariants({ disabled, selected: isSelected, }); return ( <Pressable accessibilityLabel={ option.accessibilityLabel ?? option.label } accessibilityRole="tab" accessibilityState={{ disabled, selected: isSelected, }} className={segment({ className: classNames?.segment, })} disabled={disabled} key={option.value} onLayout={(event) => handleSegmentLayout(index, event) } onPress={() => selectIndex(index)} > <Text className={label({ className: classNames?.label, })} > {option.label} </Text> </Pressable> ); })} </View> </View> </GestureDetector> );}elastic-segment-rail/uniwind/variants.tsimport { tv } from "tailwind-variants";export const elasticSegmentRailSlots = { container: "w-full", indicator: "absolute bottom-1 left-0 top-1 rounded-xl bg-[#171713]", label: "text-xs font-extrabold text-[#77776f]", rail: "flex-row rounded-2xl bg-[#efefe9] p-1", segment: "z-1 min-h-10 grow items-center justify-center px-3",} as const;export type ElasticSegmentRailSlot = keyof typeof elasticSegmentRailSlots;export const elasticSegmentRailVariants = tv({ slots: elasticSegmentRailSlots, variants: { disabled: { true: { container: "opacity-[0.45]", }, }, selected: { true: { label: "text-white", }, }, },});elastic-segment-rail/use-segment-rail.tsimport * as Haptics from "expo-haptics";import { useCallback, useMemo, useState } from "react";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import { interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring,} from "react-native-reanimated";import { ELASTIC_SEGMENT_RAIL_CONFIG } from "./config";import type { ElasticSegmentRailBaseProps } from "./types";type SegmentLayout = { width: number; x: number;};function clamp(value: number, minimum: number, maximum: number) { "worklet"; return Math.min(maximum, Math.max(minimum, value));}export function useElasticSegmentRail({ defaultValue, disabled = false, haptics = true, onValueChange, options, value,}: ElasticSegmentRailBaseProps) { if (options.length < 2) { throw new Error( "ElasticSegmentRail requires at least two options.", ); } const [internalValue, setInternalValue] = useState( defaultValue ?? options[0].value, ); const [segmentLayouts, setSegmentLayouts] = useState< Record<number, SegmentLayout> >({}); const [previewedIndex, setPreviewedIndex] = useState<number>(); const isControlled = value !== undefined; const selectedValue = value ?? internalValue; const selectedIndex = Math.max( 0, options.findIndex((option) => option.value === selectedValue), ); const orderedLayouts = useMemo( () => options.flatMap((_, index) => { const layout = segmentLayouts[index]; return layout ? [layout] : []; }), [options, segmentLayouts], ); const selectedLayout = segmentLayouts[selectedIndex]; const targetX = selectedLayout?.x ?? 0; const targetWidth = selectedLayout?.width ?? 0; const indicatorX = useSharedValue(targetX); const indicatorWidth = useSharedValue(targetWidth); const isDragging = useSharedValue(false); const lastPreviewedIndex = useSharedValue(selectedIndex); const reducedMotion = useReducedMotion(); useAnimatedReaction( () => ({ dragging: isDragging.get(), width: targetWidth, x: targetX, }), (current, previous) => { const targetChanged = current.width !== previous?.width || current.x !== previous?.x; if (current.dragging || !targetChanged) { return; } if (reducedMotion) { indicatorX.set(current.x); indicatorWidth.set(current.width); return; } indicatorX.set( withSpring( current.x, ELASTIC_SEGMENT_RAIL_CONFIG.spring, ), ); indicatorWidth.set( withSpring( current.width, ELASTIC_SEGMENT_RAIL_CONFIG.spring, ), ); }, ); const indicatorAnimatedStyle = useAnimatedStyle(() => { return { transform: [{ translateX: indicatorX.get() }], width: indicatorWidth.get(), }; }); const selectIndex = useCallback( (nextIndex: number) => { const nextOption = options[nextIndex]; if (!nextOption || nextOption.value === selectedValue) { return; } if (!isControlled) { setInternalValue(nextOption.value); } if (haptics) { void Haptics.selectionAsync(); } onValueChange?.(nextOption.value); }, [ haptics, isControlled, onValueChange, options, selectedValue, ], ); const previewIndex = useCallback((nextIndex: number) => { setPreviewedIndex(nextIndex); }, []); const commitIndex = useCallback( (nextIndex: number) => { selectIndex(nextIndex); setPreviewedIndex(undefined); }, [selectIndex], ); const cancelPreview = useCallback(() => { setPreviewedIndex(undefined); }, []); const onSegmentLayout = useCallback( (index: number, event: LayoutChangeEvent) => { const { width, x } = event.nativeEvent.layout; setSegmentLayouts((currentLayouts) => { const currentLayout = currentLayouts[index]; if ( currentLayout?.width === width && currentLayout.x === x ) { return currentLayouts; } return { ...currentLayouts, [index]: { width, x }, }; }); }, [], ); const gesture = useMemo(() => { return Gesture.Pan() .enabled(!disabled) .minDistance(ELASTIC_SEGMENT_RAIL_CONFIG.minimumPanDistance) .onStart(() => { indicatorX.set(targetX); indicatorWidth.set(targetWidth); lastPreviewedIndex.set(selectedIndex); isDragging.set(true); }) .onUpdate((event) => { if (orderedLayouts.length !== options.length) { return; } const firstLayout = orderedLayouts[0]; const lastLayout = orderedLayouts[orderedLayouts.length - 1]; const startLayout = orderedLayouts[selectedIndex]; const nextX = clamp( startLayout.x + event.translationX, firstLayout.x, lastLayout.x, ); let nextWidth = firstLayout.width; for (let index = 0; index < orderedLayouts.length - 1; index += 1) { const currentLayout = orderedLayouts[index]; const nextLayout = orderedLayouts[index + 1]; if (nextX >= currentLayout.x && nextX <= nextLayout.x) { const progress = (nextX - currentLayout.x) / Math.max(1, nextLayout.x - currentLayout.x); nextWidth = interpolate( progress, [0, 1], [currentLayout.width, nextLayout.width], ); break; } } if (nextX === lastLayout.x) { nextWidth = lastLayout.width; } indicatorX.set(nextX); indicatorWidth.set(nextWidth); let nearestIndex = 0; let nearestDistance = Math.abs( nextX - firstLayout.x, ); for (let index = 1; index < orderedLayouts.length; index += 1) { const distance = Math.abs( nextX - orderedLayouts[index].x, ); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } } if (nearestIndex !== lastPreviewedIndex.get()) { lastPreviewedIndex.set(nearestIndex); runOnJS(previewIndex)(nearestIndex); } }) .onEnd((event) => { if (orderedLayouts.length !== options.length) { return; } const firstLayout = orderedLayouts[0]; const lastLayout = orderedLayouts[orderedLayouts.length - 1]; const startLayout = orderedLayouts[selectedIndex]; const projectedX = clamp( startLayout.x + event.translationX + event.velocityX * ELASTIC_SEGMENT_RAIL_CONFIG.velocityProjection, firstLayout.x, lastLayout.x, ); let nearestIndex = 0; let nearestDistance = Math.abs( projectedX - firstLayout.x, ); for (let index = 1; index < orderedLayouts.length; index += 1) { const distance = Math.abs( projectedX - orderedLayouts[index].x, ); if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } } const destination = orderedLayouts[nearestIndex]; const isAtDestination = Math.abs(indicatorX.get() - destination.x) < 0.5 && Math.abs( indicatorWidth.get() - destination.width, ) < 0.5; if (reducedMotion || isAtDestination) { indicatorX.set(destination.x); indicatorWidth.set(destination.width); lastPreviewedIndex.set(nearestIndex); runOnJS(commitIndex)(nearestIndex); isDragging.set(false); return; } const xNeedsAnimation = Math.abs(indicatorX.get() - destination.x) >= 0.5; if (xNeedsAnimation) { indicatorX.set( withSpring( destination.x, ELASTIC_SEGMENT_RAIL_CONFIG.spring, (finished) => { if (finished) { isDragging.set(false); } }, ), ); indicatorWidth.set( withSpring( destination.width, ELASTIC_SEGMENT_RAIL_CONFIG.spring, ), ); } else { indicatorX.set(destination.x); indicatorWidth.set( withSpring( destination.width, ELASTIC_SEGMENT_RAIL_CONFIG.spring, (finished) => { if (finished) { isDragging.set(false); } }, ), ); } lastPreviewedIndex.set(nearestIndex); runOnJS(commitIndex)(nearestIndex); }) .onFinalize((_event, success) => { if (success === false) { indicatorX.set( reducedMotion ? targetX : withSpring( targetX, ELASTIC_SEGMENT_RAIL_CONFIG.spring, ), ); indicatorWidth.set( reducedMotion ? targetWidth : withSpring( targetWidth, ELASTIC_SEGMENT_RAIL_CONFIG.spring, ), ); isDragging.set(false); runOnJS(cancelPreview)(); } }); }, [ cancelPreview, commitIndex, disabled, indicatorWidth, indicatorX, isDragging, lastPreviewedIndex, orderedLayouts, options.length, previewIndex, reducedMotion, selectedIndex, targetWidth, targetX, ]); return { gesture, indicatorAnimatedStyle, onSegmentLayout, selectedIndex: previewedIndex ?? selectedIndex, selectIndex, };}