{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A PixelFlowField background component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\n\nexport type PixelFlowFieldShape = \"square\" | \"circle\" | \"cross\";\n\nexport interface PixelFlowFieldProps {\n  /** Cell size in CSS pixels. Smaller cells read the source more sharply. */\n  cellSize?: number;\n  /** Foreground content, always painted above the decorative field. */\n  children?: ReactNode;\n  /** Extra classes for the positioned wrapper element. */\n  className?: string;\n  /**\n   * Three ramp stops, dim to bright: `[field, accent, ink]`. Cells the source\n   * does not cover sit on the first stop; fully covered cells sit on the last.\n   * Accepts any CSS colour, a `var(--token)` expression, or a bare custom\n   * property name such as `--color-brand`.\n   */\n  colors?: string[];\n  /** Gap between cells in CSS pixels. */\n  gap?: number;\n  /** Freezes the field on its current frame. */\n  paused?: boolean;\n  /** Radius in CSS pixels within which the pointer disturbs cells. */\n  pointerRadius?: number;\n  /** How hard the pointer shoves cells, from 0 to 1. */\n  pointerStrength?: number;\n  /**\n   * Increment this to blow the field apart and let it re-form. Any change to\n   * the number re-scatters with a fresh deterministic seed; the value itself\n   * carries no meaning.\n   */\n  scatter?: number;\n  /** Shape drawn for every cell. */\n  shape?: PixelFlowFieldShape;\n  /** Flow speed multiplier. 1 is the calibrated default. */\n  speed?: number;\n  /**\n   * Image URL sampled instead of `text`. Needs to be CORS-readable; if the\n   * pixels cannot be read the component falls back to `text`.\n   */\n  src?: string;\n  /** Word sampled into the grid. Short words read best. */\n  text?: string;\n  /** Font weight used when rasterising `text`. Heavy weights survive better. */\n  weight?: number;\n}\n\ntype Rgba = [number, number, number, number];\n\ninterface FieldSettings {\n  cellSize: number;\n  colors: Rgba[];\n  fontFamily: string;\n  gap: number;\n  pointerRadius: number;\n  pointerStrength: number;\n  scatterSeed: number;\n  shape: PixelFlowFieldShape;\n  source: HTMLImageElement | null;\n  speed: number;\n  still: boolean;\n  text: string;\n  weight: number;\n}\n\ninterface FieldController {\n  destroy: () => void;\n  render: () => void;\n  resize: () => void;\n  setPointer: (x: number, y: number, active: boolean) => void;\n  setRunning: (running: boolean) => void;\n  setSettings: (settings: FieldSettings) => void;\n}\n\nconst MAX_DPR = 2;\nconst RGB_MAX = 255;\nconst MS_PER_SECOND = 1000;\nconst TAU = Math.PI * 2;\n\nconst DEFAULT_CELL_SIZE = 8;\nconst DEFAULT_GAP = 3;\nconst DEFAULT_SPEED = 1;\nconst DEFAULT_TEXT = \"smooth\";\nconst DEFAULT_WEIGHT = 800;\nconst DEFAULT_POINTER_RADIUS = 130;\nconst DEFAULT_POINTER_STRENGTH = 1;\nconst DEFAULT_FONT_FAMILY = \"system-ui, sans-serif\";\n\n// Ramp stops as theme tokens. The hardcoded fallbacks are the same oklch\n// values the tokens resolve to in light mode, converted to sRGB:\n// neutral oklch(0.81 0 0), brand oklch(0.72 0.2 352.53), ink oklch(0.22 0 0).\nconst DEFAULT_COLORS = [\n  \"var(--color-smooth-500, oklch(0.81 0 0))\",\n  \"var(--color-brand, oklch(0.72 0.2 352.53))\",\n  \"var(--color-foreground, oklch(0.22 0 0))\",\n];\nconst FALLBACK_COLORS: Rgba[] = [\n  [193, 193, 193, 1],\n  [239, 92, 152, 1],\n  [26, 26, 26, 1],\n];\n\nconst MIN_STEP = 3;\nconst MIN_COLS = 44;\nconst MAX_CELLS = 14_000;\nconst MIN_DRAW = 0.35;\n\n/** Brightness tiers. Each tier is one `fillStyle` + one `fill()` per frame. */\nconst TIER_COUNT = 7;\nconst RAMP_MID = 0.6;\nconst FIELD_ALPHA = 0.24;\nconst ACCENT_ALPHA = 0.92;\nconst INK_ALPHA = 1;\n\n/** Coverage below/above these lands flat, so letter edges stay crisp. */\nconst MASK_LOW = 0.08;\nconst MASK_HIGH = 0.86;\n\nconst TEXT_HEIGHT_RATIO = 0.68;\nconst TEXT_WIDTH_RATIO = 0.86;\nconst LUMA_R = 0.2126;\nconst LUMA_G = 0.7152;\nconst LUMA_B = 0.0722;\n\n/** One flow-field sample per 4x4 block of cells, bilinear-filtered per cell. */\nconst LATTICE = 4;\nconst NOISE_SCALE = 0.19;\nconst TIME_SCALE = 0.14;\nconst FLOW_TURNS = 1.35;\nconst MAG_FLOOR = 0.35;\nconst MAG_RANGE = 0.65;\nconst NOISE_OFFSET_X = 37.2;\nconst NOISE_OFFSET_Y = 11.5;\nconst NOISE_COUNTER_FLOW = 0.8;\n\nconst DRIFT_RATIO = 0.95;\nconst WORD_CALM = 0.84;\nconst SIZE_FLOOR = 0.3;\nconst SIZE_MASK = 0.64;\nconst SIZE_FLOW = 0.2;\nconst STILL_MAG = 0.55;\nconst CROSS_THICKNESS = 0.34;\n\nconst REFORM_MS = 1750;\nconst STAGGER = 0.45;\nconst WORD_LEAD = 0.16;\nconst DELAY_X = 0.55;\nconst DELAY_Y = 0.3;\nconst DELAY_JITTER = 0.15;\nconst SCATTER_SIZE = 0.5;\nconst SCATTER_SPREAD = 1.25;\n\nconst WAKE_TAU = 0.42;\nconst WAKE_GAIN = 3.4;\nconst WAKE_CEILING = 2.4;\nconst MAX_SUBSTEPS = 4;\nconst MAX_DT = 0.05;\n\nconst HASH_X = 127.1;\nconst HASH_Y = 311.7;\nconst HASH_SEED = 74.7;\nconst HASH_MULTIPLIER = 43_758.545_312_3;\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, value));\n\nconst smoothstep = (value: number) => value * value * (3 - 2 * value);\n\nconst toCssColor = (input: string) =>\n  input.trim().startsWith(\"--\") ? `var(${input.trim()})` : input.trim();\n\nconst hash2 = (x: number, y: number) => {\n  const value = Math.sin(x * HASH_X + y * HASH_Y + HASH_SEED) * HASH_MULTIPLIER;\n  return value - Math.floor(value);\n};\n\nconst valueNoise = (x: number, y: number) => {\n  const ix = Math.floor(x);\n  const iy = Math.floor(y);\n  const fx = x - ix;\n  const fy = y - iy;\n  const ux = smoothstep(fx);\n  const uy = smoothstep(fy);\n  const a = hash2(ix, iy);\n  const b = hash2(ix + 1, iy);\n  const c = hash2(ix, iy + 1);\n  const d = hash2(ix + 1, iy + 1);\n  const top = a + (b - a) * ux;\n  const bottom = c + (d - c) * ux;\n  return top + (bottom - top) * uy;\n};\n\nconst resolveCssColors = (inputs: string[], host: HTMLElement): Rgba[] => {\n  const probe = document.createElement(\"span\");\n  probe.style.display = \"none\";\n  host.append(probe);\n\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = 1;\n  canvas.height = 1;\n  const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n\n  const resolved = inputs.map((input, index) => {\n    const fallback = FALLBACK_COLORS[index % FALLBACK_COLORS.length];\n    if (!context) {\n      return fallback;\n    }\n    probe.style.color = \"\";\n    probe.style.color = toCssColor(input);\n    const computed = window.getComputedStyle(probe).color;\n    if (!computed) {\n      return fallback;\n    }\n    context.clearRect(0, 0, 1, 1);\n    context.fillStyle = \"#000000\";\n    context.fillStyle = computed;\n    context.fillRect(0, 0, 1, 1);\n    const { data } = context.getImageData(0, 0, 1, 1);\n    const alpha = data[3] / RGB_MAX;\n    if (alpha === 0) {\n      return [data[0], data[1], data[2], 1] as Rgba;\n    }\n    return [\n      Math.round(data[0] / alpha),\n      Math.round(data[1] / alpha),\n      Math.round(data[2] / alpha),\n      alpha,\n    ] as Rgba;\n  });\n\n  probe.remove();\n  return resolved;\n};\n\nconst mixChannel = (from: number, to: number, t: number) =>\n  Math.round(from + (to - from) * t);\n\n/**\n * Coverage → colour. Brightness travels through the ramp's lightness, not\n * through opacity alone, which is what makes the sampled word actually read\n * instead of looking like a dimmer patch of the same grey.\n */\nconst rampCss = (coverage: number, colors: Rgba[]) => {\n  const field = colors[0] ?? FALLBACK_COLORS[0];\n  const accent = colors[1] ?? colors[0] ?? FALLBACK_COLORS[1];\n  const ink = colors[2] ?? accent;\n  const low = coverage <= RAMP_MID;\n  const from = low ? field : accent;\n  const to = low ? accent : ink;\n  const t = low\n    ? coverage / RAMP_MID\n    : (coverage - RAMP_MID) / (1 - RAMP_MID || 1);\n  const fromAlpha = low ? FIELD_ALPHA : ACCENT_ALPHA;\n  const toAlpha = low ? ACCENT_ALPHA : INK_ALPHA;\n  const r = mixChannel(from[0], to[0], t);\n  const g = mixChannel(from[1], to[1], t);\n  const b = mixChannel(from[2], to[2], t);\n  const a = (fromAlpha + (toAlpha - fromAlpha) * t) * (to[3] ?? 1);\n  return `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`;\n};\n\nconst createFieldController = (\n  canvas: HTMLCanvasElement\n): FieldController | null => {\n  const context = canvas.getContext(\"2d\");\n  if (!context) {\n    return null;\n  }\n\n  const sampler = document.createElement(\"canvas\");\n  const samplerContext = sampler.getContext(\"2d\", { willReadFrequently: true });\n\n  let settings: FieldSettings = {\n    cellSize: DEFAULT_CELL_SIZE,\n    colors: FALLBACK_COLORS,\n    fontFamily: DEFAULT_FONT_FAMILY,\n    gap: DEFAULT_GAP,\n    pointerRadius: DEFAULT_POINTER_RADIUS,\n    pointerStrength: DEFAULT_POINTER_STRENGTH,\n    scatterSeed: 0,\n    shape: \"square\",\n    source: null,\n    speed: DEFAULT_SPEED,\n    still: false,\n    text: DEFAULT_TEXT,\n    weight: DEFAULT_WEIGHT,\n  };\n\n  let width = 1;\n  let height = 1;\n  let cols = 1;\n  let rows = 1;\n  let count = 1;\n  let step = DEFAULT_CELL_SIZE + DEFAULT_GAP;\n  let cellPx = DEFAULT_CELL_SIZE;\n\n  let mask = new Float32Array(1);\n  let ampFactor = new Float32Array(1);\n  let sizeBase = new Float32Array(1);\n  let homeX = new Float32Array(1);\n  let homeY = new Float32Array(1);\n  let scatterX = new Float32Array(1);\n  let scatterY = new Float32Array(1);\n  let wakeX = new Float32Array(1);\n  let wakeY = new Float32Array(1);\n  let delay = new Float32Array(1);\n  let latticeIndex = new Int32Array(1);\n  let latticeWx = new Float32Array(1);\n  let latticeWy = new Float32Array(1);\n\n  let latCols = 2;\n  let latRows = 2;\n  let fieldU = new Float32Array(4);\n  let fieldV = new Float32Array(4);\n\n  let tiers: Int32Array[] = [];\n  let tierCss: string[] = [];\n  let sampleKey = \"\";\n\n  let pointerX = 0;\n  let pointerY = 0;\n  let previousPointerX = 0;\n  let previousPointerY = 0;\n  let pointerActive = false;\n\n  const startedAt = performance.now();\n  let lastFrameAt = startedAt;\n  let scatterAt = startedAt;\n  let hasStarted = false;\n  let frame = 0;\n  let running = false;\n  let destroyed = false;\n\n  const allocate = () => {\n    mask = new Float32Array(count);\n    ampFactor = new Float32Array(count);\n    sizeBase = new Float32Array(count);\n    homeX = new Float32Array(count);\n    homeY = new Float32Array(count);\n    scatterX = new Float32Array(count);\n    scatterY = new Float32Array(count);\n    wakeX = new Float32Array(count);\n    wakeY = new Float32Array(count);\n    delay = new Float32Array(count);\n    latticeIndex = new Int32Array(count);\n    latticeWx = new Float32Array(count);\n    latticeWy = new Float32Array(count);\n  };\n\n  /**\n   * Rasterises the source once into a cols x rows bitmap — one pixel per cell —\n   * and reads it back. Called only when the grid or the source changes, never\n   * from the frame loop.\n   */\n  const sampleSource = () => {\n    if (!samplerContext) {\n      mask.fill(0);\n      return;\n    }\n    sampler.width = cols;\n    sampler.height = rows;\n    samplerContext.clearRect(0, 0, cols, rows);\n\n    const { source, text } = settings;\n    let drewSource = false;\n\n    if (source && source.naturalWidth > 0) {\n      const scale = Math.min(\n        cols / source.naturalWidth,\n        rows / source.naturalHeight\n      );\n      const drawWidth = source.naturalWidth * scale;\n      const drawHeight = source.naturalHeight * scale;\n      try {\n        samplerContext.drawImage(\n          source,\n          (cols - drawWidth) / 2,\n          (rows - drawHeight) / 2,\n          drawWidth,\n          drawHeight\n        );\n        drewSource = true;\n      } catch {\n        // A source the browser refuses to draw leaves the text path in charge.\n        drewSource = false;\n      }\n    }\n\n    if (!drewSource && text.length > 0) {\n      let fontSize = Math.max(rows * TEXT_HEIGHT_RATIO, 1);\n      samplerContext.font = `${settings.weight} ${fontSize}px ${settings.fontFamily}`;\n      const measured = samplerContext.measureText(text).width;\n      const maxWidth = cols * TEXT_WIDTH_RATIO;\n      if (measured > maxWidth && measured > 0) {\n        fontSize = Math.max((fontSize * maxWidth) / measured, 1);\n        samplerContext.font = `${settings.weight} ${fontSize}px ${settings.fontFamily}`;\n      }\n      samplerContext.fillStyle = \"#ffffff\";\n      samplerContext.textAlign = \"center\";\n      samplerContext.textBaseline = \"middle\";\n      samplerContext.fillText(text, cols / 2, rows / 2);\n    }\n\n    let pixels: Uint8ClampedArray | null = null;\n    try {\n      pixels = samplerContext.getImageData(0, 0, cols, rows).data;\n    } catch {\n      // Tainted canvas (cross-origin image without CORS headers).\n      pixels = null;\n    }\n    if (!pixels) {\n      mask.fill(0);\n      return;\n    }\n\n    const span = MASK_HIGH - MASK_LOW || 1;\n    for (let i = 0; i < count; i++) {\n      const p = i * 4;\n      const luma =\n        (pixels[p] * LUMA_R + pixels[p + 1] * LUMA_G + pixels[p + 2] * LUMA_B) /\n        RGB_MAX;\n      const coverage = (luma * pixels[p + 3]) / RGB_MAX;\n      mask[i] = smoothstep(clamp((coverage - MASK_LOW) / span, 0, 1));\n    }\n  };\n\n  const buildDerived = () => {\n    for (let row = 0; row < rows; row++) {\n      const ny = rows > 1 ? row / (rows - 1) : 0;\n      const latY = row / LATTICE;\n      const iy = Math.floor(latY);\n      const wy = latY - iy;\n      for (let col = 0; col < cols; col++) {\n        const i = row * cols + col;\n        const nx = cols > 1 ? col / (cols - 1) : 0;\n        const m = mask[i];\n        homeX[i] = (col + 0.5) * step;\n        homeY[i] = (row + 0.5) * step;\n        ampFactor[i] = 1 - m * WORD_CALM;\n        sizeBase[i] = SIZE_FLOOR + SIZE_MASK * m;\n        delay[i] = clamp(\n          nx * DELAY_X +\n            ny * DELAY_Y +\n            hash2(col, row) * DELAY_JITTER -\n            m * WORD_LEAD,\n          0,\n          1\n        );\n        const latX = col / LATTICE;\n        const ix = Math.floor(latX);\n        latticeIndex[i] = iy * latCols + ix;\n        latticeWx[i] = latX - ix;\n        latticeWy[i] = wy;\n      }\n    }\n  };\n\n  const buildScatter = (seed: number) => {\n    const offset = (SCATTER_SPREAD - 1) / 2;\n    for (let i = 0; i < count; i++) {\n      const a = hash2(i + 1, seed * 1.7 + 3.1);\n      const b = hash2(seed * 2.3 + 7.7, i + 1);\n      scatterX[i] = (a * SCATTER_SPREAD - offset) * width;\n      scatterY[i] = (b * SCATTER_SPREAD - offset) * height;\n    }\n  };\n\n  const buildTiers = () => {\n    const buckets: number[][] = Array.from({ length: TIER_COUNT }, () => []);\n    for (let i = 0; i < count; i++) {\n      const tier = Math.min(TIER_COUNT - 1, Math.floor(mask[i] * TIER_COUNT));\n      buckets[tier].push(i);\n    }\n    tiers = buckets.map((bucket) => Int32Array.from(bucket));\n    tierCss = buckets.map((_, tier) =>\n      rampCss((tier + 0.5) / TIER_COUNT, settings.colors)\n    );\n  };\n\n  const buildGrid = () => {\n    step = Math.max(settings.cellSize + settings.gap, MIN_STEP);\n    cellPx = settings.cellSize;\n    cols = Math.max(1, Math.ceil(width / step));\n    rows = Math.max(1, Math.ceil(height / step));\n\n    // The word is fitted to a fraction of the column count, so too few columns\n    // means too few cells per glyph and the word stops reading. On a narrow\n    // surface the grid densifies instead, keeping the type legible.\n    const legibleStep = Math.max(width / MIN_COLS, MIN_STEP);\n    if (width >= MIN_COLS && legibleStep < step) {\n      cellPx *= legibleStep / step;\n      step = legibleStep;\n      cols = Math.max(1, Math.ceil(width / step));\n      rows = Math.max(1, Math.ceil(height / step));\n    }\n\n    // A dense grid over a large surface would blow the frame budget, so the\n    // grid is coarsened rather than truncated — coverage stays complete.\n    if (cols * rows > MAX_CELLS) {\n      const scale = Math.sqrt((cols * rows) / MAX_CELLS);\n      step *= scale;\n      cellPx *= scale;\n      cols = Math.max(1, Math.ceil(width / step));\n      rows = Math.max(1, Math.ceil(height / step));\n    }\n\n    const nextCount = cols * rows;\n    const resized = nextCount !== count;\n    count = nextCount;\n    latCols = Math.ceil(cols / LATTICE) + 2;\n    latRows = Math.ceil(rows / LATTICE) + 2;\n\n    if (resized) {\n      allocate();\n      fieldU = new Float32Array(latCols * latRows);\n      fieldV = new Float32Array(latCols * latRows);\n    } else if (fieldU.length !== latCols * latRows) {\n      fieldU = new Float32Array(latCols * latRows);\n      fieldV = new Float32Array(latCols * latRows);\n    }\n\n    const key = [\n      cols,\n      rows,\n      settings.text,\n      settings.weight,\n      settings.fontFamily,\n      settings.source?.src ?? \"\",\n    ].join(\"|\");\n    if (key !== sampleKey) {\n      sampleKey = key;\n      sampleSource();\n    }\n\n    buildDerived();\n    buildScatter(settings.scatterSeed);\n    buildTiers();\n  };\n\n  const resize = () => {\n    const rect = canvas.getBoundingClientRect();\n    const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n    width = Math.max(1, rect.width);\n    height = Math.max(1, rect.height);\n    const pixelWidth = Math.max(1, Math.round(width * dpr));\n    const pixelHeight = Math.max(1, Math.round(height * dpr));\n    if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {\n      canvas.width = pixelWidth;\n      canvas.height = pixelHeight;\n    }\n    context.setTransform(dpr, 0, 0, dpr, 0, 0);\n    buildGrid();\n  };\n\n  /** Adds one cell to the current path around its centre. Never fills. */\n  const traceCell = (cx: number, cy: number, size: number) => {\n    const half = size / 2;\n    if (settings.shape === \"circle\") {\n      context.moveTo(cx + half, cy);\n      context.arc(cx, cy, half, 0, TAU);\n      return;\n    }\n    if (settings.shape === \"cross\") {\n      const arm = size * CROSS_THICKNESS;\n      context.rect(cx - half, cy - arm / 2, size, arm);\n      context.rect(cx - arm / 2, cy - half, arm, size);\n      return;\n    }\n    context.rect(cx - half, cy - half, size, size);\n  };\n\n  /** Refreshes the coarse flow lattice. O(cols + rows), not O(cells). */\n  const updateFlowField = (time: number) => {\n    for (let ly = 0; ly < latRows; ly++) {\n      const gy = ly * LATTICE * NOISE_SCALE;\n      const base = ly * latCols;\n      for (let lx = 0; lx < latCols; lx++) {\n        const gx = lx * LATTICE * NOISE_SCALE;\n        const swirl = valueNoise(gx, gy + time);\n        const strength = valueNoise(\n          gx + NOISE_OFFSET_X,\n          gy - time * NOISE_COUNTER_FLOW + NOISE_OFFSET_Y\n        );\n        const angle = swirl * TAU * FLOW_TURNS;\n        const magnitude = MAG_FLOOR + MAG_RANGE * strength;\n        fieldU[base + lx] = Math.cos(angle) * magnitude;\n        fieldV[base + lx] = Math.sin(angle) * magnitude;\n      }\n    }\n  };\n\n  /** Stamps a decaying impulse into every cell inside the pointer radius. */\n  const stampWake = (px: number, py: number, dt: number) => {\n    const radius = Math.max(settings.pointerRadius, 1);\n    const limit = radius * radius;\n    const ceiling = step * WAKE_CEILING;\n    const firstCol = clamp(Math.floor((px - radius) / step), 0, cols - 1);\n    const lastCol = clamp(Math.ceil((px + radius) / step), 0, cols - 1);\n    const firstRow = clamp(Math.floor((py - radius) / step), 0, rows - 1);\n    const lastRow = clamp(Math.ceil((py + radius) / step), 0, rows - 1);\n\n    for (let row = firstRow; row <= lastRow; row++) {\n      const base = row * cols;\n      for (let col = firstCol; col <= lastCol; col++) {\n        const i = base + col;\n        const dx = homeX[i] - px;\n        const dy = homeY[i] - py;\n        const squared = dx * dx + dy * dy;\n        if (squared >= limit || squared === 0) {\n          continue;\n        }\n        const distance = Math.sqrt(squared);\n        const falloff = 1 - distance / radius;\n        const push =\n          falloff *\n          falloff *\n          settings.pointerStrength *\n          radius *\n          WAKE_GAIN *\n          dt;\n        wakeX[i] = clamp(wakeX[i] + (dx / distance) * push, -ceiling, ceiling);\n        wakeY[i] = clamp(wakeY[i] + (dy / distance) * push, -ceiling, ceiling);\n      }\n    }\n  };\n\n  /**\n   * Walks the segment the pointer covered since the previous frame so a fast\n   * flick leaves a continuous trail instead of a dotted line of impacts.\n   */\n  const advanceWake = (dt: number) => {\n    if (!pointerActive) {\n      previousPointerX = pointerX;\n      previousPointerY = pointerY;\n      return;\n    }\n    const dx = pointerX - previousPointerX;\n    const dy = pointerY - previousPointerY;\n    const travel = Math.hypot(dx, dy);\n    const steps = clamp(Math.ceil(travel / step), 1, MAX_SUBSTEPS);\n    const slice = dt / steps;\n    for (let s = 1; s <= steps; s++) {\n      const t = s / steps;\n      stampWake(previousPointerX + dx * t, previousPointerY + dy * t, slice);\n    }\n    previousPointerX = pointerX;\n    previousPointerY = pointerY;\n  };\n\n  /** The one frame drawn when motion is reduced: grid at rest, word legible. */\n  const drawResolved = () => {\n    for (let tier = 0; tier < tiers.length; tier++) {\n      const bucket = tiers[tier];\n      if (bucket.length === 0) {\n        continue;\n      }\n      context.fillStyle = tierCss[tier];\n      context.beginPath();\n      for (const i of bucket) {\n        const size = cellPx * (sizeBase[i] + SIZE_FLOW * STILL_MAG);\n        if (size > MIN_DRAW) {\n          traceCell(homeX[i], homeY[i], size);\n        }\n      }\n      context.fill();\n    }\n  };\n\n  const drawFlowing = (reform: number, decay: number) => {\n    const settled = reform >= 1;\n    const rest = 1 / (1 - STAGGER);\n    const drift = step * DRIFT_RATIO;\n\n    for (let tier = 0; tier < tiers.length; tier++) {\n      const bucket = tiers[tier];\n      if (bucket.length === 0) {\n        continue;\n      }\n      context.fillStyle = tierCss[tier];\n      context.beginPath();\n\n      for (const i of bucket) {\n        const wx = wakeX[i] * decay;\n        const wy = wakeY[i] * decay;\n        wakeX[i] = wx;\n        wakeY[i] = wy;\n\n        const corner = latticeIndex[i];\n        const tx = latticeWx[i];\n        const ty = latticeWy[i];\n        const below = corner + latCols;\n        const uTop =\n          fieldU[corner] + (fieldU[corner + 1] - fieldU[corner]) * tx;\n        const uBottom =\n          fieldU[below] + (fieldU[below + 1] - fieldU[below]) * tx;\n        const vTop =\n          fieldV[corner] + (fieldV[corner + 1] - fieldV[corner]) * tx;\n        const vBottom =\n          fieldV[below] + (fieldV[below + 1] - fieldV[below]) * tx;\n        const u = uTop + (uBottom - uTop) * ty;\n        const v = vTop + (vBottom - vTop) * ty;\n        const magnitude = Math.abs(u) + Math.abs(v);\n\n        const amplitude = drift * ampFactor[i];\n        const restX = homeX[i] + u * amplitude + wx;\n        const restY = homeY[i] + v * amplitude + wy;\n\n        let x = restX;\n        let y = restY;\n        let scale = 1;\n        if (!settled) {\n          const local = clamp((reform - delay[i] * STAGGER) * rest, 0, 1);\n          const inverse = 1 - local;\n          // Ease-out cubic: cells decelerate into place instead of arriving\n          // at a constant speed.\n          const eased = 1 - inverse * inverse * inverse;\n          x = scatterX[i] + (restX - scatterX[i]) * eased;\n          y = scatterY[i] + (restY - scatterY[i]) * eased;\n          scale = SCATTER_SIZE + (1 - SCATTER_SIZE) * eased;\n        }\n\n        const size = cellPx * (sizeBase[i] + SIZE_FLOW * magnitude) * scale;\n        if (size <= MIN_DRAW) {\n          continue;\n        }\n        if (x < -size || x > width + size) {\n          continue;\n        }\n        if (y < -size || y > height + size) {\n          continue;\n        }\n        traceCell(x, y, size);\n      }\n      context.fill();\n    }\n  };\n\n  const draw = () => {\n    if (destroyed) {\n      return;\n    }\n    context.clearRect(0, 0, width, height);\n\n    if (settings.still) {\n      drawResolved();\n      return;\n    }\n\n    const now = performance.now();\n    const dt = clamp((now - lastFrameAt) / MS_PER_SECOND, 0, MAX_DT);\n    lastFrameAt = now;\n\n    const elapsed = (now - startedAt) / MS_PER_SECOND;\n    updateFlowField(elapsed * settings.speed * TIME_SCALE);\n    advanceWake(dt);\n\n    const reform = clamp((now - scatterAt) / REFORM_MS, 0, 1);\n    // Exponential decay, so the wake settles like momentum bleeding off\n    // rather than fading on a straight line.\n    drawFlowing(reform, Math.exp(-dt / WAKE_TAU));\n  };\n\n  const tick = () => {\n    if (destroyed || !running) {\n      return;\n    }\n    draw();\n    frame = requestAnimationFrame(tick);\n  };\n\n  resize();\n\n  return {\n    destroy: () => {\n      destroyed = true;\n      running = false;\n      cancelAnimationFrame(frame);\n      context.setTransform(1, 0, 0, 1, 0, 0);\n      context.clearRect(0, 0, canvas.width, canvas.height);\n      canvas.width = 0;\n      canvas.height = 0;\n      sampler.width = 0;\n      sampler.height = 0;\n    },\n    render: () => {\n      resize();\n      draw();\n    },\n    resize: () => {\n      resize();\n      if (!running) {\n        draw();\n      }\n    },\n    setPointer: (x: number, y: number, active: boolean) => {\n      if (!pointerActive) {\n        previousPointerX = x;\n        previousPointerY = y;\n      }\n      pointerX = x;\n      pointerY = y;\n      pointerActive = active;\n    },\n    setRunning: (next: boolean) => {\n      if (destroyed || running === next) {\n        return;\n      }\n      running = next;\n      if (next) {\n        lastFrameAt = performance.now();\n        if (!hasStarted) {\n          // The reveal plays when the field first becomes visible, not while\n          // it is still parked below the fold.\n          hasStarted = true;\n          scatterAt = lastFrameAt;\n        }\n        frame = requestAnimationFrame(tick);\n      } else {\n        cancelAnimationFrame(frame);\n      }\n    },\n    setSettings: (next: FieldSettings) => {\n      const rescatter = next.scatterSeed !== settings.scatterSeed;\n      settings = next;\n      buildGrid();\n      if (rescatter) {\n        scatterAt = performance.now();\n        wakeX.fill(0);\n        wakeY.fill(0);\n      }\n    },\n  };\n};\n\nconst PixelFlowField = ({\n  cellSize = DEFAULT_CELL_SIZE,\n  children,\n  className,\n  colors = DEFAULT_COLORS,\n  gap = DEFAULT_GAP,\n  paused = false,\n  pointerRadius = DEFAULT_POINTER_RADIUS,\n  pointerStrength = DEFAULT_POINTER_STRENGTH,\n  scatter = 0,\n  shape = \"square\",\n  speed = DEFAULT_SPEED,\n  src,\n  text = DEFAULT_TEXT,\n  weight = DEFAULT_WEIGHT,\n}: PixelFlowFieldProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const hostRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const controllerRef = useRef<FieldController | null>(null);\n  const [isSupported, setIsSupported] = useState(true);\n  const [isActive, setIsActive] = useState(false);\n  const [resolvedColors, setResolvedColors] = useState<Rgba[]>(FALLBACK_COLORS);\n  const [fontFamily, setFontFamily] = useState(DEFAULT_FONT_FAMILY);\n  const [source, setSource] = useState<HTMLImageElement | null>(null);\n\n  const colorKey = colors.join(\"|\");\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) {\n      return;\n    }\n    setResolvedColors(resolveCssColors(colorKey.split(\"|\"), host));\n  }, [colorKey]);\n\n  // The sampled word should be set in the same typeface as the surrounding\n  // page, so the family is read off the host instead of being hardcoded.\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) {\n      return;\n    }\n    let cancelled = false;\n    const read = () => {\n      if (!cancelled) {\n        setFontFamily(window.getComputedStyle(host).fontFamily);\n      }\n    };\n    read();\n    document.fonts.ready.then(read).catch(() => {\n      // A font that never resolves just leaves the fallback family in place.\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!src) {\n      setSource(null);\n      return;\n    }\n    let cancelled = false;\n    const image = new Image();\n    image.crossOrigin = \"anonymous\";\n    image.decoding = \"async\";\n    image.onload = () => {\n      if (!cancelled) {\n        setSource(image);\n      }\n    };\n    image.onerror = () => {\n      if (!cancelled) {\n        setSource(null);\n      }\n    };\n    image.src = src;\n    return () => {\n      cancelled = true;\n    };\n  }, [src]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) {\n      return;\n    }\n    const controller = createFieldController(canvas);\n    controllerRef.current = controller;\n    setIsSupported(controller !== null);\n    if (!controller) {\n      return;\n    }\n\n    const observer = new ResizeObserver(() => controller.resize());\n    observer.observe(canvas);\n\n    return () => {\n      observer.disconnect();\n      controller.destroy();\n      controllerRef.current = null;\n    };\n  }, []);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!(host && isSupported)) {\n      return;\n    }\n\n    let isOnScreen = true;\n    const sync = () => {\n      setIsActive(isOnScreen && document.visibilityState === \"visible\");\n    };\n\n    const observer = new IntersectionObserver((entries) => {\n      isOnScreen = entries.some((entry) => entry.isIntersecting);\n      sync();\n    });\n    observer.observe(host);\n    document.addEventListener(\"visibilitychange\", sync);\n    sync();\n\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n    };\n  }, [isSupported]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    controller.setSettings({\n      cellSize: Math.max(cellSize, MIN_STEP),\n      colors: resolvedColors,\n      fontFamily,\n      gap: Math.max(gap, 0),\n      pointerRadius: Math.max(pointerRadius, 1),\n      pointerStrength: clamp(pointerStrength, 0, 1),\n      scatterSeed: scatter,\n      shape,\n      source,\n      speed: Math.max(speed, 0),\n      still: shouldReduceMotion === true,\n      text,\n      weight,\n    });\n    controller.render();\n  }, [\n    cellSize,\n    fontFamily,\n    gap,\n    pointerRadius,\n    pointerStrength,\n    resolvedColors,\n    scatter,\n    shape,\n    shouldReduceMotion,\n    source,\n    speed,\n    text,\n    weight,\n  ]);\n\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (!controller) {\n      return;\n    }\n    const shouldRun = isActive && !paused && !shouldReduceMotion;\n    controller.setRunning(shouldRun);\n    if (!shouldRun) {\n      controller.render();\n    }\n  }, [isActive, paused, shouldReduceMotion]);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    const canvas = canvasRef.current;\n    if (!(host && canvas) || shouldReduceMotion) {\n      return;\n    }\n\n    // Pointer events, not hover: a touch drag has to leave a wake too.\n    const track = (event: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      controllerRef.current?.setPointer(\n        event.clientX - rect.left,\n        event.clientY - rect.top,\n        true\n      );\n    };\n    const release = () => {\n      controllerRef.current?.setPointer(0, 0, false);\n    };\n\n    host.addEventListener(\"pointermove\", track, { passive: true });\n    host.addEventListener(\"pointerdown\", track, { passive: true });\n    host.addEventListener(\"pointerleave\", release);\n    host.addEventListener(\"pointercancel\", release);\n\n    return () => {\n      host.removeEventListener(\"pointermove\", track);\n      host.removeEventListener(\"pointerdown\", track);\n      host.removeEventListener(\"pointerleave\", release);\n      host.removeEventListener(\"pointercancel\", release);\n    };\n  }, [shouldReduceMotion]);\n\n  const fallbackColor = toCssColor(colors[1] ?? colors[0] ?? DEFAULT_COLORS[1]);\n  const fallbackStep = Math.max(cellSize + gap, MIN_STEP);\n\n  return (\n    <div\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      ref={hostRef}\n    >\n      {isSupported ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n        >\n          <canvas className=\"h-full w-full\" ref={canvasRef} />\n        </div>\n      ) : (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 opacity-40\"\n          style={{\n            backgroundImage: `radial-gradient(${fallbackColor} 1px, transparent 1px)`,\n            backgroundSize: `${fallbackStep}px ${fallbackStep}px`,\n          }}\n        />\n      )}\n      <div className=\"relative z-10 h-full\">{children}</div>\n    </div>\n  );\n};\n\nexport default PixelFlowField;\n","path":"index.tsx","target":"components/smoothui/pixel-flow-field/index.tsx","type":"registry:ui"}],"name":"pixel-flow-field","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Pixel Flow Field","type":"registry:ui"}