{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"diff-text","type":"registry:ui","title":"Diff text","description":"Shows a change to a piece of text where it stands: removed words are struck through and close up, added words grow in with a passing tint, and then it settles. With `keep`, the marks stay for review.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/diff-text.tsx","type":"registry:ui","target":"components/ui/diff-text.tsx","content":"\"use client\";\nimport { useEffect, useState, type ComponentProps, type CSSProperties } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport { diffLines, type DiffOp } from \"./lib/diff-lines\";\nimport \"./diff-text.css\";\n\n/** Word-level changes from one text to another; spaces are their own tokens, so a change never eats its neighbours. */\nexport function diffWords(before: string, after: string): DiffOp[] {\n  const words = (text: string) => text.split(/(\\s+)/).filter(Boolean);\n  return diffLines(words(before), words(after));\n}\n\nexport type DiffTextProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  /** The text to show. When it changes, the change plays out in place. */\n  value: string;\n  /** Leaves removed words struck through and added words marked, for review, instead of settling to plain text. */\n  keep?: boolean;\n  /** The text to compare with when `keep` is on and nothing has changed yet. */\n  previous?: string;\n};\n\n/**\n * Shows a change to a piece of text where it stands: removed words are struck through and close up, added words grow\n * in with a brief tint, and then it settles to plain text. With `keep` the marks stay for review. For AI edits,\n * suggested rewrites and changelog lines. Under reduced motion the change is marked, then the text swaps.\n */\nexport function DiffText({ value, keep = false, previous, className, ...props }: DiffTextProps) {\n  const [frame, setFrame] = useState(() => ({ value, ops: previous !== undefined ? diffWords(previous, value) : null, run: 0 }));\n  if (frame.value !== value) setFrame({ value, ops: diffWords(frame.value, value), run: frame.run + 1 });\n  const [settled, setSettled] = useState(-1);\n\n  useEffect(() => {\n    if (keep || frame.run === 0) return;\n    const timer = window.setTimeout(() => setSettled(frame.run), 1500);\n    return () => window.clearTimeout(timer);\n  }, [frame.run, keep]);\n\n  const ops = frame.ops && (keep || settled !== frame.run) ? frame.ops : null;\n  let removed = 0,\n    added = 0;\n  return (\n    <span data-slot=\"diff-text\" data-keep={keep ? \"\" : undefined} className={cn(className)} {...props}>\n      {ops\n        ? ops.map((op, i) => {\n            if (op.type === \"eq\") return op.text;\n            const del = op.type === \"del\";\n            const order = del ? removed++ : added++;\n            const Tag = del ? \"del\" : \"ins\";\n            const start = keep && (ops[i - 1]?.type !== op.type);\n            return (\n              <Tag key={`${frame.run}-${i}`} data-slot={del ? \"dt-del\" : \"dt-ins\"} style={{ \"--dt-i\": order } as CSSProperties}>\n                {start && <span className=\"sr-only\">{del ? \"removed: \" : \"added: \"}</span>}\n                <span>{op.text}</span>\n              </Tag>\n            );\n          })\n        : frame.value}\n    </span>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/lib/diff-lines.ts","type":"registry:ui","target":"components/ui/lib/diff-lines.ts","content":"export type DiffOp = { type: \"eq\" | \"del\" | \"add\"; text: string; a?: number; b?: number };\n\n/** Line diff: trims what both sides share, then finds the longest common run of lines in the middle. */\nexport function diffLines(before: readonly string[], after: readonly string[]): DiffOp[] {\n  let start = 0;\n  while (start < before.length && start < after.length && before[start] === after[start]) start++;\n  let endA = before.length,\n    endB = after.length;\n  while (endA > start && endB > start && before[endA - 1] === after[endB - 1]) {\n    endA--;\n    endB--;\n  }\n  const a = before.slice(start, endA),\n    b = after.slice(start, endB);\n  const ops: DiffOp[] = [];\n  for (let i = 0; i < start; i++) ops.push({ type: \"eq\", text: before[i], a: i + 1, b: i + 1 });\n  const n = a.length,\n    m = b.length;\n  if (n * m > 4_000_000) {\n    a.forEach((text, i) => ops.push({ type: \"del\", text, a: start + i + 1 }));\n    b.forEach((text, j) => ops.push({ type: \"add\", text, b: start + j + 1 }));\n  } else {\n    const width = m + 1;\n    const table = new Uint32Array((n + 1) * width);\n    for (let i = n - 1; i >= 0; i--)\n      for (let j = m - 1; j >= 0; j--)\n        table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);\n    let i = 0,\n      j = 0;\n    while (i < n || j < m) {\n      if (i < n && j < m && a[i] === b[j]) {\n        ops.push({ type: \"eq\", text: a[i], a: start + i + 1, b: start + j + 1 });\n        i++;\n        j++;\n      } else if (i < n && (j === m || table[(i + 1) * width + j] >= table[i * width + j + 1])) {\n        ops.push({ type: \"del\", text: a[i], a: start + i + 1 });\n        i++;\n      } else {\n        ops.push({ type: \"add\", text: b[j], b: start + j + 1 });\n        j++;\n      }\n    }\n  }\n  for (let k = endA; k < before.length; k++) ops.push({ type: \"eq\", text: before[k], a: k + 1, b: endB + (k - endA) + 1 });\n  return ops;\n}\n\n"},{"path":"ui/diff-text.css","type":"registry:file","target":"components/ui/diff-text.css","content":"/* diff-text.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Diff text: removed words strike, then close up; added words open up with a passing tint. */\n  [data-slot=\"diff-text\"] :is([data-slot=\"dt-del\"], [data-slot=\"dt-ins\"]) { display: inline-grid; grid-template-columns: 1fr; text-decoration: none; vertical-align: bottom; }\n  [data-slot=\"diff-text\"] :is([data-slot=\"dt-del\"], [data-slot=\"dt-ins\"]) > span:not(.sr-only) { min-width: 0; overflow: hidden; white-space: pre; }\n  [data-slot=\"diff-text\"] [data-slot=\"dt-del\"] > span { color: var(--muted-foreground); text-decoration: line-through; text-decoration-color: var(--destructive); animation: dt-strike 0.3s var(--ease-out) calc(var(--dt-i) * 30ms) backwards; }\n  [data-slot=\"diff-text\"] [data-slot=\"dt-del\"] { animation: dt-out 0.45s var(--ease-in-out) calc(440ms + var(--dt-i) * 30ms) forwards; }\n  [data-slot=\"diff-text\"] [data-slot=\"dt-ins\"] { border-radius: 2px; animation: dt-in 0.5s var(--ease-out) calc(720ms + var(--dt-i) * 30ms) backwards, dt-tint 1.4s var(--ease-out) calc(720ms + var(--dt-i) * 30ms) backwards; }\n  [data-slot=\"diff-text\"][data-keep] [data-slot=\"dt-del\"] { animation: none; }\n  [data-slot=\"diff-text\"][data-keep] [data-slot=\"dt-del\"] + [data-slot=\"dt-ins\"] { margin-left: 0.3em; }\n  [data-slot=\"diff-text\"][data-keep] [data-slot=\"dt-ins\"] { background: color-mix(in oklab, var(--success) 18%, transparent); animation: dt-in 0.5s var(--ease-out) calc(300ms + var(--dt-i) * 30ms) backwards; }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"diff-text\"] :is([data-slot=\"dt-del\"], [data-slot=\"dt-ins\"]), [data-slot=\"diff-text\"] [data-slot=\"dt-del\"] > span { animation: none; }\n  }\n  @keyframes dt-strike { from { color: var(--foreground); text-decoration-color: transparent; } }\n  @keyframes dt-out { to { grid-template-columns: 0fr; opacity: 0; } }\n  @keyframes dt-in { from { grid-template-columns: 0fr; opacity: 0; } }\n  @keyframes dt-tint { 0%, 45% { background-color: color-mix(in oklab, var(--success) 22%, transparent); } 100% { background-color: transparent; } }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"text-motion","docs":"/docs/components/diff-text"}}