{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "seam",
  "type": "registry:ui",
  "title": "Seam",
  "description": "The edge between two sections, printed as a halftone. Drop it between a light section and a dark one and it reads both backgrounds; as you scroll past, the color below climbs into the band cell by cell along a ragged front, so the next section reaches up to meet you instead of starting at a hard line.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "background",
    "scroll",
    "canvas"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/seam",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/seam.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - The front is painted on one canvas by one rAF loop that stops once the spring settles; scroll\n *   never re-renders React.\n * - Cells are whole pixels and the rows are anchored to the band's bottom, so full cells meet edge\n *   to edge and the band meets the next section exactly.\n * - The top and bottom `edgeRows` are pinned to their section's color, so neither edge of the band\n *   shows a line.\n * - Mounting, resizing and coming back on screen snap the front to the scroll position instead of\n *   springing to it.\n * - Under reduced motion the front skips the spring and sits exactly where the scroll puts it.\n */\nconst TIMING = {\n  /** The spring the front rides as it follows scroll (the `stiffness` and `damping` props). */\n  front: { stiffness: 120, damping: 30, mass: 0.4 },\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * SEAM STORYBOARD\n *\n *  rest      a band between two sections, printed as a grid of\n *            cells. The top row is all the section above, the\n *            bottom row all the section below, and a ragged front\n *            of half-filled cells runs between them, so neither\n *            edge of the band shows a line\n *  scroll    the front follows the band through its scrollport.\n *            When the band comes up from the bottom of the screen\n *            the section above still holds most of it; as it rises\n *            the color below climbs into it cell by cell, and by\n *            the time it leaves at the top the lower section has\n *            taken it over. The next section reaches up to meet\n *            the reader instead of starting at a hard edge\n *  smooth    the front rides a scroll-smoothing spring (stiffness\n *            120, damping 30, mass 0.4), so a wheel tick glides\n *            instead of stepping\n *  grain     the front is ragged, not a ruled line: each cell's\n *            threshold is shifted by low-frequency noise plus a\n *            little jitter of its own, so the lower color creeps\n *            up in tongues, like ink wicking into paper\n *  patterns  `dots` and `squares` grow by area like a halftone;\n *            `dither` switches whole cells on and off through a\n *            4 × 4 ordered matrix, like an old pixel dissolve\n *  colors    with no classes it reads the background of the\n *            element above and the element below, so it matches\n *            the two sections it sits between and follows them\n *            into dark mode\n *  progress  pass `progress` (0–1) to hold or drive the front\n *            yourself; the scrollport is ignored\n *  reduced   the same mapping to scroll, without the spring: the\n *            front sits exactly where the scroll puts it\n * ───────────────────────────────────────────────────────── */\n\nconst SEAM = {\n  ramp: 1, // share of the band height the front's half-filled cells span\n  noise: 0.5, // share of the band height the front wanders at `grain` 1\n  jitter: 0.3, // share of `noise` that is each cell's own, the rest is shared with its neighbours\n  noiseScale: [7, 2.5], // cells per noise period, across and down\n  edgeRows: 2.5, // rows over which the top and bottom edges are pinned to their section\n  minFill: 0.02, // cells under this fill draw nothing\n  grout: 3, // px, the narrowest gap a halftone square leaves before it fills its cell\n  maxDpr: 2,\n} as const;\n\n// 4 × 4 Bayer matrix, as thresholds in (0, 1).\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((b) => (b + 0.5) / 16);\n\nconst clamp01 = (t: number) => Math.min(1, Math.max(0, t));\nconst smoothstep = (a: number, b: number, x: number) => {\n  const t = clamp01((x - a) / (b - a));\n  return t * t * (3 - 2 * t);\n};\n\n/** A stable 0–1 value per integer lattice point. */\nfunction hash(x: number, y: number, seed: number) {\n  let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 1442695041)) | 0;\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return (h >>> 0) / 4294967295;\n}\n\n/** Smooth value noise in (-1, 1). */\nfunction noise(x: number, y: number, seed: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const fx = x - xi;\n  const fy = y - yi;\n  const u = fx * fx * (3 - 2 * fx);\n  const v = fy * fy * (3 - 2 * fy);\n  const a = hash(xi, yi, seed);\n  const b = hash(xi + 1, yi, seed);\n  const c = hash(xi, yi + 1, seed);\n  const d = hash(xi + 1, yi + 1, seed);\n  return (a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v) * 2 - 1;\n}\n\nfunction isTransparent(color: string) {\n  return color === \"transparent\" || /rgba?\\([^)]*,\\s*0\\)$/.test(color) || /\\/\\s*0\\)$/.test(color);\n}\n\n/** The first opaque background at or above `el`. */\nfunction backgroundOf(el: Element | null) {\n  for (let node = el; node; node = node.parentElement) {\n    const color = getComputedStyle(node).backgroundColor;\n    if (!isTransparent(color)) return color;\n  }\n  return null;\n}\n\nfunction scrollParent(el: HTMLElement) {\n  for (let node = el.parentElement; node; node = node.parentElement) {\n    const { overflowY } = getComputedStyle(node);\n    if (overflowY === \"auto\" || overflowY === \"scroll\" || overflowY === \"overlay\") return node;\n  }\n  return null;\n}\n\nexport type SeamPattern = \"dots\" | \"squares\" | \"dither\";\n\ntype Options = {\n  pattern: SeamPattern;\n  cell: number;\n  grain: number;\n  progress: number | undefined;\n  seed: number;\n  stiffness: number;\n  damping: number;\n  reduced: boolean;\n};\n\nfunction createSeam() {\n  let root: HTMLElement | null = null;\n  let canvas: HTMLCanvasElement | null = null;\n  let ctx: CanvasRenderingContext2D | null = null;\n  let probes: { from: HTMLElement | null; to: HTMLElement | null } = { from: null, to: null };\n  let scroller: HTMLElement | null = null;\n  let options: Options = {\n    pattern: \"dots\",\n    cell: 12,\n    grain: 0.6,\n    progress: undefined,\n    seed: 1,\n    stiffness: TIMING.front.stiffness,\n    damping: TIMING.front.damping,\n    reduced: false,\n  };\n\n  let width = 0;\n  let height = 0;\n  let dpr = 1;\n  let cols = 0;\n  let rows = 0;\n  let x0 = 0;\n  let y0 = 0;\n  let offsets = new Float32Array(0);\n  let colors = { from: \"#ffffff\", to: \"#09090b\" };\n\n  let target = 0;\n  let front = 0;\n  let velocity = 0;\n  let raf = 0;\n  let last = 0;\n  let visible = true;\n  const cleanups: (() => void)[] = [];\n\n  function readColors() {\n    if (!root) return;\n    const above = probes.from ? getComputedStyle(probes.from).color : backgroundOf(root.previousElementSibling);\n    const below = probes.to ? getComputedStyle(probes.to).color : backgroundOf(root.nextElementSibling);\n    const page = backgroundOf(root.parentElement) ?? \"#ffffff\";\n    colors = { from: above ?? page, to: below ?? getComputedStyle(root).color };\n  }\n\n  function layout() {\n    if (!root || !canvas || !ctx) return;\n    const rect = root.getBoundingClientRect();\n    const nextDpr = Math.min(window.devicePixelRatio || 1, SEAM.maxDpr);\n    width = rect.width;\n    height = rect.height;\n    dpr = nextDpr;\n    canvas.width = Math.max(1, Math.round(width * dpr));\n    canvas.height = Math.max(1, Math.round(height * dpr));\n\n    const cell = Math.max(4, Math.round(options.cell));\n    cols = Math.ceil(width / cell) + 1;\n    rows = Math.ceil(height / cell);\n    // Centre the columns; anchor the rows to the bottom, where the band must meet the next section exactly.\n    x0 = Math.round((width - cols * cell) / 2);\n    y0 = Math.round(height - rows * cell);\n\n    offsets = new Float32Array(cols * rows);\n    const [sx, sy] = SEAM.noiseScale;\n    for (let j = 0; j < rows; j++) {\n      for (let i = 0; i < cols; i++) {\n        const shared = noise(i / sx, j / sy, options.seed);\n        const own = hash(i, j, options.seed + 7) * 2 - 1;\n        offsets[j * cols + i] = shared * (1 - SEAM.jitter) + own * SEAM.jitter;\n      }\n    }\n    scroller = scrollParent(root);\n  }\n\n  /** Where the band sits in its scrollport: 0 entering at the bottom, 1 leaving at the top. */\n  function measure() {\n    if (options.progress !== undefined) return clamp01(options.progress);\n    if (!root) return 0;\n    const rect = root.getBoundingClientRect();\n    let top = 0;\n    let bottom = window.innerHeight;\n    if (scroller) {\n      const port = scroller.getBoundingClientRect();\n      top = port.top;\n      bottom = port.bottom;\n    }\n    return clamp01((bottom - rect.top) / (bottom - top + rect.height));\n  }\n\n  function draw() {\n    if (!ctx || !canvas || width === 0) return;\n    // Whole pixels, so full cells meet edge to edge with no antialiased seam between them.\n    const cell = Math.max(4, Math.round(options.cell));\n    const amp = SEAM.noise * options.grain;\n    const ramp = SEAM.ramp;\n    // The front's centre, from below the band (nothing climbed yet) to above it (all climbed).\n    const reach = ramp / 2 + amp;\n    const at = 1 + reach - front * (1 + 2 * reach);\n    const edge = SEAM.edgeRows / rows;\n    const dither = options.pattern === \"dither\";\n    const dots = options.pattern === \"dots\";\n\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    ctx.fillStyle = colors.from;\n    ctx.fillRect(0, 0, width, height);\n    ctx.fillStyle = colors.to;\n    ctx.beginPath();\n    for (let j = 0; j < rows; j++) {\n      const cy = y0 + (j + 0.5) * cell;\n      const v = cy / height;\n      const pinTop = smoothstep(0, edge, v - 0.5 / rows);\n      const pinBottom = smoothstep(0, edge, 1 - v - 0.5 / rows);\n      for (let i = 0; i < cols; i++) {\n        const k = j * cols + i;\n        let fill = smoothstep(0, 1, (v - at + offsets[k] * amp) / ramp + 0.5);\n        fill *= pinTop;\n        fill = 1 - (1 - fill) * pinBottom;\n        if (fill < SEAM.minFill) continue;\n        const x = x0 + i * cell;\n        const y = y0 + j * cell;\n        if (dither) {\n          if (fill > BAYER[(j % 4) * 4 + (i % 4)]) ctx.rect(x, y, cell, cell);\n        } else if (fill > 0.985) {\n          ctx.rect(x, y, cell, cell);\n        } else if (dots) {\n          // Circles overlap their neighbours past half, so the gaps close the way a print halftone's do.\n          const r = cell * 0.7072 * Math.sqrt(fill);\n          ctx.moveTo(x + cell / 2 + r, y + cell / 2);\n          ctx.arc(x + cell / 2, y + cell / 2, r, 0, Math.PI * 2);\n        } else {\n          // Even pixel sizes keep a square centred on the pixel grid, and a square within\n          // `grout` of its cell fills it, so the field never shows hairline outlines.\n          const side = Math.floor((cell * Math.sqrt(fill)) / 2) * 2;\n          if (cell - side < SEAM.grout) ctx.rect(x, y, cell, cell);\n          else if (side >= 2) ctx.rect(x + (cell - side) / 2, y + (cell - side) / 2, side, side);\n        }\n      }\n    }\n    ctx.fill();\n  }\n\n  function frame(now: number) {\n    raf = 0;\n    const dt = Math.min(1 / 30, (now - last) / 1000 || 1 / 60);\n    last = now;\n    if (options.reduced) {\n      front = target;\n      velocity = 0;\n    } else {\n      const { stiffness, damping } = options;\n      const { mass } = TIMING.front;\n      const accel = (stiffness * (target - front) - damping * velocity) / mass;\n      velocity += accel * dt;\n      front += velocity * dt;\n    }\n    draw();\n    if (Math.abs(target - front) < 1e-4 && Math.abs(velocity) < 1e-3) {\n      front = target;\n      velocity = 0;\n      draw();\n      return;\n    }\n    raf = requestAnimationFrame(frame);\n  }\n\n  function wake() {\n    target = measure();\n    if (!visible) return;\n    if (!raf) {\n      last = performance.now();\n      raf = requestAnimationFrame(frame);\n    }\n  }\n\n  /** Jump straight to the scroll position: on mount, resize and coming back on screen. */\n  function snap() {\n    target = front = measure();\n    velocity = 0;\n    draw();\n  }\n\n  return {\n    connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; from: HTMLElement | null; to: HTMLElement | null }) {\n      root = el.root;\n      canvas = el.canvas;\n      ctx = canvas.getContext(\"2d\");\n      probes = { from: el.from, to: el.to };\n      readColors();\n      layout();\n      snap();\n\n      const resize = new ResizeObserver(() => {\n        layout();\n        snap();\n      });\n      resize.observe(root);\n      const io = new IntersectionObserver(([entry]) => {\n        visible = entry.isIntersecting;\n        if (visible) snap();\n      });\n      io.observe(root);\n\n      // Scroll events don't bubble, but they do pass through the capture phase, so one listener\n      // hears the window and whatever box the band scrolls inside.\n      const onScroll = () => wake();\n      window.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n      window.addEventListener(\"resize\", onScroll, { passive: true });\n\n      const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      const recolor = () => {\n        // Let the new scheme's styles land before reading the neighbours' backgrounds.\n        requestAnimationFrame(() => {\n          readColors();\n          draw();\n        });\n      };\n      scheme.addEventListener(\"change\", recolor);\n      const themeAttrs = new MutationObserver(recolor);\n      themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] });\n\n      cleanups.push(\n        () => resize.disconnect(),\n        () => io.disconnect(),\n        () => window.removeEventListener(\"scroll\", onScroll, { capture: true }),\n        () => window.removeEventListener(\"resize\", onScroll),\n        () => scheme.removeEventListener(\"change\", recolor),\n        () => themeAttrs.disconnect(),\n      );\n      return () => {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        cleanups.splice(0).forEach((fn) => fn());\n        root = canvas = scroller = null;\n        ctx = null;\n      };\n    },\n\n    setOptions(next: Options) {\n      const relayout = next.cell !== options.cell || next.seed !== options.seed;\n      const driven = next.progress !== options.progress;\n      options = next;\n      if (relayout) layout();\n      readColors();\n      if (driven && next.progress !== undefined) wake();\n      else snap();\n    },\n  };\n}\n\nconst REDUCED = \"(prefers-reduced-motion: reduce)\";\nfunction subscribeReduced(onChange: () => void) {\n  const query = window.matchMedia(REDUCED);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nfunction useReduced() {\n  return React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(REDUCED).matches,\n    () => false,\n  );\n}\n\nexport interface SeamProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** What the band breaks up into: halftone `dots`, halftone `squares`, or a pixel `dither`. */\n  pattern?: SeamPattern;\n  /** Size of one cell, in px. */\n  cell?: number;\n  /** How ragged the front is, from a straight line (`0`) to deep tongues (`1`). */\n  grain?: number;\n  /** Hold or drive the front yourself, from `0` (all the section above) to `1` (all the section below). Leave it out to follow scroll. */\n  progress?: number;\n  /** Changes the shape of the front's tongues. */\n  seed?: number;\n  /** Stiffness of the spring the front rides as it follows scroll. Higher keeps it closer to the scroll. */\n  stiffness?: number;\n  /** Damping of that spring. Lower lets the front overshoot and settle back. */\n  damping?: number;\n  /** A `text-*` color for the section above. Without it, the band reads the background of the element before it. */\n  fromClassName?: string;\n  /** A `text-*` color for the section below. Without it, the band reads the background of the element after it. */\n  toClassName?: string;\n}\n\nexport function Seam({\n  pattern = \"dots\",\n  cell = 12,\n  grain = 0.6,\n  progress,\n  seed = 1,\n  stiffness = TIMING.front.stiffness,\n  damping = TIMING.front.damping,\n  fromClassName,\n  toClassName,\n  className,\n  children,\n  ...props\n}: SeamProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const fromRef = React.useRef<HTMLSpanElement>(null);\n  const toRef = React.useRef<HTMLSpanElement>(null);\n  const [seam] = React.useState(createSeam);\n  const reduced = useReduced();\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    return seam.connect({ root, canvas, from: fromRef.current, to: toRef.current });\n  }, [seam, fromClassName, toClassName]);\n\n  React.useEffect(() => {\n    seam.setOptions({\n      pattern,\n      cell,\n      grain,\n      progress,\n      seed,\n      stiffness: Math.max(1, stiffness),\n      damping: Math.max(0, damping),\n      reduced,\n    });\n  }, [seam, pattern, cell, grain, progress, seed, stiffness, damping, reduced]);\n\n  return (\n    <div ref={rootRef} className={cn(\"relative isolate h-40 w-full overflow-hidden\", className)} {...props}>\n      <canvas ref={canvasRef} aria-hidden className=\"pointer-events-none absolute inset-0 -z-10 size-full\" />\n      {fromClassName ? <span ref={fromRef} aria-hidden className={cn(\"hidden\", fromClassName)} /> : null}\n      {toClassName ? <span ref={toRef} aria-hidden className={cn(\"hidden\", toClassName)} /> : null}\n      {children}\n    </div>\n  );\n}\n"
    }
  ]
}