---
title: Mask
description: WebGL fluid trail that melts a cover to reveal content underneath.
group: Media
order: 20
new: true
---

Move across the surface and a GPU density field carves the cover away. Content underneath shows through, then the veil seals back as the trail fades.

```tsx
import { Mask } from "@/components/motion/mask"

export function Example() {
  return (
    <Mask className="aspect-[16/10] w-full" coverColor="#09090b">
      <img src="/photo.jpg" alt="" className="size-full object-cover" />
    </Mask>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add mask
```

## Usage

```tsx
import { Mask } from "@/components/motion/mask"
```

```tsx
<Mask className="aspect-[16/10] w-full" coverColor="#09090b">
  <img src="/photo.jpg" alt="" className="size-full object-cover" />
</Mask>
```

## API Reference

### Mask

| Prop | Type | Default |
| --- | --- | --- |
| radius | number | 72 |
| softness | number | 0.7 |
| persistence | number | 0.72 |
| viscosity | number | 0.55 |
| coverColor | string | #09090b |
| className | string | — |
| children | ReactNode | — |

Also accepts the other native `div` attributes. Radius is in pixels. Uses `ogl` for the WebGL sim.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
} from "react";
import { Mesh, Program, RenderTarget, Renderer, Triangle } from "ogl";
import { cn } from "@/lib/utils";

export type MaskProps = Omit<ComponentProps<"div">, "children" | "color"> & {
  /** Brush radius in px. */
  radius?: number;
  /** Edge softness of the melt (0–1). */
  softness?: number;
  /** How long the trail lingers (0–1). */
  persistence?: number;
  /** How liquid / merged the trail feels (0–1). */
  viscosity?: number;
  /** Cover color that gets carved away. */
  coverColor?: string;
  children?: ReactNode;
};

type Drop = { x: number; y: number; strength: number };

const SIM_MAX = 384;
const STEPS_PER_FRAME = 2;
const DROP_SPACING = 6;

const VERT = `
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

/* Density in r. Laplacian diffusion + dissipate + optional splat. */
const SIM_FRAG = `
precision highp float;

uniform sampler2D tPrev;
uniform vec2 uTexel;
uniform float uAspect;
uniform vec3 uDrop;
uniform float uRadius;
uniform float uDissipate;
uniform float uDiffuse;

varying vec2 vUv;

void main() {
  float d = texture2D(tPrev, vUv).r;
  float l = texture2D(tPrev, vUv - vec2(uTexel.x, 0.0)).r;
  float r = texture2D(tPrev, vUv + vec2(uTexel.x, 0.0)).r;
  float t = texture2D(tPrev, vUv + vec2(0.0, uTexel.y)).r;
  float b = texture2D(tPrev, vUv - vec2(0.0, uTexel.y)).r;
  float lap = (l + r + t + b) * 0.25 - d;
  d = (d + lap * uDiffuse) * uDissipate;

  if (uDrop.z > 0.0) {
    vec2 diff = vec2((vUv.x - uDrop.x) * uAspect, vUv.y - uDrop.y);
    float falloff = exp(-dot(diff, diff) / max(uRadius * uRadius, 1e-6));
    d += uDrop.z * falloff;
  }

  gl_FragColor = vec4(clamp(d, 0.0, 1.0), 0.0, 0.0, 1.0);
}
`;

/* Cover color with alpha carved by density. Soft edge + light rim. */
const DRAW_FRAG = `
precision highp float;

uniform sampler2D tDensity;
uniform vec3 uCover;
uniform float uSoftness;
uniform float uRim;

varying vec2 vUv;

void main() {
  float d = texture2D(tDensity, vUv).r;
  float soft = clamp(uSoftness, 0.05, 1.0);
  float lo = mix(0.18, 0.02, soft);
  float hi = mix(0.55, 0.42, soft);
  float mask = smoothstep(lo, hi, d);
  float edge = mask * (1.0 - mask) * 4.0;
  float alpha = 1.0 - mask;
  vec3 color = uCover + vec3(uRim) * edge;
  gl_FragColor = vec4(color, alpha);
}
`;

function parseHex(hex: string): [number, number, number] {
  const raw = hex.replace("#", "").trim();
  const full =
    raw.length === 3
      ? raw
          .split("")
          .map((c) => c + c)
          .join("")
      : raw.padEnd(6, "0").slice(0, 6);
  const n = Number.parseInt(full, 16);
  if (Number.isNaN(n)) return [0.035, 0.035, 0.043];
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

function Mask({
  className,
  radius = 72,
  softness = 0.7,
  persistence = 0.72,
  viscosity = 0.55,
  coverColor = "#09090b",
  children,
  style,
  onPointerMove,
  onPointerEnter,
  onPointerLeave,
  onPointerDown,
  ...props
}: MaskProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const hostRef = useRef<HTMLDivElement>(null);
  const paramsRef = useRef({
    radius,
    softness,
    persistence,
    viscosity,
    coverColor,
  });
  const pointerRef = useRef({
    lastX: null as number | null,
    lastY: null as number | null,
  });
  const dropsRef = useRef<Drop[]>([]);
  const apiRef = useRef<{
    queue: (clientX: number, clientY: number, strength: number) => void;
    clearPointer: () => void;
  } | null>(null);

  paramsRef.current = {
    radius,
    softness,
    persistence,
    viscosity,
    coverColor,
  };

  useEffect(() => {
    const rootEl = rootRef.current;
    const host = hostRef.current;
    if (!rootEl || !host) return;
    const root = rootEl;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

    const renderer = new Renderer({
      alpha: true,
      antialias: false,
      dpr: Math.min(window.devicePixelRatio || 1, 1.5),
      powerPreference: "high-performance",
      webgl: 2,
    });
    const gl = renderer.gl;
    gl.clearColor(0, 0, 0, 0);
    const canvas = gl.canvas as HTMLCanvasElement;
    canvas.className =
      "arctis-mask-cover pointer-events-none absolute inset-0 size-full";
    canvas.setAttribute("aria-hidden", "true");
    host.appendChild(canvas);

    const geometry = new Triangle(gl);
    const gl2 =
      typeof WebGL2RenderingContext !== "undefined" &&
      gl instanceof WebGL2RenderingContext
        ? gl
        : null;
    const canFloat =
      !!gl2 && !!gl.getExtension("EXT_color_buffer_float");

    let simW = 2;
    let simH = 2;
    let read = 0;
    let layoutKey = "";
    const targets: RenderTarget[] = [];

    function makeTarget(w: number, h: number) {
      if (canFloat && gl2) {
        return new RenderTarget(gl, {
          width: w,
          height: h,
          depth: false,
          type: gl2.HALF_FLOAT,
          format: gl2.RGBA,
          internalFormat: gl2.RGBA16F,
          minFilter: gl.LINEAR,
          magFilter: gl.LINEAR,
          wrapS: gl.CLAMP_TO_EDGE,
          wrapT: gl.CLAMP_TO_EDGE,
        });
      }
      return new RenderTarget(gl, {
        width: w,
        height: h,
        depth: false,
        type: gl.UNSIGNED_BYTE,
        format: gl.RGBA,
        internalFormat: gl.RGBA,
        minFilter: gl.LINEAR,
        magFilter: gl.LINEAR,
        wrapS: gl.CLAMP_TO_EDGE,
        wrapT: gl.CLAMP_TO_EDGE,
      });
    }

    function clearTargets() {
      for (const target of targets) {
        gl.bindFramebuffer(gl.FRAMEBUFFER, target.buffer);
        gl.viewport(0, 0, simW, simH);
        gl.clearColor(0, 0, 0, 1);
        gl.clear(gl.COLOR_BUFFER_BIT);
      }
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      gl.clearColor(0, 0, 0, 0);
      read = 0;
    }

    function rebuildTargets(cssW: number, cssH: number) {
      const scale = Math.min(1, SIM_MAX / Math.max(cssW, cssH));
      const nextW = Math.max(2, Math.round(cssW * scale));
      const nextH = Math.max(2, Math.round(cssH * scale));
      const key = `${nextW}x${nextH}`;
      if (key === layoutKey && targets.length === 2) return;
      layoutKey = key;
      simW = nextW;
      simH = nextH;
      for (const target of targets) target.setSize(simW, simH);
      while (targets.length < 2) targets.push(makeTarget(simW, simH));
      clearTargets();
    }

    rebuildTargets(2, 2);

    const simProgram = new Program(gl, {
      vertex: VERT,
      fragment: SIM_FRAG,
      uniforms: {
        tPrev: { value: targets[0].texture },
        uTexel: { value: [1 / simW, 1 / simH] },
        uAspect: { value: 1 },
        uDrop: { value: [0, 0, 0] },
        uRadius: { value: 0.04 },
        uDissipate: { value: 0.96 },
        uDiffuse: { value: 0.35 },
      },
    });
    const simMesh = new Mesh(gl, { geometry, program: simProgram });

    const drawProgram = new Program(gl, {
      vertex: VERT,
      fragment: DRAW_FRAG,
      transparent: true,
      depthTest: false,
      uniforms: {
        tDensity: { value: targets[0].texture },
        uCover: { value: parseHex(coverColor) },
        uSoftness: { value: softness },
        uRim: { value: 0.08 },
      },
    });
    const drawMesh = new Mesh(gl, { geometry, program: drawProgram });

    function queueDrop(clientX: number, clientY: number, strength: number) {
      const rect = root.getBoundingClientRect();
      const w = Math.max(rect.width, 1);
      const h = Math.max(rect.height, 1);
      const x = (clientX - rect.left) / w;
      const y = 1 - (clientY - rect.top) / h;
      const drops = dropsRef.current;
      if (drops.length > 64) drops.shift();
      drops.push({ x, y, strength });
    }

    apiRef.current = {
      queue: queueDrop,
      clearPointer: () => {
        pointerRef.current.lastX = null;
        pointerRef.current.lastY = null;
      },
    };

    function resize() {
      const cssW = Math.max(root.clientWidth, 1);
      const cssH = Math.max(root.clientHeight, 1);
      renderer.setSize(cssW, cssH);
      rebuildTargets(cssW, cssH);
      simProgram.uniforms.uTexel.value = [1 / simW, 1 / simH];
      simProgram.uniforms.uAspect.value = simW / simH;
    }

    function simStep(drop: Drop | null) {
      const src = targets[read];
      const dst = targets[1 - read];
      read = 1 - read;

      const p = paramsRef.current;
      const persist = Math.min(Math.max(p.persistence, 0.05), 0.96);
      const visc = Math.min(Math.max(p.viscosity, 0), 1);
      const cssMin = Math.min(
        Math.max(root.clientWidth, 1),
        Math.max(root.clientHeight, 1),
      );
      const radiusUv = Math.max(p.radius, 8) / cssMin;
      const radius = radiusUv * (0.55 + visc * 0.35);

      simProgram.uniforms.tPrev.value = src.texture;
      simProgram.uniforms.uTexel.value = [1 / simW, 1 / simH];
      simProgram.uniforms.uAspect.value = simW / simH;
      simProgram.uniforms.uDissipate.value = 0.9 + persist * 0.09;
      simProgram.uniforms.uDiffuse.value = 0.12 + visc * 0.72;
      simProgram.uniforms.uRadius.value = Math.max(radius, 0.008);
      simProgram.uniforms.uDrop.value = drop
        ? [drop.x, drop.y, drop.strength]
        : [0, 0, 0];

      renderer.render({ scene: simMesh, target: dst });
    }

    let frame = 0;
    let running = true;

    const tick = () => {
      if (!running) return;
      frame = requestAnimationFrame(tick);

      const p = paramsRef.current;
      drawProgram.uniforms.uCover.value = parseHex(p.coverColor);
      drawProgram.uniforms.uSoftness.value = p.softness;
      drawProgram.uniforms.uRim.value =
        0.05 + Math.min(Math.max(p.viscosity, 0), 1) * 0.1;

      if (!reduced.matches) {
        const drops = dropsRef.current;
        const steps = Math.max(STEPS_PER_FRAME, Math.min(drops.length, 8));
        for (let i = 0; i < steps; i++) {
          simStep(drops.shift() || null);
        }
      }

      drawProgram.uniforms.tDensity.value = targets[read].texture;
      renderer.render({ scene: drawMesh });
    };

    const observer = new ResizeObserver(resize);
    observer.observe(root);
    resize();
    frame = requestAnimationFrame(tick);

    return () => {
      running = false;
      cancelAnimationFrame(frame);
      observer.disconnect();
      apiRef.current = null;
      if (canvas.parentNode === host) host.removeChild(canvas);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    };
  }, []);

  function strokeTo(clientX: number, clientY: number, strength: number) {
    const api = apiRef.current;
    const pointer = pointerRef.current;
    if (!api) return;

    if (pointer.lastX === null || pointer.lastY === null) {
      api.queue(clientX, clientY, strength);
      pointer.lastX = clientX;
      pointer.lastY = clientY;
      return;
    }

    const dx = clientX - pointer.lastX;
    const dy = clientY - pointer.lastY;
    const dist = Math.hypot(dx, dy);
    const steps = Math.floor(dist / DROP_SPACING);
    if (steps === 0) return;

    for (let i = 1; i <= steps; i++) {
      const t = i / steps;
      api.queue(pointer.lastX + dx * t, pointer.lastY + dy * t, strength);
    }
    pointer.lastX = clientX;
    pointer.lastY = clientY;
  }

  return (
    <div
      ref={rootRef}
      data-slot="mask"
      className={cn("arctis-mask relative overflow-hidden", className)}
      style={style as CSSProperties}
      {...props}
      onPointerEnter={(event) => {
        onPointerEnter?.(event);
        strokeTo(event.clientX, event.clientY, 0.55);
      }}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        strokeTo(event.clientX, event.clientY, 0.42);
      }}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        pointerRef.current.lastX = null;
        pointerRef.current.lastY = null;
        strokeTo(event.clientX, event.clientY, 0.7);
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        apiRef.current?.clearPointer();
      }}
    >
      <div className="arctis-mask-content absolute inset-0">{children}</div>
      <div ref={hostRef} className="absolute inset-0" aria-hidden="true" />
    </div>
  );
}

export { Mask };
```
