cards
Tension Stack
A resistant swipe stack that releases the active card and pulls the next layer into place.
iOSAndroidExpo Go
gesture handlerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-gesture-handler react-native-reanimatedanatomy.tsx<TensionStack> <TensionStack.Item /></TensionStack>Install tailwind variants
pnpm add tailwind-variantsUsage
TensionStackExample.tsximport { 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> );}tension-stack/card.tsximport 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.tsexport const TENSION_STACK_CONFIG = { dismissDuration: 240, maxVisible: 3, resistance: 0.72, threshold: 0.28, velocityThreshold: 720,} as const;tension-stack/types.tsimport 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.tsximport { 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.tsimport { 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.tsimport { 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, };}