Veltrix

skia shaders

Ink Reveal

An organic ink mask that reveals arbitrary native content through drawing or controlled progress.

AppleiOSAndroidAndroidExpoExpo Go
skiahapticsgesture handlerreanimated
GitHubOpen in GitHub

Installation

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

pnpm add @shopify/react-native-skia expo-haptics react-native-gesture-handler react-native-reanimated

Install tailwind variants

pnpm add tailwind-variants

Usage

RewardReveal.tsx
import { useRef } from "react";import { Button, Text, View } from "react-native";import { InkReveal } from "@animations/ui/components/skia-shaders/ink-reveal/uniwind";import type { InkRevealHandle } from "@animations/ui/components/skia-shaders/ink-reveal/types";export function RewardReveal() {  const revealRef = useRef<InkRevealHandle>(null);  return (    <>      <InkReveal        brushRadius={38}        className="h-60 w-full rounded-3xl"        classNames={{          content: "bg-lime-300 p-6",        }}        coverColor="#171713"        onComplete={() => console.log("Reward revealed")}        onProgressChange={(progress) => console.log(progress)}        ref={revealRef}      >        <View className="flex-1">          <Text className="text-4xl font-black">30% OFF</Text>          <Text className="text-lg font-bold">INK30</Text>        </View>      </InkReveal>      <Button        onPress={() => revealRef.current?.reset()}        title="Reset"      />    </>  );}

Controlled progress

ControlledProgressExample.tsx
import { Text, View } from "react-native";import { InkReveal } from "@animations/ui/components/skia-shaders/ink-reveal/uniwind";export function ControlledProgressExample() {  const downloadProgress = 0.65;  return (    <InkReveal      className="h-55"      disabled      progress={downloadProgress}    >      <View>        <Text>Downloaded artwork</Text>      </View>    </InkReveal>  );}

Component files

6 files

ink-reveal/config.ts
export const INK_REVEAL_CONFIG = {  brushRadius: 34,  completionDuration: 760,  completionThreshold: 0.78,  edgeSoftness: 7,  minimumPointDistance: 4,  previewBrushRadius: 22,  progressDuration: 620,} as const;export const INK_REVEAL_COLORS = {  cover: "#171713",} as const;
ink-reveal/ink-layer.tsx
import {  BlurMask,  Canvas,  Circle,  Fill,  Group,  Mask,  Path,  Rect,  Skia,} from "@shopify/react-native-skia";import { StyleSheet } from "react-native";import {  useDerivedValue,  type SharedValue,} from "react-native-reanimated";import type { InkRevealPoint } from "./types";type InkRevealLayerProps = {  brushRadius: number;  completionRadius: SharedValue<number>;  completionX: SharedValue<number>;  completionY: SharedValue<number>;  coverColor: string;  edgeSoftness: number;  height: SharedValue<number>;  points: SharedValue<InkRevealPoint[]>;  progress: SharedValue<number>;  renderPoints: boolean;  width: SharedValue<number>;};type ControlledEdgePointProps = {  brushRadius: number;  height: SharedValue<number>;  index: number;  progress: SharedValue<number>;  width: SharedValue<number>;};const CONTROLLED_EDGE_POINT_COUNT = 7;function ControlledEdgePoint({  brushRadius,  height,  index,  progress,  width,}: ControlledEdgePointProps) {  const radius = brushRadius * 0.52;  const cx = useDerivedValue(() => {    const revealWidth = progress.get() * width.get();    const variation =      Math.sin(progress.get() * Math.PI * 4 + index * 1.7) *      radius *      0.18;    return revealWidth - radius * 0.72 + variation;  });  const cy = useDerivedValue(    () =>      (height.get() * index) /      (CONTROLLED_EDGE_POINT_COUNT - 1),  );  return <Circle cx={cx} cy={cy} r={radius} />;}export function InkRevealLayer({  brushRadius,  completionRadius,  completionX,  completionY,  coverColor,  edgeSoftness,  height,  points,  progress,  renderPoints,  width,}: InkRevealLayerProps) {  const revealWidth = useDerivedValue(    () => progress.get() * width.get(),  );  const controlledBaseWidth = useDerivedValue(() => {    const currentProgress = progress.get();    if (currentProgress >= 1) {      return width.get();    }    return Math.max(      0,      revealWidth.get() - brushRadius * 0.38,    );  });  const completionUpperX = useDerivedValue(    () => completionX.get() + completionRadius.get() * 0.31,  );  const completionUpperY = useDerivedValue(    () => completionY.get() - completionRadius.get() * 0.18,  );  const completionUpperRadius = useDerivedValue(    () => completionRadius.get() * 0.76,  );  const completionLowerX = useDerivedValue(    () => completionX.get() - completionRadius.get() * 0.24,  );  const completionLowerY = useDerivedValue(    () => completionY.get() + completionRadius.get() * 0.27,  );  const completionLowerRadius = useDerivedValue(    () => completionRadius.get() * 0.68,  );  const completionSideX = useDerivedValue(    () => completionX.get() + completionRadius.get() * 0.08,  );  const completionSideY = useDerivedValue(    () => completionY.get() + completionRadius.get() * 0.34,  );  const completionSideRadius = useDerivedValue(    () => completionRadius.get() * 0.54,  );  const controlledEdgeOpacity = useDerivedValue(    () =>      progress.get() > 0 && progress.get() < 1 ? 1 : 0,  );  const inkPath = useDerivedValue(() => {    const path = Skia.PathBuilder.Make();    for (const point of points.get()) {      if (point.startsStroke) {        path.moveTo(point.x, point.y);        path.lineTo(point.x + 0.01, point.y);        continue;      }      path.lineTo(point.x, point.y);    }    return path.detach();  });  return (    <Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>      <Mask        mode="luminance"        mask={          <Group>            <Fill color="white" />            <Rect              color="black"              height={height}              width={controlledBaseWidth}              x={0}              y={0}            />            <Group              color="black"              opacity={controlledEdgeOpacity}            >              {Array.from(                { length: CONTROLLED_EDGE_POINT_COUNT },                (_value, index) => (                  <ControlledEdgePoint                    brushRadius={brushRadius}                    height={height}                    index={index}                    key={index}                    progress={progress}                    width={width}                  />                ),              )}            </Group>            <Group color="black">              <BlurMask                blur={edgeSoftness}                respectCTM                style="normal"              />              <Circle                cx={completionX}                cy={completionY}                r={completionRadius}              />              <Circle                cx={completionUpperX}                cy={completionUpperY}                r={completionUpperRadius}              />              <Circle                cx={completionLowerX}                cy={completionLowerY}                r={completionLowerRadius}              />              <Circle                cx={completionSideX}                cy={completionSideY}                r={completionSideRadius}              />            </Group>            {renderPoints && (              <Group color="black">                <BlurMask                  blur={edgeSoftness}                  respectCTM                  style="normal"                />                <Path                  path={inkPath}                  strokeCap="round"                  strokeJoin="round"                  strokeWidth={brushRadius * 2}                  style="stroke"                />              </Group>            )}          </Group>        }      >        <Fill color={coverColor} />      </Mask>    </Canvas>  );}
ink-reveal/types.ts
import type { ReactNode } from "react";export type InkRevealBaseProps = {  accessibilityLabel?: string;  brushRadius?: number;  children: ReactNode;  completionThreshold?: number;  coverColor?: string;  defaultProgress?: number;  disabled?: boolean;  edgeSoftness?: number;  haptics?: boolean;  onComplete?: () => void;  onProgressChange?: (progress: number) => void;  paused?: boolean;  /** Controlled reveal progress from `0` to `1`. */  progress?: number;};export type InkRevealHandle = {  /** Complete the reveal. */  complete: () => void;  /** Clear strokes and reset uncontrolled progress. */  reset: () => void;};export type InkRevealPoint = {  startsStroke?: boolean;  x: number;  y: number;};
ink-reveal/uniwind/index.tsx
import {  useImperativeHandle,  type Ref,} from "react";import { View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import type { SlotsToClasses } from "#shared/types/slots";import {  INK_REVEAL_COLORS,  INK_REVEAL_CONFIG,} from "../config";import { InkRevealLayer } from "../ink-layer";import type {  InkRevealBaseProps,  InkRevealHandle,} from "../types";import { useInkReveal } from "../use-ink-reveal";import {  inkRevealVariants,  type InkRevealSlot,} from "./variants";export type InkRevealProps = InkRevealBaseProps & {  className?: string;  classNames?: SlotsToClasses<InkRevealSlot>;  ref?: Ref<InkRevealHandle>;};export function InkReveal({  accessibilityLabel = "Reveal hidden content",  brushRadius = INK_REVEAL_CONFIG.brushRadius,  children,  className,  classNames,  completionThreshold,  coverColor = INK_REVEAL_COLORS.cover,  defaultProgress,  disabled,  edgeSoftness = INK_REVEAL_CONFIG.edgeSoftness,  haptics,  onComplete,  onProgressChange,  paused,  progress,  ref,}: InkRevealProps) {  const {    complete,    completionRadius,    completionX,    completionY,    displayedProgress,    gesture,    height,    isComplete,    onLayout,    points,    reset,    width,  } = useInkReveal({    brushRadius,    completionThreshold,    defaultProgress,    disabled,    haptics,    onComplete,    onProgressChange,    paused,    progress,  });  useImperativeHandle(    ref,    () => ({      complete,      reset,    }),    [complete, reset],  );  const { container, content } = inkRevealVariants();  return (    <GestureDetector gesture={gesture}>      <View        accessibilityHint="Draw over the surface or double tap to reveal all content."        accessibilityLabel={accessibilityLabel}        accessibilityRole="button"        accessibilityState={{          disabled: Boolean(disabled),          expanded: isComplete,        }}        accessible={!isComplete}        className={container({          className: [className, classNames?.container],        })}        onAccessibilityTap={complete}        onLayout={onLayout}      >        <View          accessibilityElementsHidden={!isComplete}          className={content({            className: classNames?.content,          })}          importantForAccessibility={            isComplete ? "auto" : "no-hide-descendants"          }        >          {children}        </View>        <InkRevealLayer          brushRadius={brushRadius}          completionRadius={completionRadius}          completionX={completionX}          completionY={completionY}          coverColor={coverColor}          edgeSoftness={edgeSoftness}          height={height}          points={points}          progress={displayedProgress}          renderPoints={!disabled}          width={width}        />      </View>    </GestureDetector>  );}
ink-reveal/uniwind/variants.ts
import { tv } from "tailwind-variants";export const inkRevealSlots = {  container: "relative overflow-hidden",  content: "flex-1",} as const;export type InkRevealSlot = keyof typeof inkRevealSlots;export const inkRevealVariants = tv({  slots: inkRevealSlots,});
ink-reveal/use-ink-reveal.ts
import {  useCallback,  useMemo,  useState,} from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import {  Easing,  runOnJS,  useDerivedValue,  useReducedMotion,  useSharedValue,  withTiming,} from "react-native-reanimated";import { INK_REVEAL_CONFIG } from "./config";import type {  InkRevealBaseProps,  InkRevealPoint,} from "./types";type UseInkRevealOptions = Omit<  InkRevealBaseProps,  "accessibilityLabel" | "children" | "coverColor" | "edgeSoftness">;function clampProgress(progress: number) {  "worklet";  return Math.min(1, Math.max(0, progress));}export function useInkReveal({  brushRadius = INK_REVEAL_CONFIG.brushRadius,  completionThreshold = INK_REVEAL_CONFIG.completionThreshold,  defaultProgress = 0,  disabled = false,  haptics = true,  onComplete,  onProgressChange,  paused = false,  progress,}: UseInkRevealOptions) {  if (brushRadius <= 0) {    throw new Error("InkReveal requires brushRadius to be positive.");  }  if (completionThreshold <= 0 || completionThreshold > 1) {    throw new Error(      "InkReveal requires completionThreshold to be greater than zero and at most one.",    );  }  const normalizedDefaultProgress = clampProgress(defaultProgress);  const normalizedProgress =    progress === undefined      ? undefined      : clampProgress(progress);  const [completed, setCompleted] = useState(    (normalizedProgress ?? normalizedDefaultProgress) >=      completionThreshold,  );  const internalProgress = useSharedValue(    normalizedDefaultProgress,  );  const points = useSharedValue<InkRevealPoint[]>([]);  const revealedArea = useSharedValue(0);  const completionRadius = useSharedValue(0);  const completionX = useSharedValue(0);  const completionY = useSharedValue(0);  const width = useSharedValue(0);  const height = useSharedValue(0);  const reducedMotion = useReducedMotion();  const isControlled = normalizedProgress !== undefined;  const isComplete =    completed ||    (normalizedProgress ?? normalizedDefaultProgress) >=      completionThreshold;  const displayedProgress = useDerivedValue(() => {    const nextProgress =      normalizedProgress ?? internalProgress.get();    return reducedMotion || paused      ? nextProgress      : withTiming(nextProgress, {          duration: INK_REVEAL_CONFIG.progressDuration,        });  });  const reportProgress = useCallback(    (nextProgress: number) => {      const clampedProgress = Math.min(        1,        Math.max(0, nextProgress),      );      if (        clampedProgress >= completionThreshold &&        !completed      ) {        setCompleted(true);        if (!isControlled) {          const targetRadius =            Math.hypot(width.get(), height.get()) +            brushRadius * 2;          completionRadius.set(            reducedMotion              ? targetRadius              : withTiming(targetRadius, {                  duration:                    INK_REVEAL_CONFIG.completionDuration,                  easing: Easing.out(Easing.cubic),                }),          );        }        if (haptics) {          void Haptics.notificationAsync(            Haptics.NotificationFeedbackType.Success,          );        }        onComplete?.();      }      onProgressChange?.(        clampedProgress >= completionThreshold          ? 1          : clampedProgress,      );    },    [      completed,      brushRadius,      completionRadius,      completionThreshold,      height,      haptics,      isControlled,      onComplete,      onProgressChange,      reducedMotion,      width,    ],  );  const complete = useCallback(() => {    completionX.set(width.get() / 2);    completionY.set(height.get() / 2);    reportProgress(1);  }, [    completionX,    completionY,    height,    reportProgress,    width,  ]);  const reset = useCallback(() => {    completionRadius.set(0);    points.set([]);    revealedArea.set(0);    setCompleted(false);    if (!isControlled) {      internalProgress.set(        reducedMotion          ? normalizedDefaultProgress          : withTiming(normalizedDefaultProgress, {              duration: INK_REVEAL_CONFIG.progressDuration,            }),      );    }    onProgressChange?.(      normalizedProgress ?? normalizedDefaultProgress,    );  }, [    completionRadius,    internalProgress,    isControlled,    normalizedDefaultProgress,    normalizedProgress,    onProgressChange,    points,    reducedMotion,    revealedArea,  ]);  const gesture = useMemo(() => {    function appendPoint(point: InkRevealPoint) {      "worklet";      points.set([...points.get(), point]);    }    function estimatedProgress() {      "worklet";      const area = width.get() * height.get();      if (area <= 0) {        return 0;      }      return clampProgress(        revealedArea.get() / area +          (normalizedProgress ?? internalProgress.get()),      );    }    const pan = Gesture.Pan()      .enabled(!disabled && !paused && !isComplete)      .minDistance(2)      .onBegin((event) => {        completionX.set(event.x);        completionY.set(event.y);        appendPoint({          startsStroke: true,          x: event.x,          y: event.y,        });        revealedArea.set(          revealedArea.get() + Math.PI * brushRadius ** 2,        );      })      .onUpdate((event) => {        const currentPoints = points.get();        const previousPoint =          currentPoints[currentPoints.length - 1];        if (!previousPoint) {          return;        }        const distance = Math.hypot(          event.x - previousPoint.x,          event.y - previousPoint.y,        );        if (          distance <          INK_REVEAL_CONFIG.minimumPointDistance        ) {          return;        }        appendPoint({          x: event.x,          y: event.y,        });        completionX.set(event.x);        completionY.set(event.y);        revealedArea.set(          revealedArea.get() + distance * brushRadius * 2,        );      })      .onEnd(() => {        runOnJS(reportProgress)(estimatedProgress());      });    const tap = Gesture.Tap()      .enabled(!disabled && !paused && !isComplete)      .onEnd((event, success) => {        if (!success) {          return;        }        appendPoint({          startsStroke: true,          x: event.x,          y: event.y,        });        completionX.set(event.x);        completionY.set(event.y);        revealedArea.set(          revealedArea.get() + Math.PI * brushRadius ** 2,        );        runOnJS(reportProgress)(estimatedProgress());      });    return Gesture.Race(pan, tap);  }, [    brushRadius,    completionX,    completionY,    disabled,    height,    internalProgress,    isComplete,    normalizedProgress,    paused,    points,    reportProgress,    revealedArea,    width,  ]);  const onLayout = useCallback(    (event: LayoutChangeEvent) => {      width.set(event.nativeEvent.layout.width);      height.set(event.nativeEvent.layout.height);    },    [height, width],  );  return {    complete,    completionRadius,    completionX,    completionY,    displayedProgress,    gesture,    height,    isComplete,    onLayout,    points,    reset,    width,  };}

API reference

Generated directly from the exported component props.

InkReveal