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

A shallow walnut tray filled level with pale sand, and a colour portrait lying on the glass underneath. The pointer acts as a fingertip: wherever it is dragged it scoops the sand aside, banks it into a low ridge along both sides of the furrow, and leaves the print showing through with a few stray grains still on it. The furrow does not last. Loose sand slumps in from the edges, the ridges sink, and over about twenty seconds the line closes from its oldest end. A lamp at the top left rakes across the bed, so ridges are lit on one flank and shaded on the other and the near wall of each furrow throws a shadow onto the picture. With no pointer a slow hand keeps drawing one wandering line. The tray scales to phone width, where a sideways drag draws and the button lifts all the sand at once.

Code

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>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/portrait-buried-under-sand-in-a-tray-where-the-pointer-drags-a-finger-trail-that-sifts-shut.json

When to use it

How to customize it

Accessibility

Questions

How is the sand simulated?

A grid of 128 by 160 numbers holds how deep the sand is in each cell. Pressing the finger down lowers the cells under it and adds most of what was removed to a ring of cells around it. Every frame, any step between two neighbouring cells that is steeper than `REPOSE` collapses, the way a real sand wall does; shallower slopes creep very slowly; and every cell drifts a hair toward the level bed, which is the refilling. That grid is uploaded as a small texture and the shader does the rest.

Why does the edge of a furrow look grainy and not blurred?

Depth alone would give a soft gradient from sand to picture. The shader instead compares the depth with a per-grain random number, so where the bed is thin some grains are present and some are not. The same trick leaves a scatter of single grains on the cleared glass.

Where does the pushed sand go?

Seven tenths of it is banked onto the sand beside the finger and the rest is dropped, which keeps the ridges believable. It is only ever banked onto cells that still have sand, so nothing falls back into the furrow behind the fingertip.

Can visitors keep what they have drawn?

Set `LEVEL` and `CREEP` to `0` and remove the idle hand by deleting the `wander` branch in the loop. The bed will then hold every mark until the button is used; only walls too steep to stand still fall in.

What does it cost per frame?

One pass over about twenty thousand cells on the CPU, one small texture upload, and one shader pass with nine texture reads per pixel. Device pixel ratio is capped at two and the loop pauses off screen.

Related: Photograph in an Enamel Darkroom Tray Under Water That Ripples Where the Pointer Drags, Reversible Sequin Portrait Panel That Flips to Gold Foil Where the Pointer Brushes, Bar Magnet Cursor That Combs Iron Filings on Paper into Field Lines.

More cursor effects or all free UI components. Start for free to have a full site built around it.

More in Cursors

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap