Veltrix

data visualization

Compare Lens

A Victory-powered comparison chart reveals the previous series through a continuously draggable Skia lens.

AppleiOSAndroidAndroidExpoExpo Go
victoryskiagesture handlerreanimatedworklets
GitHubOpen in GitHub

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-worklets

Behavior

  • 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-variants

Usage

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

Component files

6 files

compare-lens/artwork.tsx
import {  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.ts
export 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.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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],  };}

API reference

Generated directly from the exported component props.

CompareLens