Veltrix

navigation

Elastic Segment Rail

An adaptive segmented control with a draggable indicator that reshapes across labels.

AppleiOSAndroidAndroidExpoExpo Go
hapticsgesture handlerreanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add expo-haptics react-native-gesture-handler react-native-reanimated

Install tailwind variants, uniwind

pnpm add tailwind-variants uniwind

Usage

ElasticSegmentRailExample.tsx
import { 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}    />  );}

Component files

5 files

elastic-segment-rail/config.ts
export 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.ts
export 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.tsx
import {  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.ts
import { 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.ts
import * 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,  };}

API reference

Generated directly from the exported component props.

ElasticSegmentRail