skia shaders
Frosted Refocus
A draggable glass lens restores sharp color over softened native content.
iOSAndroidExpo Go
skiagesture handlerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @shopify/react-native-skia react-native-gesture-handler react-native-reanimatedBehavior
- The parent defines width and height.
- Content is captured once instead of rendering two native trees.
- Dragging takes ownership from
autoPlay. - Changing
refreshKeyrequests a new snapshot after content updates. - Lens position remains constrained to the captured bounds.
Install tailwind variants
pnpm add tailwind-variantsUsage
FrostedRefocusExample.tsximport { Text, View } from "react-native";import { FrostedRefocus } from "@animations/ui/components/skia-shaders/frosted-refocus/uniwind";export function FrostedRefocusExample() { return ( <View className="h-70"> <FrostedRefocus className="rounded-[28px]" classNames={{ content: "bg-black" }} lensRadius={68} > <View className="flex-1 bg-violet-600 p-6"> <Text className="text-3xl font-extrabold text-white"> Move to refocus </Text> </View> </FrostedRefocus> </View> );}frosted-refocus/config.tsexport const FROSTED_REFOCUS_CONFIG = { blur: 7, borderWidth: 2, lensRadius: 62, previewDuration: 4200,} as const;frosted-refocus/layer.tsximport { Blur, BlurMask, Canvas, Circle, ColorMatrix, Fill, Image, ImageShader, Line, RadialGradient, rect, type SkImage, vec,} from "@shopify/react-native-skia";import { useMemo } from "react";import { type SharedValue, useDerivedValue,} from "react-native-reanimated";import { StyleSheet } from "react-native";type FrostedLayerProps = { blur: number; borderColor: string; borderWidth: number; desaturation: number; height: number; image: SkImage; lensRadius: number; lensScale: SharedValue<number>; lensX: SharedValue<number>; lensY: SharedValue<number>; magnification: number; overlayColor: string; refractionColors: readonly [string, string]; reticle: boolean; width: number;};export function FrostedLayer({ blur, borderColor, borderWidth, desaturation, height, image, lensRadius, lensScale, lensX, lensY, magnification, overlayColor, refractionColors, reticle, width,}: FrostedLayerProps) { if (desaturation < 0 || desaturation > 1) { throw new Error( "FrostedRefocus requires desaturation between zero and one.", ); } const imageRect = rect(0, 0, width, height); const colorMatrix = useMemo(() => { const red = 0.2126 * desaturation; const green = 0.7152 * desaturation; const blue = 0.0722 * desaturation; const retainedColor = 1 - desaturation; return [ retainedColor + red, green, blue, 0, 0, red, retainedColor + green, blue, 0, 0, red, green, retainedColor + blue, 0, 0, 0, 0, 0, 1, 0, ]; }, [desaturation]); const radius = useDerivedValue( () => lensRadius * lensScale.get(), ); const cyanX = useDerivedValue(() => lensX.get() - 1.5); const magentaX = useDerivedValue(() => lensX.get() + 1.5); const gradientCenter = useDerivedValue(() => vec(lensX.get(), lensY.get()), ); const horizontalStart = useDerivedValue(() => vec(lensX.get() - radius.get() * 0.7, lensY.get()), ); const horizontalEnd = useDerivedValue(() => vec(lensX.get() + radius.get() * 0.7, lensY.get()), ); const verticalStart = useDerivedValue(() => vec(lensX.get(), lensY.get() - radius.get() * 0.7), ); const verticalEnd = useDerivedValue(() => vec(lensX.get(), lensY.get() + radius.get() * 0.7), ); const imageTransform = useDerivedValue(() => [ { translateX: lensX.get() }, { translateY: lensY.get() }, { scale: 1 / magnification }, { translateX: -lensX.get() }, { translateY: -lensY.get() }, ]); return ( <Canvas pointerEvents="none" style={StyleSheet.absoluteFill}> <Image fit="cover" height={height} image={image} width={width} x={0} y={0} > <Blur blur={blur} /> <ColorMatrix matrix={colorMatrix} /> </Image> <Fill color={overlayColor} /> <Circle color="rgba(15,15,20,0.42)" cx={lensX} cy={lensY} r={radius} > <BlurMask blur={12} style="solid" /> </Circle> <Circle cx={lensX} cy={lensY} r={radius}> <ImageShader fit="cover" image={image} rect={imageRect} transform={imageTransform} tx="clamp" ty="clamp" /> </Circle> <Circle color={refractionColors[0]} cx={cyanX} cy={lensY} opacity={0.48} r={radius} strokeWidth={borderWidth + 2} style="stroke" /> <Circle color={refractionColors[1]} cx={magentaX} cy={lensY} opacity={0.42} r={radius} strokeWidth={borderWidth + 2} style="stroke" /> <Circle color={borderColor} cx={lensX} cy={lensY} r={radius} strokeWidth={borderWidth} style="stroke" /> <Circle cx={lensX} cy={lensY} opacity={0.24} r={radius}> <RadialGradient c={gradientCenter} colors={[ "rgba(255,255,255,0)", "rgba(255,255,255,0.28)", ]} r={radius} /> </Circle> {reticle && ( <> <Line color="rgba(255,255,255,0.32)" p1={horizontalStart} p2={horizontalEnd} strokeWidth={0.8} /> <Line color="rgba(255,255,255,0.32)" p1={verticalStart} p2={verticalEnd} strokeWidth={0.8} /> <Circle color="rgba(255,255,255,0.78)" cx={lensX} cy={lensY} r={2.5} /> </> )} </Canvas> );}frosted-refocus/types.tsimport type { ReactNode } from "react";export type FrostedRefocusBaseProps = { accessibilityLabel?: string; autoPlay?: boolean; blur?: number; borderColor?: string; borderWidth?: number; children: ReactNode; desaturation?: number; disabled?: boolean; lensRadius?: number; magnification?: number; overlayColor?: string; refractionColors?: readonly [string, string]; refreshKey?: number | string; reticle?: boolean;};frosted-refocus/uniwind/index.tsximport { View } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import type { SlotsToClasses } from "#shared/types/slots";import { FROSTED_REFOCUS_CONFIG } from "../config";import { FrostedLayer } from "../layer";import type { FrostedRefocusBaseProps } from "../types";import { useFrostedRefocus } from "../use-frosted-refocus";import { frostedRefocusVariants, type FrostedRefocusSlot,} from "./variants";export type FrostedRefocusProps = FrostedRefocusBaseProps & { className?: string; classNames?: SlotsToClasses<FrostedRefocusSlot>;};export function FrostedRefocus({ accessibilityLabel = "Drag to refocus the image", autoPlay = false, blur = FROSTED_REFOCUS_CONFIG.blur, borderColor = "rgba(255,255,255,0.86)", borderWidth = FROSTED_REFOCUS_CONFIG.borderWidth, children, className, classNames, desaturation = 0.42, disabled = false, lensRadius = FROSTED_REFOCUS_CONFIG.lensRadius, magnification = 1.09, overlayColor = "rgba(235,235,229,0.22)", refractionColors = ["#3ee8ff", "#ff5fd1"], refreshKey, reticle = true,}: FrostedRefocusProps) { const { contentRef, gesture, image, layout, lensScale, lensX, lensY, onLayout, } = useFrostedRefocus({ autoPlay, disabled, lensRadius, refreshKey, }); const { content, root } = frostedRefocusVariants({ disabled }); return ( <GestureDetector gesture={gesture}> <View accessibilityLabel={accessibilityLabel} accessibilityRole="adjustable" className={root({ className: [className, classNames?.root], })} onLayout={onLayout} > <View className={content({ className: classNames?.content, })} collapsable={false} ref={contentRef} > {children} </View> {image && ( <FrostedLayer blur={blur} borderColor={borderColor} borderWidth={borderWidth} desaturation={desaturation} height={layout.height} image={image} lensRadius={lensRadius} lensScale={lensScale} lensX={lensX} lensY={lensY} magnification={magnification} overlayColor={overlayColor} refractionColors={refractionColors} reticle={reticle} width={layout.width} /> )} </View> </GestureDetector> );}frosted-refocus/uniwind/variants.tsimport { tv } from "tailwind-variants";export const frostedRefocusSlots = { content: "h-full w-full", root: "relative h-full w-full overflow-hidden",} as const;export type FrostedRefocusSlot = keyof typeof frostedRefocusSlots;export const frostedRefocusVariants = tv({ slots: frostedRefocusSlots, variants: { disabled: { true: { root: "opacity-[0.45]", }, }, },});frosted-refocus/use-frosted-refocus.tsimport { useCallback, useEffect, useMemo, useRef, useState } from "react";import type { LayoutChangeEvent, View } from "react-native";import { Gesture } from "react-native-gesture-handler";import { makeImageFromView, type SkImage,} from "@shopify/react-native-skia";import { cancelAnimation, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming,} from "react-native-reanimated";import { FROSTED_REFOCUS_CONFIG } from "./config";type UseFrostedRefocusProps = { autoPlay: boolean; disabled: boolean; lensRadius: number; refreshKey?: number | string;};export function useFrostedRefocus({ autoPlay, disabled, lensRadius, refreshKey,}: UseFrostedRefocusProps) { const contentRef = useRef<View>(null); const [image, setImage] = useState<SkImage | null>(null); const [layout, setLayout] = useState({ height: 0, width: 0 }); const lensX = useSharedValue(0); const lensY = useSharedValue(0); const lensScale = useSharedValue(1); const reducedMotion = useReducedMotion(); const startAutoPlay = useCallback(() => { if ( !autoPlay || disabled || reducedMotion || layout.width === 0 || layout.height === 0 ) { return; } const minX = lensRadius; const maxX = Math.max(minX, layout.width - lensRadius); const minY = lensRadius; const maxY = Math.max(minY, layout.height - lensRadius); lensX.set( withRepeat( withSequence( withTiming(maxX, { duration: FROSTED_REFOCUS_CONFIG.previewDuration / 2, }), withTiming(minX, { duration: FROSTED_REFOCUS_CONFIG.previewDuration / 2, }), ), -1, true, ), ); lensY.set( withRepeat( withSequence( withTiming(maxY * 0.72, { duration: FROSTED_REFOCUS_CONFIG.previewDuration * 0.42, }), withTiming(minY * 1.18, { duration: FROSTED_REFOCUS_CONFIG.previewDuration * 0.58, }), ), -1, true, ), ); }, [ autoPlay, disabled, layout.height, layout.width, lensRadius, lensX, lensY, reducedMotion, ]); const capture = useCallback(async () => { if (!contentRef.current || layout.width === 0) { return; } const snapshot = await makeImageFromView(contentRef); setImage(snapshot); }, [layout.width]); useEffect(() => { capture(); }, [capture, refreshKey]); useEffect(() => { startAutoPlay(); return () => { cancelAnimation(lensX); cancelAnimation(lensY); }; }, [lensX, lensY, startAutoPlay]); const onLayout = useCallback( (event: LayoutChangeEvent) => { const nextLayout = event.nativeEvent.layout; setLayout({ height: nextLayout.height, width: nextLayout.width, }); lensX.set(nextLayout.width / 2); lensY.set(nextLayout.height / 2); }, [lensX, lensY], ); const gesture = useMemo( () => Gesture.Pan() .enabled(!disabled) .onBegin((event) => { cancelAnimation(lensX); cancelAnimation(lensY); lensX.set( Math.max( lensRadius, Math.min(layout.width - lensRadius, event.x), ), ); lensY.set( Math.max( lensRadius, Math.min(layout.height - lensRadius, event.y), ), ); lensScale.set( withTiming(1.025, { duration: 90 }), ); }) .onUpdate((event) => { lensX.set( Math.max( lensRadius, Math.min(layout.width - lensRadius, event.x), ), ); lensY.set( Math.max( lensRadius, Math.min(layout.height - lensRadius, event.y), ), ); }) .onFinalize(() => { lensScale.set( withTiming(1, { duration: 120 }), ); }), [ disabled, layout.height, layout.width, lensRadius, lensX, lensY, lensScale, ], ); return { contentRef, gesture, image, layout, lensX, lensY, lensScale, onLayout, };}