data visualization
Compare Lens
A Victory-powered comparison chart reveals the previous series through a continuously draggable Skia lens.
iOSAndroidExpo Go
victoryskiagesture handlerreanimatedworklets
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add victory-native @shopify/react-native-skia react-native-gesture-handler react-native-reanimated react-native-workletsBehavior
- The parent defines the chart width and height.
- Releasing snaps the lens to the nearest point.
- Controlled and uncontrolled selection are supported.
- Reduced Motion removes the snap spring.
Install tailwind variants
pnpm add tailwind-variantsUsage
CompareLensExample.tsximport { CompareLens } from "@animations/ui/components/data-visualization/compare-lens/uniwind";const currentWeek = [ { label: "Mon", value: 42 }, { label: "Tue", value: 58 }, { label: "Wed", value: 51 }, { label: "Thu", value: 76 }, { label: "Fri", value: 68 }, { label: "Sat", value: 84 }, { label: "Sun", value: 79 },];const previousWeek = [ { label: "Mon", value: 36 }, { label: "Tue", value: 49 }, { label: "Wed", value: 61 }, { label: "Thu", value: 57 }, { label: "Fri", value: 72 }, { label: "Sat", value: 65 }, { label: "Sun", value: 71 },];export function CompareLensExample() { return ( <CompareLens className="h-72 w-full" classNames={{ callout: "bg-slate-950", difference: "bg-violet-500", }} comparison={previousWeek} comparisonColor="#8b5cf6" formatValue={(value) => `${value}%`} lensColor="#8b5cf6" primary={currentWeek} /> );}compare-lens/artwork.tsximport { Circle, Group, Line, RoundedRect, rect, rrect, vec,} from "@shopify/react-native-skia";import { useMemo } from "react";import { StyleSheet, View } from "react-native";import { useDerivedValue, type SharedValue,} from "react-native-reanimated";import { CartesianChart, Line as VictoryLine, Scatter, type ChartBounds, type PointsArray,} from "victory-native";import { COMPARE_LENS_CONFIG } from "./config";import type { CompareLensPoint } from "./types";type CompareLensArtworkProps = { comparison: readonly CompareLensPoint[]; comparisonColor: string; gridColor: string; lensColor: string; lensWidth: number; lensX: SharedValue<number>; primary: readonly CompareLensPoint[]; primaryColor: string;};type CompareLensLayerProps = { chartBounds: ChartBounds; comparisonColor: string; comparisonPoints: PointsArray; lensColor: string; lensWidth: number; lensX: SharedValue<number>; primaryColor: string; primaryPoints: PointsArray;};function CompareLensLayer({ chartBounds, comparisonColor, comparisonPoints, lensColor, lensWidth, lensX, primaryColor, primaryPoints,}: CompareLensLayerProps) { const lensLeft = useDerivedValue( () => lensX.get() - lensWidth / 2, ); const lensClip = useDerivedValue(() => rrect( rect( lensLeft.get(), chartBounds.top, lensWidth, chartBounds.bottom - chartBounds.top, ), 18, 18, ), ); const lensLineStart = useDerivedValue(() => vec(lensX.get(), chartBounds.top), ); const lensLineEnd = useDerivedValue(() => vec(lensX.get(), chartBounds.bottom), ); return ( <> <VictoryLine color={primaryColor} curveType="linear" points={primaryPoints} strokeCap="round" strokeJoin="round" strokeWidth={3} /> <Group clip={lensClip}> <RoundedRect color={lensColor} height={chartBounds.bottom - chartBounds.top} opacity={0.08} r={18} width={lensWidth} x={lensLeft} y={chartBounds.top} /> <VictoryLine color={comparisonColor} curveType="linear" points={comparisonPoints} strokeCap="round" strokeJoin="round" strokeWidth={3} /> <Scatter color={comparisonColor} points={comparisonPoints} radius={4} /> </Group> <Line color={lensColor} opacity={0.55} p1={lensLineStart} p2={lensLineEnd} strokeWidth={1.5} /> <Circle color={lensColor} cx={lensX} cy={chartBounds.bottom} r={6} /> </> );}export function CompareLensArtwork({ comparison, comparisonColor, gridColor, lensColor, lensWidth, lensX, primary, primaryColor,}: CompareLensArtworkProps) { const chartData = useMemo( () => primary.map((point, index) => ({ comparison: comparison[index]?.value ?? point.value, index, primary: point.value, })), [comparison, primary], ); return ( <View pointerEvents="none" style={StyleSheet.absoluteFill}> <CartesianChart data={chartData} domainPadding={{ bottom: 8, top: 8 }} frame={{ lineWidth: 0 }} padding={COMPARE_LENS_CONFIG.plotPadding} xAxis={{ lineWidth: 0 }} xKey="index" yAxis={[ { lineColor: gridColor, lineWidth: 1, tickCount: 3, }, ]} yKeys={["primary", "comparison"]} > {({ chartBounds, points }) => ( <CompareLensLayer chartBounds={chartBounds} comparisonColor={comparisonColor} comparisonPoints={points.comparison} lensColor={lensColor} lensWidth={lensWidth} lensX={lensX} primaryColor={primaryColor} primaryPoints={points.primary} /> )} </CartesianChart> </View> );}compare-lens/config.tsexport const COMPARE_LENS_CONFIG = { lensWidth: 84, plotPadding: { bottom: 30, left: 24, right: 24, top: 62, }, spring: { damping: 18, mass: 0.7, stiffness: 210, },} as const;export const COMPARE_LENS_COLORS = { comparison: "#6f5cff", grid: "#deddd6", lens: "#6f5cff", primary: "#171713",} as const;compare-lens/types.tsexport type CompareLensPoint = { label: string; value: number;};export type CompareLensBaseProps = { accessibilityLabel?: string; comparison: readonly CompareLensPoint[]; comparisonColor?: string; defaultSelectedIndex?: number; disabled?: boolean; formatValue?: (value: number) => string; gridColor?: string; lensColor?: string; lensWidth?: number; onSelectedIndexChange?: (index: number) => void; primary: readonly CompareLensPoint[]; primaryColor?: string; selectedIndex?: number;};compare-lens/uniwind/index.tsximport { Text, View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import type { SlotsToClasses } from "#shared/types/slots";import { CompareLensArtwork } from "../artwork";import { COMPARE_LENS_COLORS, COMPARE_LENS_CONFIG,} from "../config";import type { CompareLensBaseProps } from "../types";import { useCompareLens } from "../use-compare-lens";import { compareLensVariants, type CompareLensSlot,} from "./variants";export type CompareLensProps = CompareLensBaseProps & { className?: string; classNames?: SlotsToClasses<CompareLensSlot>;};function defaultFormatValue(value: number) { return String(value);}export function CompareLens({ accessibilityLabel = "Compare data series", className, classNames, comparison, comparisonColor = COMPARE_LENS_COLORS.comparison, defaultSelectedIndex, disabled, formatValue = defaultFormatValue, gridColor = COMPARE_LENS_COLORS.grid, lensColor = COMPARE_LENS_COLORS.lens, lensWidth = COMPARE_LENS_CONFIG.lensWidth, onSelectedIndexChange, primary, primaryColor = COMPARE_LENS_COLORS.primary, selectedIndex,}: CompareLensProps) { const { accessibilityActions, comparisonPoint, difference, gesture, lensX, onAccessibilityAction, onLayout, primaryPoint, } = useCompareLens({ comparison, defaultSelectedIndex, disabled, onSelectedIndexChange, primary, selectedIndex, }); const { callout, comparisonValue, container, difference: differenceSlot, label, primaryValue, } = compareLensVariants(); const differencePrefix = difference > 0 ? "+" : ""; return ( <GestureDetector gesture={gesture}> <View accessibilityActions={accessibilityActions} accessibilityLabel={accessibilityLabel} accessibilityRole="adjustable" accessibilityState={{ disabled }} accessibilityValue={{ text: `${primaryPoint.label}: ${formatValue(primaryPoint.value)} compared with ${formatValue(comparisonPoint.value)}, difference ${differencePrefix}${formatValue(difference)}`, }} accessible className={container({ className: [className, classNames?.container], })} onAccessibilityAction={onAccessibilityAction} onLayout={onLayout} > <View className={callout({ className: classNames?.callout, })} > <Text className={label({ className: classNames?.label })}> {primaryPoint.label} </Text> <Text className={primaryValue({ className: classNames?.primaryValue, })} > {formatValue(primaryPoint.value)} </Text> <Text className={comparisonValue({ className: classNames?.comparisonValue, })} > {formatValue(comparisonPoint.value)} </Text> <Text className={differenceSlot({ className: classNames?.difference, })} > {differencePrefix} {formatValue(difference)} </Text> </View> <CompareLensArtwork comparison={comparison} comparisonColor={comparisonColor} gridColor={gridColor} lensColor={lensColor} lensWidth={lensWidth} lensX={lensX} primary={primary} primaryColor={primaryColor} /> </View> </GestureDetector> );}compare-lens/uniwind/variants.tsimport { tv } from "tailwind-variants";export const compareLensSlots = { callout: "absolute top-2 z-10 self-center flex-row items-center gap-2.5 rounded-2xl bg-[#171713] px-3.5 py-2.5", comparisonValue: "text-xs font-extrabold text-[#b9b0ff]", container: "relative overflow-hidden", difference: "overflow-hidden rounded-[10px] bg-white/10 px-1.75 py-0.75 text-[11px] font-black text-white", label: "text-[11px] font-black text-white", primaryValue: "text-xs font-extrabold text-white",} as const;export type CompareLensSlot = keyof typeof compareLensSlots;export const compareLensVariants = tv({ slots: compareLensSlots,});compare-lens/use-compare-lens.tsimport { useCallback, useMemo, useState } from "react";import type { AccessibilityActionEvent, LayoutChangeEvent,} from "react-native";import { Gesture } from "react-native-gesture-handler";import { runOnJS, useAnimatedReaction, useReducedMotion, useSharedValue, withSpring,} from "react-native-reanimated";import { COMPARE_LENS_CONFIG } from "./config";import type { CompareLensBaseProps } from "./types";type ChartLayout = { height: number; width: number;};function clamp(value: number, minimum: number, maximum: number) { "worklet"; return Math.min(maximum, Math.max(minimum, value));}export function useCompareLens({ comparison, defaultSelectedIndex, disabled = false, onSelectedIndexChange, primary, selectedIndex,}: CompareLensBaseProps) { if (primary.length < 2) { throw new Error("CompareLens requires at least two primary points."); } if (primary.length !== comparison.length) { throw new Error( "CompareLens requires primary and comparison series with matching lengths.", ); } const lastIndex = primary.length - 1; const initialIndex = clamp( defaultSelectedIndex ?? Math.floor(lastIndex / 2), 0, lastIndex, ); const [internalIndex, setInternalIndex] = useState(initialIndex); const [layout, setLayout] = useState<ChartLayout>({ height: 0, width: 0, }); const isControlled = selectedIndex !== undefined; const currentIndex = clamp( selectedIndex ?? internalIndex, 0, lastIndex, ); const plotStart = COMPARE_LENS_CONFIG.plotPadding.left; const plotEnd = Math.max( plotStart, layout.width - COMPARE_LENS_CONFIG.plotPadding.right, ); const plotWidth = Math.max(0, plotEnd - plotStart); const targetX = plotStart + (currentIndex / lastIndex) * plotWidth; const lensX = useSharedValue(targetX); const lastPreviewedIndex = useSharedValue(currentIndex); const dragging = useSharedValue(false); const reducedMotion = useReducedMotion(); const selectIndex = useCallback( (nextIndex: number) => { const boundedIndex = Math.min( lastIndex, Math.max(0, nextIndex), ); if (!isControlled) { setInternalIndex(boundedIndex); } onSelectedIndexChange?.(boundedIndex); }, [isControlled, lastIndex, onSelectedIndexChange], ); useAnimatedReaction( () => ({ dragging: dragging.get(), targetX, }), (current, previous) => { if ( current.dragging || current.targetX === previous?.targetX ) { return; } lensX.set( reducedMotion ? current.targetX : withSpring( current.targetX, COMPARE_LENS_CONFIG.spring, ), ); lastPreviewedIndex.set(currentIndex); }, [currentIndex, reducedMotion, targetX], ); const gesture = useMemo( () => Gesture.Pan() .enabled(!disabled) .minDistance(2) .onBegin((event) => { dragging.set(true); lensX.set(clamp(event.x, plotStart, plotEnd)); }) .onUpdate((event) => { const nextX = clamp(event.x, plotStart, plotEnd); const nextIndex = Math.round( ((nextX - plotStart) / Math.max(1, plotWidth)) * lastIndex, ); lensX.set(nextX); if (nextIndex !== lastPreviewedIndex.get()) { lastPreviewedIndex.set(nextIndex); runOnJS(selectIndex)(nextIndex); } }) .onFinalize(() => { const nextIndex = lastPreviewedIndex.get(); const nextX = plotStart + (nextIndex / lastIndex) * plotWidth; lensX.set( reducedMotion ? nextX : withSpring(nextX, COMPARE_LENS_CONFIG.spring), ); dragging.set(false); }), [ disabled, dragging, lastIndex, lastPreviewedIndex, lensX, plotEnd, plotStart, plotWidth, reducedMotion, selectIndex, ], ); const onLayout = useCallback((event: LayoutChangeEvent) => { const { height, width } = event.nativeEvent.layout; setLayout((currentLayout) => { if ( currentLayout.height === height && currentLayout.width === width ) { return currentLayout; } return { height, width }; }); }, []); const onAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { if (event.nativeEvent.actionName === "increment") { selectIndex(currentIndex + 1); } if (event.nativeEvent.actionName === "decrement") { selectIndex(currentIndex - 1); } }, [currentIndex, selectIndex], ); return { accessibilityActions: [ { name: "increment" as const }, { name: "decrement" as const }, ], comparisonPoint: comparison[currentIndex], currentIndex, difference: comparison[currentIndex].value - primary[currentIndex].value, gesture, layout, lensX, onAccessibilityAction, onLayout, primaryPoint: primary[currentIndex], };}