{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dissolve-input",
  "title": "Dissolve input",
  "description": "Ledge’s underlined login input, whose text dissolves into particles from right to left with a visible action button, on Enter or after a confirmed action.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/dissolve-input/dissolve-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CornerDownLeft } from \"lucide-react\";\nimport { useReducedMotionConfig } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/** Imperative controls, for flows that confirm the value elsewhere (a login that succeeded). */\nexport type DissolveInputHandle = {\n  /** Dissolve whatever the field holds now, then clear it. */\n  dissipate: () => void;\n  focus: () => void;\n  /** The field's live value, including browser autofill React has not seen yet. */\n  getValue: () => string;\n};\n\nexport type DissolveInputProps = Omit<\n  React.InputHTMLAttributes<HTMLInputElement>,\n  \"onChange\" | \"onSubmit\" | \"value\" | \"defaultValue\" | \"color\"\n> & {\n  /** Controlled value. */\n  value: string;\n  /** Every edit, and `\"\"` once the text has dissolved. */\n  onChange: (value: string) => void;\n  /**\n   * Enter or the action button. Return `false` to keep the text and skip the\n   * effect; a throw or rejection shows a retry message under the field.\n   */\n  onSubmit?: (value: string) => void | boolean | Promise<void | boolean>;\n  /** Submit and dissolve on Enter, with a visible action button. Default `true`. */\n  dissipateOnEnter?: boolean;\n  /** After the last particle has gone and the value has been cleared. */\n  onDissipated?: () => void;\n  /** Draw the particles from bullets instead of the text. Password inputs always mask. */\n  mask?: boolean;\n  /** Classes for the `<input>` itself; `className` goes on the underlined wrapper. */\n  inputClassName?: string;\n  /** Accessible name of the icon-only action button. Default \"Submit\". */\n  submitLabel?: string;\n};\n\n/** One dissolve, from the moment the text is frozen until it has gone. */\ntype Run = { text: string; animate: boolean };\n\nconst ERROR_MESSAGE = \"Couldn’t submit. Please try again.\";\n\n/* ------------------------------------------------------------------------ */\n/* Particles                                                                */\n/* ------------------------------------------------------------------------ */\n\n/** Hard ceiling on particles, whatever the text's length; cells grow to fit. */\nconst MAX_PARTICLES = 1600;\n/** Smallest square of ink, in CSS px, that one particle carries away. */\nconst MIN_CELL = 1.5;\n/** Rough share of a text's bounding box that is ink, to size cells up front. */\nconst INK_SHARE = 0.3;\n/** Opacity steps particles are batched into: one fill per step per frame. */\nconst ALPHA_STEPS = 12;\n/** Ragged edge of the release wave: each particle leaves up to this late, s. */\nconst RELEASE_JITTER = 0.08;\n/** Each particle's own lifetime, from release to fully faded, s. */\nconst LIFE_MIN = 0.48;\nconst LIFE_SPREAD = 0.3;\nconst TAU = Math.PI * 2;\n\n/**\n * Every particle as parallel typed arrays (positions in device px, times in\n * seconds), plus the release order the ink layer is eaten in.\n */\ntype Swarm = {\n  count: number;\n  cell: number;\n  cellX: Float32Array;\n  cellY: Float32Array;\n  x: Float32Array;\n  y: Float32Array;\n  dx: Float32Array;\n  dy: Float32Array;\n  release: Float32Array;\n  life: Float32Array;\n  radius: Float32Array;\n  ink: Float32Array;\n  phase: Float32Array;\n  order: Uint32Array;\n  /** When the last particle has faded. */\n  duration: number;\n};\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst px = (value: string) => parseFloat(value) || 0;\n\n/**\n * Paints `text` onto `ctx` exactly over the input's own text: same font,\n * left padding and scroll, baseline centred the way a browser centres an\n * input's line box. Returns the horizontal band worth sampling, in CSS px.\n */\nfunction paintText(ctx: CanvasRenderingContext2D, input: HTMLInputElement, canvas: HTMLCanvasElement, text: string, dpr: number) {\n  const style = getComputedStyle(input);\n  const padLeft = px(style.paddingLeft) + px(style.borderLeftWidth);\n  const padRight = px(style.paddingRight) + px(style.borderRightWidth);\n  const padTop = px(style.paddingTop) + px(style.borderTopWidth);\n  const padBottom = px(style.paddingBottom) + px(style.borderBottomWidth);\n\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;\n  if (style.letterSpacing !== \"normal\") ctx.letterSpacing = style.letterSpacing;\n  ctx.fillStyle = style.color;\n  ctx.textBaseline = \"alphabetic\";\n\n  const metrics = ctx.measureText(text);\n  const width = metrics.width;\n  // Offsets, not client rects, so a scaled ancestor cannot skew the match.\n  const left = input.offsetLeft - canvas.offsetLeft + padLeft;\n  const room = input.offsetWidth - padLeft - padRight;\n  let x = left - input.scrollLeft;\n  if (width < room && style.textAlign === \"center\") x = left + (room - width) / 2;\n  else if (width < room && (style.textAlign === \"right\" || style.textAlign === \"end\")) x = left + room - width;\n\n  const centre = input.offsetTop - canvas.offsetTop + padTop + (input.offsetHeight - padTop - padBottom) / 2;\n  const ascent = metrics.fontBoundingBoxAscent || metrics.actualBoundingBoxAscent;\n  const descent = metrics.fontBoundingBoxDescent || metrics.actualBoundingBoxDescent;\n  ctx.fillText(text, x, centre + (ascent - descent) / 2);\n  ctx.setTransform(1, 0, 0, 1, 0, 0);\n\n  const size = px(style.fontSize);\n  return { from: x - size * 0.3, to: x + width + size * 0.3, size, color: style.color };\n}\n\n/**\n * Cuts the painted ink into square cells and keeps every cell with any ink\n * in it, as [left, top, centroid x, centroid y, coverage] runs.\n */\nfunction cutCells(data: Uint8ClampedArray, width: number, height: number, from: number, to: number, cell: number) {\n  const cells: number[] = [];\n  const x0 = clamp(Math.floor(from), 0, width);\n  const x1 = clamp(Math.ceil(to), 0, width);\n  for (let top = 0; top < height; top += cell) {\n    const bottom = Math.min(top + cell, height);\n    for (let left = x0; left < x1; left += cell) {\n      const right = Math.min(left + cell, x1);\n      let sum = 0;\n      let sx = 0;\n      let sy = 0;\n      for (let y = top; y < bottom; y++) {\n        for (let x = left; x < right; x++) {\n          const a = data[(y * width + x) * 4 + 3];\n          if (!a) continue;\n          sum += a;\n          sx += a * x;\n          sy += a * y;\n        }\n      }\n      if (sum) cells.push(left, top, sx / sum + 0.5, sy / sum + 0.5, sum / (cell * cell * 255));\n    }\n  }\n  return cells;\n}\n\n/**\n * Turns the ink into a swarm. Cell size adapts to the text's width so a long\n * value never exceeds MAX_PARTICLES. Release runs as a wave from the right\n * edge to the left, each particle a little late at random; once out, it\n * drifts up and to the left on its own heading, swells and fades.\n */\nfunction buildSwarm(\n  data: Uint8ClampedArray,\n  width: number,\n  height: number,\n  band: { from: number; to: number; size: number },\n  dpr: number,\n): Swarm | null {\n  const from = band.from * dpr;\n  const to = band.to * dpr;\n  const span = clamp(to - from, 0, width);\n  let cell = Math.max(1, Math.round(MIN_CELL * dpr));\n  const estimate = (span * band.size * dpr * INK_SHARE) / MAX_PARTICLES;\n  if (estimate > cell * cell) cell = Math.ceil(Math.sqrt(estimate));\n\n  let cells = cutCells(data, width, height, from, to, cell);\n  for (let tries = 0; cells.length / 5 > MAX_PARTICLES && tries < 6; tries++) {\n    cell = Math.ceil(cell * Math.sqrt(cells.length / 5 / MAX_PARTICLES));\n    cells = cutCells(data, width, height, from, to, cell);\n  }\n  const count = cells.length / 5;\n  if (!count) return null;\n\n  const f32 = () => new Float32Array(count);\n  const swarm: Swarm = {\n    count, cell,\n    cellX: f32(), cellY: f32(), x: f32(), y: f32(), dx: f32(), dy: f32(),\n    release: f32(), life: f32(), radius: f32(), ink: f32(), phase: f32(),\n    order: new Uint32Array(count),\n    duration: 0,\n  };\n\n  let minX = Infinity;\n  let maxX = -Infinity;\n  for (let i = 0; i < count; i++) {\n    minX = Math.min(minX, cells[i * 5 + 2]);\n    maxX = Math.max(maxX, cells[i * 5 + 2]);\n  }\n  const reach = Math.max(1, maxX - minX);\n  // Wider text sweeps for longer, but the whole run stays near a second.\n  const sweep = clamp(0.2 + reach / dpr / 900, 0.22, 0.55);\n\n  for (let i = 0; i < count; i++) {\n    const o = i * 5;\n    swarm.cellX[i] = cells[o];\n    swarm.cellY[i] = cells[o + 1];\n    swarm.x[i] = cells[o + 2];\n    swarm.y[i] = cells[o + 3];\n    swarm.ink[i] = Math.min(1, cells[o + 4] * 1.3);\n    // 0 at the right edge, 1 at the left: the right goes first.\n    const fromRight = (maxX - swarm.x[i]) / reach;\n    swarm.release[i] = sweep * fromRight + Math.random() * RELEASE_JITTER;\n    swarm.life[i] = LIFE_MIN + Math.random() * LIFE_SPREAD;\n    // The canvas is the field's own box, so drift is steered short of its\n    // left and top edges (never below a quarter of the intended distance).\n    const drift = (10 + Math.random() * 26) * dpr;\n    const rise = (2 + Math.random() * 11) * dpr;\n    swarm.dx[i] = -Math.max(drift * 0.25, Math.min(drift, swarm.x[i] * 0.9));\n    swarm.dy[i] = -Math.max(rise * 0.25, Math.min(rise, swarm.y[i] * 0.85));\n    swarm.radius[i] = cell * 0.5 * (0.85 + Math.random() * 0.45);\n    swarm.phase[i] = Math.random() * TAU;\n    swarm.order[i] = i;\n    swarm.duration = Math.max(swarm.duration, swarm.release[i] + swarm.life[i]);\n  }\n  swarm.order.sort((a, b) => swarm.release[a] - swarm.release[b]);\n  return swarm;\n}\n\n/**\n * Freezes `text` onto `canvas` over the input and plays it away. Each frame\n * is a pure function of elapsed time, so frame rate only changes smoothness.\n * The crisp text lives on an offscreen ink layer that particles are bitten\n * out of as they release, so the text stays sharp until it moves.\n *\n * Returns a stop function, or null when the effect cannot run here (no 2D\n * context, nothing visible, or the pixels could not be read).\n */\nfunction playDissipation(\n  canvas: HTMLCanvasElement,\n  input: HTMLInputElement,\n  text: string,\n  onDone: () => void,\n): (() => void) | null {\n  const ctx = canvas.getContext(\"2d\");\n  const layer = document.createElement(\"canvas\");\n  const inkCtx = layer.getContext(\"2d\");\n  if (!ctx || !inkCtx) return null;\n\n  const dpr = Math.min(window.devicePixelRatio || 1, 2);\n  const width = Math.round(canvas.offsetWidth * dpr);\n  const height = Math.round(canvas.offsetHeight * dpr);\n  if (!width || !height) return null;\n  canvas.width = layer.width = width;\n  canvas.height = layer.height = height;\n\n  let swarm: Swarm | null;\n  let color: string;\n  try {\n    const band = paintText(inkCtx, input, canvas, text, dpr);\n    color = band.color;\n    swarm = buildSwarm(inkCtx.getImageData(0, 0, width, height).data, width, height, band, dpr);\n  } catch {\n    return null;\n  }\n  if (!swarm) return null;\n  const { count, cell, order, release, life } = swarm;\n\n  // First frame now, before paint: the input's text is already hidden.\n  ctx.drawImage(layer, 0, 0);\n  ctx.fillStyle = color;\n\n  const start = performance.now();\n  let released = 0;\n  let frame = 0;\n\n  const draw = (t: number) => {\n    // Bite newly released cells out of the crisp layer, once each.\n    for (; released < count && release[order[released]] <= t; released++) {\n      const i = order[released];\n      inkCtx.clearRect(swarm.cellX[i], swarm.cellY[i], cell, cell);\n    }\n    ctx.globalAlpha = 1;\n    ctx.clearRect(0, 0, width, height);\n    ctx.drawImage(layer, 0, 0);\n\n    const batches: (Path2D | undefined)[] = [];\n    for (let k = 0; k < released; k++) {\n      const i = order[k];\n      const p = (t - release[i]) / life[i];\n      if (p >= 1) continue;\n      const left = 1 - p;\n      const step = Math.round(swarm.ink[i] * left * left * ALPHA_STEPS);\n      if (step <= 0) continue;\n      const travel = 1 - left * left * left;\n      const r = swarm.radius[i] * (1 + 0.6 * p);\n      const x = swarm.x[i] + swarm.dx[i] * travel;\n      const y = swarm.y[i] + swarm.dy[i] * travel + Math.sin(swarm.phase[i] + p * 7) * 1.2 * dpr * p;\n      const path = (batches[step] ??= new Path2D());\n      path.moveTo(x + r, y);\n      path.arc(x, y, r, 0, TAU);\n    }\n    for (let step = 1; step <= ALPHA_STEPS; step++) {\n      const path = batches[step];\n      if (!path) continue;\n      ctx.globalAlpha = step / ALPHA_STEPS;\n      ctx.fill(path);\n    }\n    ctx.globalAlpha = 1;\n  };\n\n  const tick = (now: number) => {\n    const t = Math.max(0, (now - start) / 1000);\n    if (t >= swarm.duration) {\n      ctx.clearRect(0, 0, width, height);\n      onDone();\n      return;\n    }\n    draw(t);\n    frame = requestAnimationFrame(tick);\n  };\n  frame = requestAnimationFrame(tick);\n\n  return () => {\n    cancelAnimationFrame(frame);\n    ctx.clearRect(0, 0, width, height);\n  };\n}\n\n/* ------------------------------------------------------------------------ */\n/* Component                                                                */\n/* ------------------------------------------------------------------------ */\n\n/**\n * An underlined single-value field whose text dissolves into drifting dust,\n * right to left, once it is submitted. Enter (or the round action button)\n * calls `onSubmit`; unless that returns `false` the text blows away and the\n * value clears for another go. For a login, set `dissipateOnEnter={false}`\n * and call `ref.dissipate()` only once the credentials are accepted.\n *\n * The particles are drawn from the input's own computed font and colour, so\n * the field follows the theme. Reduced motion skips the animation and just\n * clears.\n *\n * The idea of text dissolving into particles was popularised by Miguel\n * Piedrafita (m1guelpf); this implementation is written from scratch.\n */\nexport const DissolveInput = React.forwardRef<DissolveInputHandle, DissolveInputProps>(function DissolveInput(\n  {\n    value,\n    onChange,\n    onSubmit,\n    dissipateOnEnter = true,\n    onDissipated,\n    mask = false,\n    inputClassName,\n    submitLabel = \"Submit\",\n    className,\n    style,\n    type,\n    placeholder = \"Type and press Enter…\",\n    disabled = false,\n    onKeyDown,\n    \"aria-label\": ariaLabel,\n    \"aria-describedby\": ariaDescribedBy,\n    \"aria-invalid\": ariaInvalid,\n    ...inputProps\n  },\n  ref,\n) {\n  const reduceMotion = useReducedMotionConfig();\n  const errorId = React.useId();\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  // Same-tick guards: state lags a render behind a double Enter or click.\n  const submittingRef = React.useRef(false);\n  const runningRef = React.useRef(false);\n  /** Disabling the field drops its focus; give it back when the work is done. */\n  const refocusRef = React.useRef(false);\n\n  const [pending, setPending] = React.useState(false);\n  const [run, setRun] = React.useState<Run | null>(null);\n  const [failed, setFailed] = React.useState(false);\n\n  const busy = pending || run !== null;\n  const masked = mask || type === \"password\";\n\n  const liveValue = () => inputRef.current?.value || value;\n\n  const holdFocus = () => {\n    if (wrapperRef.current?.contains(document.activeElement)) refocusRef.current = true;\n  };\n\n  const dissipate = () => {\n    if (runningRef.current) return;\n    const text = liveValue();\n    if (!text) {\n      onDissipated?.();\n      return;\n    }\n    runningRef.current = true;\n    holdFocus();\n    setRun({ text: masked ? \"•\".repeat([...text].length) : text, animate: !reduceMotion });\n  };\n\n  const submit = async () => {\n    const input = inputRef.current;\n    if (!input || disabled || submittingRef.current || runningRef.current) return;\n    const text = liveValue();\n    if (!text || !input.checkValidity()) {\n      input.reportValidity();\n      return;\n    }\n    submittingRef.current = true;\n    holdFocus();\n    setFailed(false);\n    setPending(true);\n    try {\n      const result = await onSubmit?.(text);\n      if (result !== false) dissipate();\n    } catch {\n      setFailed(true);\n    } finally {\n      submittingRef.current = false;\n      setPending(false);\n    }\n  };\n\n  React.useImperativeHandle(ref, () => ({\n    dissipate,\n    focus: () => inputRef.current?.focus(),\n    getValue: liveValue,\n  }));\n\n  /** Restores the field and clears the value; only ever called from the run's frame loop. */\n  const finishRun = React.useEffectEvent(() => {\n    runningRef.current = false;\n    setRun(null);\n    onChange(\"\");\n    onDissipated?.();\n  });\n\n  React.useLayoutEffect(() => {\n    if (!run) return;\n    const canvas = canvasRef.current;\n    const input = inputRef.current;\n    const stop = run.animate && canvas && input ? playDissipation(canvas, input, run.text, () => finishRun()) : null;\n    if (stop) return stop;\n    // No animation here: finish on the next frame instead.\n    const frame = requestAnimationFrame(() => finishRun());\n    return () => cancelAnimationFrame(frame);\n  }, [run]);\n\n  React.useEffect(() => {\n    if (busy || !refocusRef.current) return;\n    refocusRef.current = false;\n    // Only reclaim focus that was dropped, never focus the caller moved on purpose.\n    if (document.activeElement && document.activeElement !== document.body) return;\n    inputRef.current?.focus();\n  }, [busy]);\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || !dissipateOnEnter || event.key !== \"Enter\") return;\n    if (event.nativeEvent.isComposing || event.keyCode === 229) return;\n    event.preventDefault();\n    void submit();\n  };\n\n  const describedBy = [ariaDescribedBy, failed ? errorId : null].filter(Boolean).join(\" \") || undefined;\n\n  return (\n    <div\n      ref={wrapperRef}\n      className={cn(\n        \"relative flex w-full items-center gap-2 border-b-2 border-primary pb-1 font-body\",\n        disabled && \"opacity-50\",\n        className,\n      )}\n    >\n      <div className=\"relative min-w-0 flex-1\">\n        <input\n          {...inputProps}\n          ref={inputRef}\n          type={type}\n          value={value}\n          placeholder={placeholder}\n          disabled={disabled || busy}\n          aria-label={ariaLabel ?? placeholder}\n          aria-busy={busy || undefined}\n          aria-invalid={failed ? true : ariaInvalid}\n          aria-describedby={describedBy}\n          onChange={(event) => {\n            onChange(event.target.value);\n            if (failed) setFailed(false);\n          }}\n          onKeyDown={handleKeyDown}\n          // Hidden, not removed, so the field keeps its size under the canvas.\n          style={run ? { ...style, opacity: 0 } : style}\n          className={cn(\n            \"min-h-11 w-full appearance-none border-0 bg-transparent py-1 text-2xl font-medium text-ink outline-none placeholder:text-faint focus-visible:ring-2 focus-visible:ring-lead/20\",\n            inputClassName,\n          )}\n        />\n        <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 size-full\" />\n      </div>\n      {dissipateOnEnter && value ? (\n        <button\n          type=\"button\"\n          onClick={() => void submit()}\n          disabled={disabled || busy}\n          aria-label={pending ? \"Submitting\" : run ? \"Processing\" : submitLabel}\n          aria-keyshortcuts=\"Enter\"\n          title={`${submitLabel} (Enter)`}\n          className={cn(\n            \"inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lead disabled:cursor-not-allowed\",\n            // A disabled field is already dimmed as a whole; only dim the button for busy.\n            !disabled && \"disabled:opacity-50\",\n          )}\n        >\n          <CornerDownLeft aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n      ) : null}\n      {failed ? (\n        <p id={errorId} role=\"alert\" className=\"absolute top-full left-0 mt-2 text-xs text-stop\">\n          {ERROR_MESSAGE}\n        </p>\n      ) : null}\n    </div>\n  );\n});\n\nDissolveInput.displayName = \"DissolveInput\";\n\nexport default DissolveInput;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "kind": "primitive",
    "whenToUse": "Use for a single-value submission or a success transition. Enter previews the effect; in a login form set dissipateOnEnter={false} and call ref.dissipate() only after authentication succeeds. Completion clears the controlled value, allowing another attempt.",
    "origin": {
      "project": "Ledge",
      "path": "src/components/dissipate-input.tsx; src/app/login/page.tsx"
    },
    "credits": []
  },
  "type": "registry:ui"
}
