{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"inline-edit","type":"registry:component","title":"Inline edit","description":"Edits your name and bio in place without moving a pixel.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/inline-edit.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Long enough to notice after the field settles, short enough that the\n// pencil is back before the next edit.\nconst SAVED_FOR = 1600;\n\nexport function InlineEdit({\n  label,\n  value,\n  onSave,\n  placeholder,\n  multiline = false,\n  className,\n}: {\n  label: string;\n  value: string;\n  onSave: (value: string) => void;\n  placeholder: string;\n  multiline?: boolean;\n  // Typography goes here; the display and the editor share it exactly.\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [editing, setEditing] = useState(false);\n  const [draft, setDraft] = useState(value);\n  const [saved, setSaved] = useState(false);\n  const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const savedTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  // Keyboard exits hand focus back to the text; a click elsewhere keeps\n  // focus wherever the click put it.\n  const refocus = useRef(false);\n\n  useEffect(() => () => clearTimeout(savedTimer.current), []);\n\n  useEffect(() => {\n    if (editing) {\n      const field = fieldRef.current;\n      field?.focus();\n      // Caret at the end, where most edits start.\n      field?.setSelectionRange(field.value.length, field.value.length);\n    } else if (refocus.current) {\n      refocus.current = false;\n      buttonRef.current?.focus();\n    }\n  }, [editing]);\n\n  const start = () => {\n    setDraft(value);\n    setEditing(true);\n  };\n\n  const finish = (commit: boolean, keyboard: boolean) => {\n    if (!editing) return;\n    refocus.current = keyboard;\n    setEditing(false);\n    const next = multiline ? draft.trim() : draft.replace(/\\s+/g, \" \").trim();\n    if (!commit || next === value) return;\n    onSave(next);\n    setSaved(true);\n    clearTimeout(savedTimer.current);\n    savedTimer.current = setTimeout(() => setSaved(false), SAVED_FOR);\n  };\n\n  // Identical box, padding, font and line-height for the display and the\n  // editor, so the swap doesn't move a single glyph.\n  const box = cn(\n    \"col-start-1 row-start-1 block w-full rounded-lg py-1 pr-9 pl-2 text-left\",\n    multiline ? \"whitespace-pre-wrap break-words\" : \"truncate whitespace-pre\",\n    className,\n  );\n  const empty = value.trim() === \"\";\n\n  return (\n    // Pulled out by its own padding, so the text lines up with the\n    // surrounding content while the tint still has room to breathe.\n    <div className=\"group relative -mx-2 grid\">\n      <button\n        ref={buttonRef}\n        type=\"button\"\n        onClick={start}\n        // Hidden while editing, but still sizing the cell, so the editor\n        // inherits its exact width and starting height.\n        className={cn(\n          box,\n          \"cursor-text outline-hidden transition-[background-color] duration-150 ease-out\",\n          \"hover:bg-foreground/[0.04] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n          editing && \"invisible\",\n          empty && \"text-muted-foreground\",\n        )}\n      >\n        <span className=\"sr-only\">Edit {label}: </span>\n        {/* While editing, it mirrors the draft so a textarea grows with it.\n            The trailing space keeps a new empty line from collapsing. */}\n        {editing ? `${draft} ` : empty ? placeholder : value}\n      </button>\n\n      {editing &&\n        (multiline ? (\n          <textarea\n            ref={fieldRef}\n            aria-label={label}\n            value={draft}\n            placeholder={placeholder}\n            rows={1}\n            onChange={(e) => setDraft(e.target.value)}\n            onBlur={() => finish(true, false)}\n            onKeyDown={(e) => {\n              // Shift+Enter still adds a line break.\n              if (e.key === \"Enter\" && !e.shiftKey) {\n                e.preventDefault();\n                finish(true, true);\n              } else if (e.key === \"Escape\") {\n                e.preventDefault();\n                finish(false, true);\n              }\n            }}\n            className={cn(\n              box,\n              \"h-full resize-none overflow-hidden bg-background text-foreground ring-[1.5px] ring-foreground/25 outline-hidden placeholder:text-muted-foreground\",\n            )}\n          />\n        ) : (\n          <input\n            ref={fieldRef}\n            aria-label={label}\n            value={draft}\n            placeholder={placeholder}\n            onChange={(e) => setDraft(e.target.value)}\n            onBlur={() => finish(true, false)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.preventDefault();\n                finish(true, true);\n              } else if (e.key === \"Escape\") {\n                e.preventDefault();\n                finish(false, true);\n              }\n            }}\n            className={cn(\n              box,\n              \"h-full bg-background text-foreground ring-[1.5px] ring-foreground/25 outline-hidden placeholder:text-muted-foreground\",\n            )}\n          />\n        ))}\n\n      {/* Pencil on hover or focus, a check right after saving. Pinned to the\n          first line's height so it stays put as the text grows. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute top-1 right-1.5 grid size-6 place-items-center text-muted-foreground\",\n          editing && \"invisible\",\n        )}\n      >\n        <SwapIcon\n          visible={!saved}\n          reduceMotion={reduceMotion}\n          className=\"opacity-0 transition-opacity duration-150 ease-out group-hover:opacity-100 group-has-[:focus-visible]:opacity-100\"\n        >\n          <path d=\"M9.75 3.75 12.25 6.25M3.25 12.75l.6-2.6 6.9-6.9a1.25 1.25 0 0 1 1.77 0l.73.73a1.25 1.25 0 0 1 0 1.77l-6.9 6.9Z\" />\n        </SwapIcon>\n        <SwapIcon visible={saved} reduceMotion={reduceMotion} className=\"text-foreground\">\n          <path d=\"m3.5 8.5 3 3 6-7\" />\n        </SwapIcon>\n      </span>\n    </div>\n  );\n}\n\nfunction SwapIcon({\n  visible,\n  reduceMotion,\n  className,\n  children,\n}: {\n  visible: boolean;\n  reduceMotion: boolean | null;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const hidden = reduceMotion\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  // The motion layer owns the swap; the wrapper owns the hover fade, so\n  // the two never fight over one opacity.\n  return (\n    <span className={cn(\"col-start-1 row-start-1\", className)}>\n      <motion.svg\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        className=\"size-4\"\n        initial={false}\n        animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n        transition={ICON_SWAP}\n      >\n        {children}\n      </motion.svg>\n    </span>\n  );\n}\n\nexport default function InlineEditDemo() {\n  const [name, setName] = useState(\"Ava Moreno\");\n  const [bio, setBio] = useState(\n    \"Design engineer. Builds the small interactions nobody notices until they are missing.\",\n  );\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const [showSaved, setShowSaved] = useState(false);\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const saved = (apply: () => void) => {\n    apply();\n    setShowSaved(true);\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setShowSaved(false), SAVED_FOR);\n  };\n\n  return (\n    <section\n      aria-label=\"Profile\"\n      className=\"w-[400px] max-w-full rounded-[24px] bg-background p-5 shadow-raised\"\n    >\n      <div className=\"mb-4 flex items-center justify-between\">\n        {/* eslint-disable-next-line @next/next/no-img-element -- a tiny local SVG gains nothing from next/image. */}\n        <img\n          src=\"/avatars/ava.svg\"\n          alt=\"\"\n          className=\"size-14 rounded-full bg-[oklch(0.97_0_0)] outline-1 -outline-offset-1 outline-[oklch(0_0_0/0.1)] dark:outline-[oklch(1_0_0/0.1)]\"\n        />\n        <span\n          aria-hidden\n          className={cn(\n            \"flex items-center gap-1.5 text-xs text-muted-foreground transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n            showSaved\n              ? \"translate-y-0 opacity-100 blur-[0px] duration-200\"\n              : \"translate-y-0.5 opacity-0 blur-[4px] duration-150 motion-reduce:translate-y-0\",\n          )}\n        >\n          <svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"size-3.5\"\n          >\n            <path d=\"m3.5 8.5 3 3 6-7\" />\n          </svg>\n          Saved\n        </span>\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {showSaved ? \"Saved\" : \"\"}\n        </span>\n      </div>\n      <InlineEdit\n        label=\"Name\"\n        value={name}\n        placeholder=\"Add your name\"\n        onSave={(v) => saved(() => setName(v))}\n        className=\"text-lg leading-7 font-semibold text-foreground\"\n      />\n      <p className=\"mb-2 text-sm text-muted-foreground\">@ava · Lisbon</p>\n      <InlineEdit\n        label=\"Bio\"\n        value={bio}\n        placeholder=\"Add a short bio\"\n        multiline\n        onSave={(v) => saved(() => setBio(v))}\n        className=\"text-[15px] leading-6 text-foreground\"\n      />\n    </section>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/inline-edit"}