Veltrix

skia shaders

Frosted Refocus

A draggable glass lens restores sharp color over softened native content.

AppleiOSAndroidAndroidExpoExpo Go
skiagesture handlerreanimated
GitHubOpen in GitHub

Installation

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

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

Behavior

  • The parent defines width and height.
  • Content is captured once instead of rendering two native trees.
  • Dragging takes ownership from autoPlay.
  • Changing refreshKey requests a new snapshot after content updates.
  • Lens position remains constrained to the captured bounds.

Install tailwind variants

pnpm add tailwind-variants

Usage

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

Component files

6 files

frosted-refocus/config.ts
export const FROSTED_REFOCUS_CONFIG = {  blur: 7,  borderWidth: 2,  lensRadius: 62,  previewDuration: 4200,} as const;
frosted-refocus/layer.tsx
import {  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.ts
import 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

FrostedRefocus