Veltrix

cards

Tension Stack

A resistant swipe stack that releases the active card and pulls the next layer into place.

AppleiOSAndroidAndroidExpoExpo Go
gesture handlerreanimated
GitHubOpen in GitHub

Installation

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

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

Anatomy

1 file

anatomy.tsx
<TensionStack>  <TensionStack.Item /></TensionStack>

Install tailwind variants

pnpm add tailwind-variants

Usage

TensionStackExample.tsx
import { Text, View } from "react-native";import { TensionStack } from "@animations/ui/components/cards/tension-stack/uniwind";function StackCard({ title }: { title: string }) {  return (    <View className="flex-1 justify-end p-6">      <Text className="text-2xl font-extrabold">{title}</Text>    </View>  );}const items = [  <TensionStack.Item id="profile" key="profile">    <StackCard title="Profile" />  </TensionStack.Item>,  <TensionStack.Item id="insights" key="insights">    <StackCard title="Insights" />  </TensionStack.Item>,];export function TensionStackExample() {  return (    <View className="h-57.5">      <TensionStack        classNames={{ card: "rounded-3xl" }}        loop        onDismiss={(id) => console.log(id)}      >        {items}      </TensionStack>    </View>  );}

Component files

6 files

tension-stack/card.tsx
import type { PropsWithChildren } from "react";import type { StyleProp, ViewStyle } from "react-native";import Animated, {  Extrapolation,  interpolate,  type SharedValue,  useAnimatedStyle,} from "react-native-reanimated";type TensionCardProps = PropsWithChildren<{  className?: string;  depth: number;  moving?: boolean;  style?: StyleProp<ViewStyle>;  translateX: SharedValue<number>;  width: SharedValue<number>;}>;export function TensionCard({  children,  className,  depth,  moving = false,  style,  translateX,  width,}: TensionCardProps) {  const animatedStyle = useAnimatedStyle(() => {    const progress = Math.min(      Math.abs(translateX.get()) / Math.max(width.get(), 1),      1,    );    const restingScale = 1 - depth * 0.055;    const restingY = depth * 13;    if (depth === 0) {      if (!moving) {        return {          opacity: 1,          transform: [            { translateX: 0 },            { rotate: "0deg" },          ],        };      }      return {        opacity: 1,        transform: [          { translateX: translateX.get() },          {            rotate: `${interpolate(              translateX.get(),              [-Math.max(width.get(), 1), 0, Math.max(width.get(), 1)],              [-7, 0, 7],              Extrapolation.CLAMP,            )}deg`,          },        ],      };    }    return {      opacity: interpolate(        progress,        [0, 1],        [1 - depth * 0.16, 1 - (depth - 1) * 0.16],        Extrapolation.CLAMP,      ),      transform: [        {          translateY: interpolate(            progress,            [0, 1],            [restingY, Math.max(0, restingY - 13)],            Extrapolation.CLAMP,          ),        },        {          scale: interpolate(            progress,            [0, 1],            [restingScale, restingScale + 0.055],            Extrapolation.CLAMP,          ),        },      ],    };  });  return (    <Animated.View      className={className}      pointerEvents={depth === 0 ? "auto" : "none"}      style={[style, animatedStyle]}    >      {children}    </Animated.View>  );}
tension-stack/config.ts
export const TENSION_STACK_CONFIG = {  dismissDuration: 240,  maxVisible: 3,  resistance: 0.72,  threshold: 0.28,  velocityThreshold: 720,} as const;
tension-stack/types.ts
import type { ReactNode } from "react";export type TensionStackItem = {  accessibilityLabel?: string;  children: ReactNode;  id: string;};export type TensionStackItemProps = TensionStackItem;export type TensionStackBaseProps = {  accessibilityLabel?: string;  autoAdvanceInterval?: number;  defaultIndex?: number;  disabled?: boolean;  children: ReactNode;  index?: number;  loop?: boolean;  maxVisible?: number;  onDismiss?: (id: string, index: number) => void;  onIndexChange?: (index: number) => void;  threshold?: number;};
tension-stack/uniwind/index.tsx
import {  Children,  isValidElement,} from "react";import { View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import type { SlotsToClasses } from "#shared/types/slots";import { TensionCard } from "../card";import type {  TensionStackBaseProps,  TensionStackItem,  TensionStackItemProps,} from "../types";import { useTensionStack } from "../use-tension-stack";import {  tensionStackVariants,  type TensionStackSlot,} from "./variants";export type TensionStackProps = TensionStackBaseProps & {  className?: string;  classNames?: SlotsToClasses<TensionStackSlot>;};function TensionStackItem({  children,}: TensionStackItemProps) {  return children;}function TensionStackRoot({  accessibilityLabel = "Swipe the top card to continue",  autoAdvanceInterval,  children,  className,  classNames,  defaultIndex,  disabled,  index,  loop,  maxVisible,  onDismiss,  onIndexChange,  threshold,}: TensionStackProps) {  const items = Children.toArray(children).flatMap<TensionStackItem>(    (child) =>      isValidElement<TensionStackItemProps>(child) &&      child.type === TensionStackItem        ? [child.props]        : [],  );  const {    activeIndex,    gesture,    movingItemId,    onLayout,    translateX,    visibleItems,    width,  } = useTensionStack({    autoAdvanceInterval,    defaultIndex,    disabled,    index,    items,    loop,    maxVisible,    onDismiss,    onIndexChange,    threshold,  });  const { card, root } = tensionStackVariants({ disabled });  return (    <View      accessibilityLabel={accessibilityLabel}      accessibilityRole="adjustable"      accessibilityValue={{        max: items.length,        min: items.length > 0 ? 1 : 0,        now: items.length > 0 ? activeIndex + 1 : 0,      }}      className={root({        className: [className, classNames?.root],      })}      onLayout={onLayout}    >      <GestureDetector gesture={gesture}>        <View className="absolute inset-0">          {visibleItems.map(({ depth, item }) => (            <TensionCard              key={item.id}              className={card({                className: classNames?.card,              })}              depth={depth}              moving={item.id === movingItemId}              translateX={translateX}              width={width}            >              {item.children}            </TensionCard>          ))}        </View>      </GestureDetector>    </View>  );}export const TensionStack = Object.assign(TensionStackRoot, {  Item: TensionStackItem,});
tension-stack/uniwind/variants.ts
import { tv } from "tailwind-variants";export const tensionStackSlots = {  card: "absolute inset-0 overflow-hidden",  root: "relative h-full w-full",} as const;export type TensionStackSlot = keyof typeof tensionStackSlots;export const tensionStackVariants = tv({  slots: tensionStackSlots,  variants: {    disabled: {      true: {        root: "opacity-[0.45]",      },    },  },});
tension-stack/use-tension-stack.ts
import {  useCallback,  useEffect,  useLayoutEffect,  useMemo,  useState,} from "react";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import {  runOnJS,  useReducedMotion,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import { TENSION_STACK_CONFIG } from "./config";import type {  TensionStackBaseProps,  TensionStackItem,} from "./types";type UseTensionStackProps = Omit<  TensionStackBaseProps,  "accessibilityLabel" | "children"> & {  items: readonly TensionStackItem[];};export function useTensionStack({  autoAdvanceInterval,  defaultIndex = 0,  disabled = false,  index,  items,  loop = false,  maxVisible = TENSION_STACK_CONFIG.maxVisible,  onDismiss,  onIndexChange,  threshold = TENSION_STACK_CONFIG.threshold,}: UseTensionStackProps) {  if (threshold <= 0 || threshold >= 1) {    throw new Error(      "TensionStack requires threshold to be between zero and one.",    );  }  if (maxVisible < 1) {    throw new Error("TensionStack requires maxVisible to be at least one.");  }  const isControlled = index !== undefined;  const [internalIndex, setInternalIndex] = useState(defaultIndex);  const [movingItemId, setMovingItemId] = useState(    items[index ?? defaultIndex]?.id,  );  const activeIndex = index ?? internalIndex;  const translateX = useSharedValue(0);  const width = useSharedValue(0);  const reducedMotion = useReducedMotion();  const commitDismiss = useCallback(    () => {      const item = items[activeIndex];      if (!item) {        return;      }      const nextIndex =        activeIndex === items.length - 1          ? loop            ? 0            : activeIndex          : activeIndex + 1;      onDismiss?.(item.id, activeIndex);      if (nextIndex !== activeIndex) {        if (!isControlled) {          setInternalIndex(nextIndex);        }        onIndexChange?.(nextIndex);      }    },    [      activeIndex,      isControlled,      items,      loop,      onDismiss,      onIndexChange,    ],  );  const canAdvance =    items.length > 0 &&    (activeIndex < items.length - 1 || loop);  useLayoutEffect(() => {    translateX.set(0);    setMovingItemId(items[activeIndex]?.id);  }, [activeIndex, items, translateX]);  const dismiss = useCallback(    (direction: -1 | 1 = 1) => {      if (disabled || !canAdvance) {        return;      }      const distance = Math.max(width.get(), 320) * 1.18;      translateX.set(        reducedMotion          ? direction * distance          : withTiming(              direction * distance,              { duration: TENSION_STACK_CONFIG.dismissDuration },              (finished) => {                if (finished) {                  runOnJS(commitDismiss)();                }              },            ),      );      if (reducedMotion) {        commitDismiss();      }    },    [      commitDismiss,      disabled,      canAdvance,      reducedMotion,      translateX,      width,    ],  );  useEffect(() => {    if (!autoAdvanceInterval || disabled || items.length < 2) {      return;    }    const timer = setInterval(() => {      dismiss(1);    }, autoAdvanceInterval);    return () => {      clearInterval(timer);    };  }, [autoAdvanceInterval, disabled, dismiss, items.length]);  const onLayout = useCallback(    (event: LayoutChangeEvent) => {      width.set(event.nativeEvent.layout.width);    },    [width],  );  const gesture = useMemo(    () =>      Gesture.Pan()        .enabled(!disabled && items.length > 0)        .minDistance(3)        .onUpdate((event) => {          const distance = event.translationX;          const boundary = width.get() * 0.55;          if (Math.abs(distance) <= boundary) {            translateX.set(distance);            return;          }          const overflow = Math.abs(distance) - boundary;          translateX.set(            Math.sign(distance) *              (boundary + overflow * TENSION_STACK_CONFIG.resistance),          );        })        .onEnd((event) => {          const stackWidth = width.get();          const projected =            translateX.get() + event.velocityX * 0.12;          const shouldDismiss =            Math.abs(projected) >= stackWidth * threshold ||            Math.abs(event.velocityX) >=              TENSION_STACK_CONFIG.velocityThreshold;          if (shouldDismiss && canAdvance) {            const direction = projected < 0 ? -1 : 1;            const distance = Math.max(stackWidth, 320) * 1.18;            translateX.set(              withTiming(                direction * distance,                { duration: TENSION_STACK_CONFIG.dismissDuration },                (finished) => {                  if (finished) {                    runOnJS(commitDismiss)();                  }                },              ),            );            return;          }          translateX.set(            withSpring(0, {              damping: 18,              mass: 0.72,              stiffness: 210,            }),          );        }),    [      commitDismiss,      canAdvance,      disabled,      items.length,      threshold,      translateX,      width,    ],  );  const visibleItems = Array.from(    {      length: Math.min(        loop ? items.length : items.length - activeIndex,        maxVisible,      ),    },    (_, depth) => {      const itemIndex = loop        ? (activeIndex + depth) % items.length        : activeIndex + depth;      return {        depth,        item: items[itemIndex] as TensionStackItem,      };    },  ).reverse();  return {    activeIndex,    gesture,    movingItemId,    onLayout,    translateX,    visibleItems,    width,  };}

API reference

Generated directly from the exported component props.

TensionStack

TensionStack.Item