{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lattice-field",
  "title": "Lattice Field",
  "description": "A grid of dots that parts around the pointer and falls apart when pressed, then climbs back into line.",
  "registryDependencies": [
    "utils",
    "https://ui.tinkererslabs.com/r/render-surface.json"
  ],
  "files": [
    {
      "path": "registry/default/lattice-field/lattice-field.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  RenderSurface,\n  useThemeColors,\n  type SurfaceColor,\n} from \"@/registry/default/render-surface/render-surface\"\nimport { cn } from \"@/lib/utils\"\n\nexport type LatticeFieldProps = React.HTMLAttributes<HTMLDivElement> & {\n  /** Pixels between dots. The lattice is rebuilt to suit whatever box it gets. */\n  spacing?: number\n  /** Diameter of a dot in pixels. */\n  dotSize?: number\n  color?: string\n  /** The lagging second colour drawn behind. Same as color turns it off. */\n  echoColor?: string\n  /** How far the pointer reaches, in pixels. Zero turns the reaction off. */\n  pointerRadius?: number\n  /** Furthest a dot is pushed by the pointer, in pixels. */\n  push?: number\n  /** Pixels per second squared, once the lattice has been let go. */\n  gravity?: number\n  /** Pixels per second the dots leave the click at. */\n  scatter?: number\n  /** Whether a press breaks the lattice. */\n  collapseOnClick?: boolean\n  /** Multiplies the idle wave. Zero holds the lattice perfectly still. */\n  sway?: number\n  paused?: boolean\n  surfaceClassName?: string\n}\n\ntype Lattice = {\n  /** Carried so teardown can release the pair, which is handed no context. */\n  gl: WebGLRenderingContext\n  program: WebGLProgram | null\n  buffer: WebGLBuffer | null\n  count: number\n  columns: number\n  rows: number\n  uniform: (name: string) => WebGLUniformLocation | null\n}\n\n/*\n * The lattice never stores a position. Every dot's place is a function of its\n * cell, the clock, and the three numbers the pointer contributes, so a frame\n * costs one draw call and no state at all -- which is also why letting go can\n * be a simple mix back towards the grid rather than a simulation that has to\n * be unwound.\n */\nconst VERTEX = `\nattribute vec2 aCell;\n\nuniform vec2 uGrid;\nuniform vec2 uResolution;\nuniform float uDpr;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uPointerRadius;\nuniform float uPush;\nuniform vec2 uOrigin;\nuniform float uFall;\nuniform float uCollapse;\nuniform float uGravity;\nuniform float uScatter;\nuniform float uTime;\nuniform float uSway;\nuniform float uMotion;\nuniform float uDotSize;\n\nvarying float vFade;\n\nconst float DRAG = 3.0;\n\nfloat hash(vec2 seed) {\n  return fract(sin(dot(seed, vec2(12.9898, 78.233))) * 43758.5453);\n}\n\nvoid main() {\n  vec2 span = uResolution / uGrid;\n  vec2 rest = (aCell + 0.5) * span;\n  float grain = hash(aCell);\n\n  // A slow wave so an untouched lattice still reads as a field. Two\n  // frequencies, because a single one makes visible diagonal bands.\n  vec2 sway = vec2(\n    sin(uTime * 0.7 + rest.y * 0.012 + grain * 6.28),\n    cos(uTime * 0.5 + rest.x * 0.010 + grain * 6.28)\n  ) * uSway * uMotion;\n\n  vec2 toPointer = rest - uPointer;\n  float distance = length(toPointer);\n  float reach = max(uPointerRadius, 0.0001);\n  float falloff = smoothstep(reach, 0.0, distance);\n  vec2 ripple =\n    normalize(toPointer + vec2(0.0001)) *\n    falloff * falloff * uPush * uPointerActive * uMotion;\n\n  // Ballistic, not simulated: an outward kick that fades with distance from\n  // the press, gravity on top, and a floor the dot is not allowed past.\n  vec2 away = normalize(rest - uOrigin + vec2(0.0001));\n  float nearness = smoothstep(reach * 3.0, 0.0, length(rest - uOrigin));\n  vec2 velocity = away * uScatter * mix(0.35, 1.0, nearness);\n\n  // The kick is dragged rather than carried, so its travel converges on\n  // uScatter / DRAG instead of growing for as long as the press is held. A\n  // constant velocity throws the whole lattice off the sides within a second,\n  // which empties the box rather than filling its floor.\n  float travel = (1.0 - exp(-DRAG * uFall)) / DRAG;\n  vec2 thrown = rest + (\n    velocity * travel + vec2(0.0, 0.5 * uGravity * uFall * uFall)\n  ) * uMotion;\n\n  // The floor and the two walls are set a little differently for every dot, so\n  // what gathers against them reads as a heap rather than as one drawn line.\n  float drift = hash(aCell.yx + 3.7);\n  vec2 edge = span * 0.5 + span * vec2(drift, grain) * 2.0;\n  thrown.x = clamp(thrown.x, edge.x, uResolution.x - edge.x);\n  thrown.y = min(thrown.y, uResolution.y - edge.y);\n\n  vec2 position = mix(rest + sway + ripple, thrown, uCollapse);\n\n  vFade = mix(0.55 + falloff * 0.45 * uPointerActive, 1.0, uCollapse * 0.25);\n\n  vec2 clip = position / uResolution * 2.0 - 1.0;\n  gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);\n  gl_PointSize = uDotSize * uDpr;\n}\n`\n\nconst FRAGMENT = `\nprecision mediump float;\n\nuniform vec3 uColor;\nuniform float uAlpha;\n\nvarying float vFade;\n\nvoid main() {\n  float edge = length(gl_PointCoord - 0.5);\n  float mask = smoothstep(0.5, 0.35, edge);\n  if (mask <= 0.0) discard;\n\n  // Premultiplied, which is how the canvas composites, so dots do not carry a\n  // pale fringe over whatever they sit on.\n  float alpha = mask * vFade * uAlpha;\n  gl_FragColor = vec4(uColor * alpha, alpha);\n}\n`\n\nconst MAX_DOTS = 24000\nconst RETURN_SECONDS = 1.4\n\nfunction compile(gl: WebGLRenderingContext, type: number, source: string) {\n  const shader = gl.createShader(type)\n  if (!shader) return null\n\n  gl.shaderSource(shader, source)\n  gl.compileShader(shader)\n\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader)\n    return null\n  }\n\n  return shader\n}\n\nfunction createProgram(gl: WebGLRenderingContext) {\n  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)\n  const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)\n  const program = gl.createProgram()\n\n  if (!vertex || !fragment || !program) return null\n\n  gl.attachShader(program, vertex)\n  gl.attachShader(program, fragment)\n  gl.linkProgram(program)\n  gl.deleteShader(vertex)\n  gl.deleteShader(fragment)\n\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    gl.deleteProgram(program)\n    return null\n  }\n\n  return program\n}\n\n/** Widens the spacing until the lattice fits the budget, however big the box. */\nfunction fit(width: number, height: number, spacing: number) {\n  const step = Math.max(spacing, 2)\n  const area = Math.max(width, 1) * Math.max(height, 1)\n  const wanted = area / (step * step)\n  const cell = wanted > MAX_DOTS ? Math.sqrt(area / MAX_DOTS) : step\n\n  return {\n    columns: Math.max(Math.floor(width / cell), 1),\n    rows: Math.max(Math.floor(height / cell), 1),\n  }\n}\n\nfunction easeOut(value: number) {\n  const clamped = Math.min(Math.max(value, 0), 1)\n  return 1 - Math.pow(1 - clamped, 3)\n}\n\n/**\n * A lattice of dots that holds its grid, parts around the pointer, and breaks\n * apart when pressed before settling back into place. Decoration: it sits\n * behind its children on its own layer and takes no pointer events itself.\n */\nexport function LatticeField({\n  spacing = 16,\n  dotSize = 2,\n  color = \"--foreground\",\n  echoColor = \"--primary\",\n  pointerRadius = 140,\n  push = 26,\n  gravity = 1400,\n  scatter = 320,\n  collapseOnClick = true,\n  sway = 1,\n  paused,\n  className,\n  surfaceClassName,\n  children,\n  ...rootProps\n}: LatticeFieldProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const pointer = React.useRef({ x: 0, y: 0, active: 0 })\n  const press = React.useRef({\n    held: false,\n    x: 0,\n    y: 0,\n    startedAt: -1,\n    releasedAt: -1,\n    frozen: 0,\n  })\n\n  const tokens = React.useMemo(\n    () => [color, echoColor].filter((entry) => entry.startsWith(\"--\")),\n    [color, echoColor]\n  )\n\n  const resolved = useThemeColors(rootRef, tokens)\n  const ink = color.startsWith(\"--\") ? resolved[color] : undefined\n  const echo = echoColor.startsWith(\"--\") ? resolved[echoColor] : undefined\n  const ready = ink !== undefined && echo !== undefined\n\n  const colors = React.useRef<{ ink: SurfaceColor; echo: SurfaceColor }>({\n    ink: [0, 0, 0],\n    echo: [0, 0, 0],\n  })\n\n  React.useEffect(() => {\n    if (ink && echo) colors.current = { ink, echo }\n  }, [ink, echo])\n\n  React.useEffect(() => {\n    if (pointerRadius <= 0 && !collapseOnClick) return\n\n    const inside = (event: PointerEvent) => {\n      const box = rootRef.current?.getBoundingClientRect()\n      if (!box) return null\n\n      const x = event.clientX - box.left\n      const y = event.clientY - box.top\n      const within = x >= 0 && x <= box.width && y >= 0 && y <= box.height\n\n      return { x, y, within }\n    }\n\n    // Followed on the window rather than on this element, so the lattice still\n    // answers while sitting behind something that takes every event itself.\n    const move = (event: PointerEvent) => {\n      const at = inside(event)\n      if (!at) return\n\n      pointer.current.x = at.x\n      pointer.current.y = at.y\n      pointer.current.active = at.within ? 1 : 0\n\n      if (!at.within) press.current.held = false\n    }\n\n    const down = (event: PointerEvent) => {\n      if (!collapseOnClick) return\n\n      const at = inside(event)\n      if (!at?.within) return\n\n      press.current.x = at.x\n      press.current.y = at.y\n      press.current.held = !press.current.held\n    }\n\n    const leave = () => {\n      pointer.current.active = 0\n      press.current.held = false\n    }\n\n    window.addEventListener(\"pointermove\", move, { passive: true })\n    window.addEventListener(\"pointerdown\", down, { passive: true })\n    window.addEventListener(\"pointercancel\", leave, { passive: true })\n\n    return () => {\n      window.removeEventListener(\"pointermove\", move)\n      window.removeEventListener(\"pointerdown\", down)\n      window.removeEventListener(\"pointercancel\", leave)\n    }\n  }, [collapseOnClick, pointerRadius])\n\n  const setup = React.useCallback(\n    ({\n      context,\n      size,\n    }: {\n      context: WebGLRenderingContext\n      size: { width: number; height: number }\n    }) => {\n      const program = createProgram(context)\n      const { columns, rows } = fit(size.width, size.height, spacing)\n\n      const cells = new Float32Array(columns * rows * 2)\n      for (let row = 0; row < rows; row += 1) {\n        for (let column = 0; column < columns; column += 1) {\n          const index = (row * columns + column) * 2\n          cells[index] = column\n          cells[index + 1] = row\n        }\n      }\n\n      const buffer = context.createBuffer()\n      context.bindBuffer(context.ARRAY_BUFFER, buffer)\n      context.bufferData(context.ARRAY_BUFFER, cells, context.STATIC_DRAW)\n\n      const cache = new Map<string, WebGLUniformLocation | null>()\n\n      return {\n        gl: context,\n        program,\n        buffer,\n        count: columns * rows,\n        columns,\n        rows,\n        uniform: (name: string) => {\n          if (!program) return null\n          if (!cache.has(name))\n            cache.set(name, context.getUniformLocation(program, name))\n          return cache.get(name) ?? null\n        },\n      } satisfies Lattice\n    },\n    [spacing]\n  )\n\n  const draw = React.useCallback(\n    ({\n      context,\n      size,\n      state,\n      time,\n    }: {\n      context: WebGLRenderingContext\n      size: { width: number; height: number; dpr: number }\n      state: Lattice\n      time: number\n    }) => {\n      const { program, buffer, count } = state\n      if (!program || !buffer) return\n\n      const { width, height, dpr } = size\n\n      // The press is stamped by the loop rather than by the handler, so both\n      // ends of the fall are measured on the clock that actually draws it.\n      const held = press.current.held\n      if (held && press.current.startedAt < 0) {\n        press.current.startedAt = time\n        press.current.releasedAt = -1\n      } else if (!held && press.current.startedAt >= 0) {\n        press.current.frozen = time - press.current.startedAt\n        press.current.releasedAt = time\n        press.current.startedAt = -1\n      }\n\n      let collapse = 0\n      let fall = 0\n\n      if (press.current.startedAt >= 0) {\n        collapse = 1\n        fall = time - press.current.startedAt\n      } else if (press.current.releasedAt >= 0) {\n        const since = (time - press.current.releasedAt) / RETURN_SECONDS\n        collapse = 1 - easeOut(since)\n        fall = press.current.frozen\n        if (collapse <= 0) press.current.releasedAt = -1\n      }\n\n      context.viewport(0, 0, Math.round(width * dpr), Math.round(height * dpr))\n      context.clearColor(0, 0, 0, 0)\n      context.clear(context.COLOR_BUFFER_BIT)\n      context.enable(context.BLEND)\n      context.blendFunc(context.ONE, context.ONE_MINUS_SRC_ALPHA)\n\n      context.useProgram(program)\n      context.bindBuffer(context.ARRAY_BUFFER, buffer)\n\n      const cell = context.getAttribLocation(program, \"aCell\")\n      context.enableVertexAttribArray(cell)\n      context.vertexAttribPointer(cell, 2, context.FLOAT, false, 0, 0)\n\n      context.uniform2f(state.uniform(\"uGrid\"), state.columns, state.rows)\n      context.uniform2f(state.uniform(\"uResolution\"), width, height)\n      context.uniform1f(state.uniform(\"uDpr\"), dpr)\n      context.uniform2f(\n        state.uniform(\"uPointer\"),\n        pointer.current.x,\n        pointer.current.y\n      )\n      context.uniform1f(\n        state.uniform(\"uPointerActive\"),\n        pointerRadius > 0 ? pointer.current.active : 0\n      )\n      context.uniform1f(state.uniform(\"uPointerRadius\"), pointerRadius)\n      context.uniform1f(state.uniform(\"uPush\"), push)\n      context.uniform2f(\n        state.uniform(\"uOrigin\"),\n        press.current.x,\n        press.current.y\n      )\n      context.uniform1f(state.uniform(\"uFall\"), fall)\n      context.uniform1f(state.uniform(\"uCollapse\"), collapse)\n      context.uniform1f(state.uniform(\"uGravity\"), gravity)\n      context.uniform1f(state.uniform(\"uScatter\"), scatter)\n      context.uniform1f(state.uniform(\"uTime\"), time)\n      context.uniform1f(state.uniform(\"uSway\"), sway)\n      context.uniform1f(state.uniform(\"uDotSize\"), dotSize)\n\n      // The same lattice twice: once under-driven in the second colour, which\n      // reads as the grid lagging behind itself, then once in full.\n      const trailing = colors.current.echo\n      context.uniform1f(state.uniform(\"uMotion\"), 0.55)\n      context.uniform1f(state.uniform(\"uAlpha\"), 0.55)\n      context.uniform3f(\n        state.uniform(\"uColor\"),\n        trailing[0],\n        trailing[1],\n        trailing[2]\n      )\n      context.drawArrays(context.POINTS, 0, count)\n\n      const front = colors.current.ink\n      context.uniform1f(state.uniform(\"uMotion\"), 1)\n      context.uniform1f(state.uniform(\"uAlpha\"), 1)\n      context.uniform3f(state.uniform(\"uColor\"), front[0], front[1], front[2])\n      context.drawArrays(context.POINTS, 0, count)\n    },\n    [dotSize, gravity, pointerRadius, push, scatter, sway]\n  )\n\n  // A resize rebuilds the lattice, so this runs often rather than once.\n  const teardown = React.useCallback((state: Lattice) => {\n    if (state.program) state.gl.deleteProgram(state.program)\n    if (state.buffer) state.gl.deleteBuffer(state.buffer)\n    state.program = null\n    state.buffer = null\n  }, [])\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"lattice-field\"\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      {...rootProps}\n    >\n      {ready && (\n        <RenderSurface<Lattice, \"webgl\">\n          contextType=\"webgl\"\n          setup={setup}\n          draw={draw}\n          teardown={teardown}\n          paused={paused}\n          className={cn(\n            \"pointer-events-none absolute inset-0 -z-10\",\n            surfaceClassName\n          )}\n        />\n      )}\n      {children}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "scene",
    "background",
    "particles",
    "pointer",
    "webgl"
  ],
  "type": "registry:ui"
}