{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-edit","type":"registry:component","title":"Inline edit","description":"Rename a title, a project or a row where it stands: the text becomes the field without moving a pixel, and saves itself.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/inline-edit.tsx","type":"registry:component","content":"// Hairline UI · inline-edit · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * INLINE EDIT: rename it where it stands\n *\n *   rest      plain text in your own type; a hairline frame,\n *             sized by the text, fades in under the pointer\n *   edit      a click puts the caret exactly where you clicked\n *             (it's a real field under the text, so double-click\n *             selects a word); Tab or F2 selects it all. The text\n *             never moves: the frame just turns to the focus\n *             colour, and grows with what you type\n *   save      Enter or leaving the field. The new name stays, a\n *             spinner opens beside it and a check draws itself;\n *             if the server stores something else (\"relay-web\"),\n *             the text morphs into it\n *   refused   onSave throws: the field opens again with what you\n *             typed, shakes, turns red and says why under it\n *   cancel    Escape morphs the text back to the saved name\n *   elsewhere a new value from outside (someone renamed it)\n *             morphs in, unless you're in the middle of an edit\n *\n * Type comes from where you put it: the frame and its padding\n * are in em, so a 13px table cell and a 24px page title both\n * look right.\n * ───────────────────────────────────────────────────────── */\n\nexport interface InlineEditProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"defaultValue\" | \"onChange\"> {\n  /** The saved text, controlled. */\n  value?: string;\n  defaultValue?: string;\n  /** The saved text after a successful save. */\n  onValueChange?: (value: string) => void;\n  /** Saves a new value. Return what was stored (it morphs in if it differs); throw an Error to refuse with its message. */\n  onSave?: (value: string, previous: string) => Promise<string | void> | string | void;\n  /** Refuse a value before it's sent; the message opens under the field. */\n  validate?: (value: string) => string | null | undefined | void;\n  /** Applied before validating and saving. Collapses spaces and trims by default. */\n  normalize?: (value: string) => string;\n  /** Accessible name, e.g. \"Project name\". */\n  label: string;\n  placeholder?: string;\n  /** Fill the width and wrap onto more lines (titles). Off: one line that sizes to its text (cells, breadcrumbs). */\n  wrap?: boolean;\n  maxLength?: number;\n  /** What leaving the field does with an edit. */\n  blurAction?: \"save\" | \"cancel\";\n  /** An empty value cancels the edit unless allowed. */\n  allowEmpty?: boolean;\n  /** Submits the saved value with a form. */\n  name?: string;\n  disabled?: boolean;\n}\n\ntype Status = \"idle\" | \"pending\" | \"success\" | \"error\";\ntype Morph = { from: string; to: string; key: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst TIDY = (v: string) => v.replace(/\\s+/g, \" \").trim();\n/** Same box, same letters: the field and the text must lay out identically, so neither kerns nor ligates. */\nconst TYPE: React.CSSProperties = { fontKerning: \"none\", fontVariantLigatures: \"none\" };\n/** Frame padding in em, so it follows the type it's set in. */\nconst PAD_X = \"0.4em\";\nconst PAD_Y = \"0.12em\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 380ms ${EASE}, filter 240ms ${EASE}`,\n});\n\n/** One line of words that morphs from one text to the next: TextMorph, started from the text that was showing. */\nfunction MorphOnce({ from, to, onDone }: { from: string; to: string; onDone: () => void }) {\n  const [text, setText] = React.useState(from);\n  React.useEffect(() => {\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setText(to))));\n    const timer = window.setTimeout(onDone, 780);\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n      window.clearTimeout(timer);\n    };\n  }, [to, onDone]);\n  return <TextMorph className=\"whitespace-pre\">{text}</TextMorph>;\n}\n\n/** Wrapped text changes as a whole: the old lines lift out through a blur while the new ones rise in. */\nfunction SwapOnce({ from, to, onDone }: { from: string; to: string; onDone: () => void }) {\n  const inRef = React.useRef<HTMLSpanElement>(null);\n  const outRef = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    inRef.current?.animate(\n      [\n        { opacity: 0, transform: \"translateY(0.3em)\", filter: \"blur(4px)\" },\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n      ],\n      { duration: 420, delay: 60, easing: EASE, fill: \"backwards\" },\n    );\n    outRef.current?.animate(\n      [\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        { opacity: 0, transform: \"translateY(-0.3em)\", filter: \"blur(4px)\" },\n      ],\n      { duration: 220, easing: EASE, fill: \"forwards\" },\n    );\n    const timer = window.setTimeout(onDone, 560);\n    return () => window.clearTimeout(timer);\n  }, [onDone]);\n  return (\n    <span className=\"relative block min-w-0\">\n      <span ref={inRef} className=\"block\">\n        {to || \" \"}\n      </span>\n      <span ref={outRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0 block\">\n        {from}\n      </span>\n    </span>\n  );\n}\n\n/** A sentence under the field that crossfades whole when it changes, and folds away when there's nothing to say. */\nfunction Line({ children, show, id, tone, wide, reduced }: { children: React.ReactNode; show: boolean; id: string; tone: string; wide: boolean; reduced: boolean }) {\n  // While it folds away it keeps saying what it said, instead of emptying first.\n  const kept = React.useRef(children);\n  if (show) kept.current = children;\n  return (\n    // Under one-line text the message may run past the field's width without widening it (or wrapping what's beside it).\n    <span className={`grid ${wide ? \"[contain:inline-size]\" : \"\"}`} style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE}` }}>\n      <span className={`min-h-0 min-w-0 ${wide ? \"[clip-path:inset(0_-100vw_0_0)]\" : \"overflow-hidden\"}`}>\n        <span\n          id={id}\n          className={`block pt-1.5 text-[12.5px] leading-[1.45] font-normal tracking-normal normal-case transition-[color,opacity,filter] duration-300 ${wide ? \"w-max max-w-[min(24rem,calc(100vw_-_3rem))]\" : \"\"} ${tone}`}\n          style={{ opacity: show ? 1 : 0, filter: show ? \"none\" : \"blur(3px)\" }}\n        >\n          {kept.current}\n        </span>\n      </span>\n    </span>\n  );\n}\n\nexport function InlineEdit({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onSave,\n  validate,\n  normalize = TIDY,\n  label,\n  placeholder = \"Untitled\",\n  wrap = false,\n  maxLength,\n  blurAction = \"save\",\n  allowEmpty = false,\n  name,\n  disabled = false,\n  className = \"\",\n  ...props\n}: InlineEditProps) {\n  const uid = React.useId();\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue);\n  const saved = value ?? own;\n  const [editing, setEditing] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  // Edits started with the keyboard keep a focus frame after saving; a mouse user sees the text settle.\n  const [viaKeys, setViaKeys] = React.useState(false);\n  const [draft, setDraft] = React.useState(saved);\n  const [optimistic, setOptimistic] = React.useState<string | null>(null);\n  const [status, setStatus] = React.useState<Status>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n  const [morph, setMorph] = React.useState<Morph | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const fieldRef = React.useRef<HTMLTextAreaElement>(null);\n  const pointerFocus = React.useRef(false);\n  const selectAll = React.useRef(false);\n  const saveId = React.useRef(0);\n  const morphKey = React.useRef(0);\n  const lastHeight = React.useRef(0);\n\n  // What the text says when it isn't being edited: the name on its way to the server, or the saved one.\n  const shown = optimistic ?? saved;\n  const shownRef = React.useRef(shown);\n  const editingRef = React.useRef(editing);\n\n  const startMorph = React.useCallback(\n    (from: string, to: string) => {\n      if (from === to || reduced) return;\n      // A morph that's still running is re-aimed rather than restarted, so it never jumps.\n      setMorph((m) => (m ? { ...m, to } : { from, to, key: ++morphKey.current }));\n    },\n    [reduced],\n  );\n  const morphDone = React.useCallback(() => setMorph(null), []);\n\n  // A new value from outside (someone else renamed it) morphs in, unless you're editing.\n  const [seen, setSeen] = React.useState(value);\n  if (value !== seen) {\n    setSeen(value);\n    if (value !== undefined && !editing && optimistic === null && value !== shownRef.current) startMorph(shownRef.current, value);\n  }\n  React.useLayoutEffect(() => {\n    shownRef.current = morph ? morph.to : shown;\n    editingRef.current = editing;\n  });\n\n  const shake = () => {\n    if (reduced) return;\n    rootRef.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  };\n\n  const begin = (select: boolean, keys = select) => {\n    if (disabled) return;\n    if (!editing) {\n      setDraft(shown);\n      setEditing(true);\n    }\n    if (status === \"success\") setStatus(\"idle\");\n    selectAll.current = select;\n    setViaKeys(keys);\n  };\n\n  // Keyboard entry selects everything (like renaming a file); a click leaves the caret where it landed.\n  React.useLayoutEffect(() => {\n    if (!editing || !selectAll.current) return;\n    selectAll.current = false;\n    fieldRef.current?.select();\n  }, [editing]);\n\n  const cancel = () => {\n    if (draft !== saved) startMorph(draft, saved);\n    setEditing(false);\n    setError(null);\n    if (status === \"error\") setStatus(\"idle\");\n  };\n\n  const settle = (result: string, sent: string, id: number) => {\n    if (id !== saveId.current) return;\n    setOptimistic(null);\n    if (result !== sent) startMorph(sent, result);\n    if (value === undefined) setOwn(result);\n    onValueChange?.(result);\n    setStatus(\"success\");\n    setAnnounce(result === sent ? \"Saved\" : `Saved as ${result}`);\n  };\n\n  const refuse = (message: string, sent: string, id: number) => {\n    if (id !== saveId.current) return;\n    setOptimistic(null);\n    // Already typing something newer: keep it, just say why the last one failed.\n    if (!editingRef.current) setDraft(sent);\n    setEditing(true);\n    setStatus(\"error\");\n    setError(message);\n    setAnnounce(`Not saved. ${message}`);\n    shake();\n  };\n\n  const commit = () => {\n    const next = normalize(draft);\n    if (next === normalize(saved) || (!next && !allowEmpty)) {\n      // Nothing to save (or an emptied name): the text settles back to what's saved.\n      cancel();\n      return;\n    }\n    const problem = validate?.(next);\n    if (problem) {\n      setError(problem);\n      setStatus(\"error\");\n      setAnnounce(problem);\n      shake();\n      return;\n    }\n    const id = ++saveId.current;\n    setEditing(false);\n    setError(null);\n    if (draft !== next) startMorph(draft, next);\n    setOptimistic(next);\n    let result: Promise<string | void> | string | void;\n    try {\n      result = onSave?.(next, saved);\n    } catch (err) {\n      refuse(err instanceof Error ? err.message : \"Couldn't save that.\", next, id);\n      return;\n    }\n    if (result && typeof (result as Promise<string | void>).then === \"function\") {\n      setStatus(\"pending\");\n      (result as Promise<string | void>).then(\n        (stored) => settle(typeof stored === \"string\" ? stored : next, next, id),\n        (err) => refuse(err instanceof Error ? err.message : \"Couldn't save that.\", next, id),\n      );\n    } else settle(typeof result === \"string\" ? result : next, next, id);\n  };\n\n  // The check stays a moment, then the slot closes.\n  React.useEffect(() => {\n    if (status !== \"success\") return;\n    const timer = window.setTimeout(() => setStatus(\"idle\"), 1400);\n    return () => window.clearTimeout(timer);\n  }, [status]);\n\n  // Wrapped text that changes line count eases to its new height (typing follows the text at once).\n  React.useLayoutEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const h = el.offsetHeight;\n    const prev = lastHeight.current;\n    lastHeight.current = h;\n    if (wrap && !editing && !reduced && prev && Math.abs(prev - h) > 0.5)\n      el.animate([{ height: `${prev}px` }, { height: `${h}px` }], { duration: 420, easing: EASE });\n  });\n\n  const left = maxLength !== undefined ? maxLength - draft.length : Infinity;\n  const counting = editing && left <= 10;\n  const lineText = error ?? (counting ? \"count\" : wrap && (status === \"pending\" || status === \"success\") ? status : null);\n  const tone = error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\";\n  const framed = editing || (focused && viaKeys && !disabled);\n  const shell: React.CSSProperties = {\n    opacity: framed || status === \"error\" ? 1 : undefined,\n    background: status === \"error\" && error ? \"color-mix(in oklab, #ef4444 7%, var(--background))\" : editing ? \"var(--background)\" : \"transparent\",\n    boxShadow:\n      status === \"error\" && error\n        ? \"inset 0 0 0 1px color-mix(in oklab, #ef4444 45%, transparent)\"\n        : editing\n          ? \"inset 0 0 0 1px color-mix(in oklab, var(--ring) 55%, var(--border))\"\n          : focused && viaKeys\n            ? \"inset 0 0 0 1px color-mix(in oklab, var(--ring) 40%, var(--border))\"\n            : \"inset 0 0 0 1px var(--border)\",\n    transition: reduced ? \"none\" : `opacity 160ms ${EASE}, box-shadow 200ms ${EASE}, background-color 200ms ${EASE}`,\n  };\n  const pad: React.CSSProperties = { padding: `${PAD_Y} calc(${PAD_X} + 2px) ${PAD_Y} ${PAD_X}` };\n  const flow = wrap ? \"whitespace-pre-wrap [overflow-wrap:break-word]\" : \"whitespace-pre\";\n\n  let display: React.ReactNode;\n  if (morph) display = wrap ? <SwapOnce key={morph.key} from={morph.from} to={morph.to} onDone={morphDone} /> : <MorphOnce key={morph.key} from={morph.from} to={morph.to} onDone={morphDone} />;\n  else display = shown || <span className=\"text-muted-foreground\">{placeholder}</span>;\n\n  return (\n    // Spans throughout: it goes inside headings, paragraphs and table cells.\n    <span className={`${wrap ? \"flex w-full flex-col\" : \"inline-flex max-w-full flex-col align-baseline\"} ${className}`} {...props}>\n      <span className={`flex min-w-0 items-center ${wrap ? \"w-full\" : \"\"}`}>\n        <span\n          ref={rootRef}\n          className={`group relative flex min-w-0 rounded-[0.4em] ${wrap ? \"w-full\" : \"\"} ${disabled ? \"\" : \"cursor-text\"}`}\n          style={{ ...TYPE, ...pad, margin: `-${PAD_Y} -${PAD_X}`, overflow: \"clip\" }}\n        >\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 ${disabled ? \"\" : \"group-hover:opacity-100\"}`}\n            style={shell}\n          />\n          {editing ? (\n            <span aria-hidden=\"true\" className={`invisible relative min-w-[2ch] ${wrap ? \"w-full\" : \"overflow-hidden\"} ${flow}`}>\n              {draft || placeholder || \"​\"}\n            </span>\n          ) : (\n            <span aria-hidden=\"true\" className={`relative min-w-0 ${wrap ? `w-full ${flow}` : morph ? \"whitespace-pre\" : \"overflow-hidden text-ellipsis whitespace-pre\"}`}>\n              {display}\n            </span>\n          )}\n          <textarea\n            ref={fieldRef}\n            rows={1}\n            wrap={wrap ? \"soft\" : \"off\"}\n            value={editing ? draft : shown}\n            disabled={disabled}\n            maxLength={maxLength}\n            placeholder={editing ? placeholder : undefined}\n            aria-label={label}\n            aria-invalid={Boolean(error) || undefined}\n            aria-describedby={lineText ? `${uid}-line` : undefined}\n            spellCheck={editing}\n            autoComplete=\"off\"\n            onPointerDown={() => {\n              pointerFocus.current = true;\n            }}\n            onFocus={() => {\n              setFocused(true);\n              begin(!pointerFocus.current);\n              pointerFocus.current = false;\n            }}\n            onBlur={() => {\n              setFocused(false);\n              if (!editing) return;\n              if (blurAction === \"save\") commit();\n              else cancel();\n            }}\n            onChange={(e) => {\n              // A title is one line: pasted line breaks become spaces.\n              const text = e.target.value.replace(/\\s*\\r?\\n\\s*/g, \" \");\n              if (!editing) begin(false, true);\n              setDraft(text);\n              if (error) {\n                setError(null);\n                setStatus(\"idle\");\n              }\n            }}\n            onKeyDown={(e) => {\n              if (e.nativeEvent.isComposing) return;\n              if (e.key === \"Enter\" || (e.key === \"F2\" && !editing)) {\n                e.preventDefault();\n                if (editing) commit();\n                else begin(true);\n              } else if (e.key === \"Escape\" && editing) {\n                e.preventDefault();\n                e.stopPropagation();\n                cancel();\n              }\n            }}\n            className={`absolute inset-0 m-0 block size-full resize-none overflow-hidden border-0 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-default ${flow}`}\n            style={{\n              ...TYPE,\n              ...pad,\n              font: \"inherit\",\n              letterSpacing: \"inherit\",\n              wordSpacing: \"inherit\",\n              textAlign: \"inherit\",\n              textTransform: \"inherit\",\n              color: editing ? \"inherit\" : \"transparent\",\n              caretColor: editing ? undefined : \"transparent\",\n            }}\n          />\n        </span>\n\n        {/* Spinner, drawn check or alert beside one-line text; wrapped text says it in the line below. */}\n        {!wrap && (\n          <span\n            aria-hidden=\"true\"\n            className=\"relative flex h-[1em] shrink-0 items-center justify-center\"\n            style={{\n              width: status === \"idle\" ? 0 : 14,\n              marginLeft: status === \"idle\" ? 0 : 8,\n              transition: reduced ? \"none\" : `width 380ms ${EASE}, margin 380ms ${EASE}`,\n            }}\n          >\n            <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(status === \"pending\", reduced)}>\n              <span className={`size-3 rounded-full border-[1.5px] border-muted-foreground border-t-transparent motion-reduce:animate-none ${status === \"pending\" ? \"animate-spin\" : \"\"}`} />\n            </span>\n            <span className=\"absolute inset-0 flex items-center justify-center text-emerald-600 dark:text-emerald-400\" style={swap(status === \"success\", reduced)}>\n              <Check drawn={status === \"success\"} reduced={reduced} />\n            </span>\n            <span className=\"absolute inset-0 flex items-center justify-center text-red-600 dark:text-red-400\" style={swap(status === \"error\", reduced)}>\n              <Alert />\n            </span>\n          </span>\n        )}\n      </span>\n\n      <Line id={`${uid}-line`} show={lineText !== null} tone={tone} wide={!wrap} reduced={reduced}>\n        {error ? (\n          error\n        ) : counting ? (\n          <span className=\"inline-flex items-baseline tabular-nums\">\n            <NumberRoll value={Math.max(0, left)} duration={600} />\n            <TextMorph className=\"ml-1\">{left === 1 ? \"character left\" : \"characters left\"}</TextMorph>\n          </span>\n        ) : (\n          <span className=\"inline-flex items-center gap-1.5\">\n            <span className=\"relative flex size-3.5 items-center justify-center\">\n              <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(status === \"pending\", reduced)}>\n                <span className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none ${status === \"pending\" ? \"animate-spin\" : \"\"}`} />\n              </span>\n              <span className=\"absolute inset-0 flex items-center justify-center text-emerald-600 dark:text-emerald-400\" style={swap(status === \"success\", reduced)}>\n                <Check drawn={status === \"success\"} reduced={reduced} />\n              </span>\n            </span>\n            <TextMorph>{status === \"success\" ? \"Saved\" : \"Saving\"}</TextMorph>\n          </span>\n        )}\n      </Line>\n\n      {name && <input type=\"hidden\" name={name} value={saved} />}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </span>\n  );\n}\n\nfunction Check({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${EASE} 120ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nfunction Alert() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n      <circle cx=\"8\" cy=\"11\" r=\"1\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/inline-edit. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}