skia shaders
Ink Reveal
An organic ink mask that reveals arbitrary native content through drawing or controlled progress.
iOSAndroidExpo Go
skiahapticsgesture handlerreanimated
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-reanimatedInstall tailwind variants
pnpm add tailwind-variantsUsage
RewardReveal.tsximport { 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.tsximport { 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> );}ink-reveal/config.tsexport 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.tsximport { 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.tsimport 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.tsximport { 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.tsimport { 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.tsimport { 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, };}