# Portrait Buried Under Sand in a Tray Where the Pointer Drags a Finger Trail That Sifts Shut

> Cursor effect where a portrait lies under sand in a tray: the pointer ploughs a trail down to the print and the sand sifts shut. Free React and HTML code.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut).

- Page, with a live preview: https://shadcn.exchange/r/weinc/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut
- Screenshot: https://shadcn.exchange/shot/weinc/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut/light.png
- Registry item: https://we.inc/r/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useReducedMotion } from "framer-motion";

// The print lying on the glass floor of the tray.
const print = {
  src: "https://images.pexels.com/photos/9955200/pexels-photo-9955200.jpeg?auto=compress&cs=tinysrgb&w=900",
  alt: "A man in a white beret and white jacket against a blue wall, holding a pink gerbera daisy in front of one eye",
};

/** Sand grid across the tray. The picture is drawn far finer than this: the grid only holds how deep the sand lies. */
const GW = 128;
const GH = 160;
/** Width of the fingertip, as a share of the tray width. */
const FINGER = 0.07;
/** The steepest wall the sand will hold, as depth per grid cell. Anything steeper collapses at once. */
const REPOSE = 0.3;
/** How quickly the walls of a furrow creep inward, and how quickly grains sift back over bare glass (per second). */
const CREEP = 0.55;
const LEVEL = 0.011;

const WALNUT =
  "repeating-linear-gradient(91deg, rgba(255,220,180,0.05) 0 1px, rgba(0,0,0,0.07) 1px 3px, rgba(255,220,180,0.02) 3px 7px, rgba(0,0,0,0.04) 7px 12px), linear-gradient(135deg, #7a5333 0%, #4a311d 55%, #36220f 100%)";

const VERT = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;

const FRAG = `precision highp float;
uniform vec2 u_res;
uniform vec2 u_grid;
uniform sampler2D u_sand;
uniform sampler2D u_photo;
uniform float u_img;

float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vnoise(vec2 p){
  vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
vec3 bed(vec2 uv){
  vec2 o = 0.5 / u_grid;
  return (texture2D(u_sand, uv + o).rgb + texture2D(u_sand, uv - o).rgb + texture2D(u_sand, uv + vec2(o.x, -o.y)).rgb + texture2D(u_sand, uv + vec2(-o.x, o.y)).rgb) * 0.25;
}
vec2 cover(vec2 uv){
  float A = u_res.x / u_res.y;
  vec2 t = uv;
  if (u_img > A) t.x = (uv.x - 0.5) * A / u_img + 0.5;
  else t.y = (uv.y - 0.5) * u_img / A + 0.5;
  return clamp(t, 0.0, 1.0);
}

void main(){
  vec2 px = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
  vec2 uv = px / u_res;
  vec3 s = bed(uv);
  float h = s.r * 2.5;
  vec2 slope = (s.gb - 0.5) / 1.2;

  // Grains about one screen pixel across, whatever the density of the display.
  vec2 gp = px / max(1.0, floor(u_res.x / 700.0 + 0.5));
  float n1 = hash(floor(gp));
  float n1b = hash(floor(gp) + 19.7);
  float n2 = hash(floor(gp / 2.0) + 3.1);
  float mottle = vnoise(gp / 16.0);

  vec2 lamp = normalize(vec2(-0.6, -0.8));
  vec3 L = normalize(vec3(lamp * 0.92, 0.5));

  // Sand does not end in a line. Where the bed thins it breaks into single grains, and a few are always left behind on the glass.
  float cov = smoothstep(0.12, 0.48, h + (n2 - 0.5) * 0.36 + (n1 - 0.5) * 0.18);
  float stray = step(0.9925, hash(floor(gp / 1.5) + 71.3));
  cov = max(cov, stray * 0.92);

  vec3 n = normalize(vec3(-slope * 3.4 + (vec2(n1, n1b) - 0.5) * 0.6, 1.0));
  float dif = max(dot(n, L), 0.0);
  float toward = bed(uv + lamp * 0.011).r * 2.5;
  float occluded = clamp((toward - h) * 1.3 - 0.05, 0.0, 1.0);

  vec3 grain = mix(vec3(0.80, 0.71, 0.58), vec3(0.60, 0.50, 0.39), n1 * n1);
  grain = mix(grain, vec3(0.93, 0.88, 0.79), step(0.88, n1b) * 0.6);
  grain *= 0.92 + 0.16 * mottle;
  vec3 sand = grain * (0.30 + 1.0 * dif) * (1.0 - 0.5 * occluded);
  // Flecks of quartz that flash where a slope faces the lamp.
  sand += vec3(1.0, 0.97, 0.9) * step(0.985, n2) * step(0.5, n1b) * pow(dif, 3.0) * 0.3;

  vec3 photo = texture2D(u_photo, cover(uv)).rgb;
  // The wall of the furrow nearest the lamp throws its shadow onto the print.
  photo *= 1.0 - 0.5 * smoothstep(0.15, 0.9, toward) * (1.0 - cov);
  // A thin film of dust and the faint reflection of the lamp in the glass.
  photo = photo * 0.93 + 0.035 + 0.05 * smoothstep(0.55, 0.0, abs(uv.x * 0.8 + uv.y - 0.62));

  gl_FragColor = vec4(mix(photo, sand, cov), 1.0);
}`;

export default function PortraitBuriedUnderSandInATrayWhereThePointerDragsAFingerTrailThatSiftsShut() {
  const reduce = useReducedMotion();
  const well = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const photo = useRef<HTMLImageElement>(null);
  /** The fingertip in tray fractions, where it was a frame ago, and when a person last moved it. */
  const finger = useRef({ x: 0.5, y: 0.5, px: 0.5, py: 0.5, at: -1e6, moved: false, fresh: true });
  /** 1 while the sand is brushed off the whole print, and how long the bed is still rushing to its new level. */
  const bare = useRef({ on: 0, rush: 0 });
  const [live, setLive] = useState(false);
  const [cleared, setCleared] = useState(false);

  useEffect(() => {
    const el = well.current;
    const cv = canvas.current;
    const img = photo.current;
    if (!el || !cv || !img) return;
    const gl = cv.getContext("webgl", { antialias: false, alpha: false });
    if (!gl) return;

    const compile = (type: number, source: string) => {
      const shader = gl.createShader(type)!;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return shader;
    };
    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
    gl.useProgram(program);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const at = gl.getAttribLocation(program, "a");
    gl.enableVertexAttribArray(at);
    gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0);
    const u = (name: string) => gl.getUniformLocation(program, name);
    gl.uniform1i(u("u_photo"), 0);
    gl.uniform1i(u("u_sand"), 1);
    gl.uniform2f(u("u_grid"), GW, GH);
    const texture = (unit: number) => {
      gl.activeTexture(gl.TEXTURE0 + unit);
      gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    };

    // Depth of sand in every cell. 1 is the level bed, 0 is bare glass, above 1 is a ridge pushed up beside a furrow.
    let depth = new Float32Array(GW * GH).fill(1);
    let spare = new Float32Array(GW * GH);
    const packed = new Uint8Array(GW * GH * 4);
    const reach = GW * FINGER;

    /** Press the fingertip down at one spot: scoop out what is under it and bank it up around the edge. */
    const press = (cx: number, cy: number) => {
      const out = reach * 1.8;
      const x0 = Math.max(0, Math.floor(cx - out));
      const x1 = Math.min(GW - 1, Math.ceil(cx + out));
      const y0 = Math.max(0, Math.floor(cy - out));
      const y1 = Math.min(GH - 1, Math.ceil(cy + out));
      let moved = 0;
      let room = 0;
      for (let y = y0; y <= y1; y++) {
        for (let x = x0; x <= x1; x++) {
          const i = y * GW + x;
          const d = Math.hypot(x - cx, y - cy);
          if (d < reach) {
            const a = Math.max(0, (d / reach - 0.45) / 0.55);
            const left = Math.min(depth[i], a * a * (3 - 2 * a) * 1.15);
            moved += depth[i] - left;
            depth[i] = left;
          } else if (d < out) {
            // Only bank sand onto sand: none of it falls back into the furrow behind the finger.
            room += Math.sin((Math.PI * (d - reach)) / (out - reach)) * Math.min(1, Math.max(0, (depth[i] - 0.35) / 0.5));
          }
        }
      }
      if (moved <= 0 || room <= 0) return;
      const share = (moved * 0.7) / room;
      for (let y = y0; y <= y1; y++) {
        for (let x = x0; x <= x1; x++) {
          const i = y * GW + x;
          const d = Math.hypot(x - cx, y - cy);
          if (d >= reach && d < out) {
            const w = Math.sin((Math.PI * (d - reach)) / (out - reach)) * Math.min(1, Math.max(0, (depth[i] - 0.35) / 0.5));
            depth[i] = Math.min(2.3, depth[i] + w * share);
          }
        }
      }
    };
    const drag = (ax: number, ay: number, bx: number, by: number) => {
      const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / 1.2));
      for (let s = 1; s <= steps; s++) press(ax + ((bx - ax) * s) / steps, ay + ((by - ay) * s) / steps);
    };

    /** Let the bed settle for one tick: walls steeper than sand can hold collapse, the rest creeps, and grains sift back. */
    const settle = (dt: number, rate: number, level: number) => {
      const creep = Math.min(0.2, CREEP * dt);
      const back = Math.min(1, rate * dt);
      const flow = (d: number) => (d > REPOSE ? (d - REPOSE) * 0.22 : d < -REPOSE ? (d + REPOSE) * 0.22 : 0) + d * creep * 0.25;
      for (let y = 0; y < GH; y++) {
        const up = y > 0 ? -GW : 0;
        const down = y < GH - 1 ? GW : 0;
        for (let x = 0; x < GW; x++) {
          const i = y * GW + x;
          const here = depth[i];
          const v = here + flow(depth[i + up] - here) + flow(depth[i + down] - here) + flow(depth[x > 0 ? i - 1 : i] - here) + flow(depth[x < GW - 1 ? i + 1 : i] - here);
          spare[i] = v + (level - v) * back;
        }
      }
      const swap = depth;
      depth = spare;
      spare = swap;
    };

    // With nobody touching it, a slow hand draws one long wandering line.
    const wander = (t: number): [number, number] => [
      GW * (0.5 + 0.3 * Math.sin(t * 0.5 + 0.2) + 0.07 * Math.sin(t * 1.31 + 1.0)),
      GH * (0.47 + 0.33 * Math.sin(t * 0.37 + 1.9) + 0.05 * Math.cos(t * 1.13)),
    ];

    const draw = () => {
      for (let y = 0; y < GH; y++) {
        for (let x = 0; x < GW; x++) {
          const i = y * GW + x;
          const sx = (depth[x < GW - 1 ? i + 1 : i] - depth[x > 0 ? i - 1 : i]) * 0.5;
          const sy = (depth[y < GH - 1 ? i + GW : i] - depth[y > 0 ? i - GW : i]) * 0.5;
          packed[i * 4] = Math.max(0, Math.min(255, depth[i] * 102));
          packed[i * 4 + 1] = Math.max(0, Math.min(255, 127.5 + sx * 153));
          packed[i * 4 + 2] = Math.max(0, Math.min(255, 127.5 + sy * 153));
          packed[i * 4 + 3] = 255;
        }
      }
      gl.activeTexture(gl.TEXTURE1);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, GW, GH, 0, gl.RGBA, gl.UNSIGNED_BYTE, packed);
      gl.uniform2f(u("u_res"), cv.width, cv.height);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    };

    // It opens with twenty-two seconds of that line already drawn, the oldest end half sifted shut.
    const LEAD = 22;
    let lead = wander(-LEAD);
    for (let t = -LEAD; t < 0; t += 1 / 30) {
      const next = wander(t);
      drag(lead[0], lead[1], next[0], next[1]);
      lead = next;
      settle(1 / 30, LEVEL, 1);
    }

    let ready = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    const started = performance.now();

    const loop = (stamp: number) => {
      if (ready) {
        const t = (stamp - started) / 1000;
        const dt = Math.min(last ? (stamp - last) / 1000 : 0.016, 0.033);
        last = stamp;
        const f = finger.current;
        const b = bare.current;
        if (f.moved) {
          const x = f.x * GW;
          const y = f.y * GH;
          if (f.fresh) press(x, y);
          else drag(f.px * GW, f.py * GH, x, y);
          f.fresh = false;
          f.px = f.x;
          f.py = f.y;
          f.moved = false;
        }
        if (reduce) {
          // No drifting sand: a stroke stays as it was drawn, and the button changes the whole tray at once.
          if (b.rush > 0) {
            depth.fill(b.on ? 0 : 1);
            b.rush = 0;
          }
        } else {
          if (stamp - f.at > 2600 && !b.on) {
            const next = wander(t);
            drag(lead[0], lead[1], next[0], next[1]);
            lead = next;
          } else lead = wander(t);
          b.rush = Math.max(0, b.rush - dt);
          settle(dt, b.rush > 0 ? 2.4 : b.on ? 0.6 : LEVEL, b.on ? 0 : 1);
        }
        draw();
      }
      raf = visible && !document.hidden && !reduce ? requestAnimationFrame(loop) : 0;
    };
    const wake = () => {
      if (!raf && visible && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const resize = () => {
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      cv.width = Math.max(1, Math.round(el.clientWidth * ratio));
      cv.height = Math.max(1, Math.round(el.clientHeight * ratio));
      gl.viewport(0, 0, cv.width, cv.height);
      if (reduce && ready) draw();
    };
    const upload = () => {
      if (ready || !img.complete || !img.naturalWidth) return;
      try {
        texture(0);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
        texture(1);
        gl.uniform1f(u("u_img"), img.naturalWidth / img.naturalHeight);
        ready = true;
        setLive(true);
        wake();
      } catch {
        // The picture could not be read into the shader: the still version underneath stays on show.
        ready = false;
      }
    };

    const ro = new ResizeObserver(resize);
    ro.observe(el);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      wake();
    });
    io.observe(el);
    document.addEventListener("visibilitychange", wake);
    img.addEventListener("load", upload);
    // With motion reduced the loop is not running, so each stroke or button press asks for one redraw.
    el.addEventListener("sand-touched", wake);
    resize();
    upload();
    wake();

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", wake);
      img.removeEventListener("load", upload);
      el.removeEventListener("sand-touched", wake);
    };
  }, [reduce]);

  function touch(e: ReactPointerEvent<HTMLDivElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    const f = finger.current;
    const now = performance.now();
    const x = (e.clientX - r.left) / r.width;
    const y = (e.clientY - r.top) / r.height;
    // A press, or a move after a rest, sets the finger down afresh so no line is drawn from where it last was.
    if (e.type !== "pointermove" || now - f.at > 400) {
      f.fresh = true;
      f.px = x;
      f.py = y;
    }
    f.x = x;
    f.y = y;
    f.at = now;
    f.moved = true;
    e.currentTarget.dispatchEvent(new Event("sand-touched"));
  }

  function toggle() {
    const next = !cleared;
    setCleared(next);
    bare.current = { on: next ? 1 : 0, rush: 1.6 };
    well.current?.dispatchEvent(new Event("sand-touched"));
  }

  return (
    <section className="relative isolate flex min-h-[800px] w-full flex-col items-center justify-center overflow-hidden bg-[#14110e] px-4 py-14">
      <style>{`
        .pbs-grain { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.35 0 0 0 0 0.27 0 0 0 0 0.18 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); }
        .pbs-furrow { -webkit-mask: radial-gradient(42% 16% at 46% 30%, transparent 62%, #000 100%); mask: radial-gradient(42% 16% at 46% 30%, transparent 62%, #000 100%); }
        @media (prefers-reduced-motion: reduce) { .pbs-fade { transition: none; } }
      `}</style>
      <div aria-hidden="true" className="absolute inset-0 -z-10" style={{ background: "radial-gradient(60% 50% at 44% 38%, #2a241e 0%, #14110e 70%)" }} />

      {/* The tray: a lacquered rim round a shallow well. */}
      <div className="relative w-[min(88vw,470px)] rounded-[22px] bg-[#4a311d] p-[22px] shadow-[inset_0_2px_0_rgba(255,226,190,0.42),inset_2px_0_0_rgba(255,226,190,0.2),inset_0_-4px_6px_rgba(0,0,0,0.6),inset_-3px_0_5px_rgba(0,0,0,0.4),0_0_0_1px_rgba(0,0,0,0.7),0_40px_60px_-20px_rgba(0,0,0,0.9),0_90px_120px_-50px_rgba(0,0,0,0.9)]" style={{ backgroundImage: WALNUT }}>
        <div
          ref={well}
          onPointerMove={touch}
          onPointerDown={touch}
          onPointerEnter={touch}
          className="relative aspect-[4/5] w-full cursor-crosshair touch-pan-y overflow-hidden rounded-[7px] bg-[#b9a585] shadow-[0_1.5px_0_rgba(255,226,190,0.3),1.5px_0_0_rgba(255,226,190,0.14),0_-1px_0_rgba(0,0,0,0.6)]"
        >
          <img ref={photo} src={print.src} alt={print.alt} crossOrigin="anonymous" width={900} height={1350} className="absolute inset-0 h-full w-full object-cover" />
          {/* The still version: sand with one cleared patch. On show until the shader is ready and whenever WebGL is missing. */}
          <div aria-hidden="true" className={`pbs-grain pbs-furrow absolute inset-0 bg-[#c9b796] ${cleared ? "hidden" : ""}`} />
          <canvas ref={canvas} aria-hidden="true" className={`pbs-fade absolute inset-0 h-full w-full transition-opacity duration-500 ${live ? "opacity-100" : "opacity-0"}`} />
          {/* The wall of the well shades the sand along the two edges nearest the lamp. */}
          <div aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[7px] shadow-[inset_8px_11px_14px_rgba(30,18,6,0.55),inset_2px_3px_3px_rgba(30,18,6,0.5),inset_-1px_-1px_0_rgba(255,255,255,0.16),inset_0_0_0_1px_rgba(0,0,0,0.6)]" />
        </div>
      </div>

      <div className="mt-8 flex w-[min(88vw,470px)] items-center justify-between gap-4 px-1 text-[13px]">
        <p className="text-stone-300">Draw in the sand.</p>
        <button
          type="button"
          onClick={toggle}
          aria-pressed={cleared}
          className="min-h-[40px] shrink-0 rounded-full border border-white/15 bg-white/5 px-4 text-[13px] font-medium text-white transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#14110e]"
        >
          {cleared ? "Pour the sand back" : "Brush the sand off"}
        </button>
      </div>
    </section>
  );
}
```
