← Forms

Inline Edit Field

Click a piece of text and it turns into an input right where it sits, no jump. An underline grows beneath it and a round save button spins in beside it, its tick drawing itself. Save and the button turns green, the text settles back with a soft highlight, and the button shrinks away.

Display nameJane Doe
Job titleProduct designer

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-inline-edit-field and an aria-label to the element holding the text.
  3. Enter or the tick saves, Escape cancels, and clicking away saves too. The input takes the text’s own font and grows as you type.

Options

Add any of these attributes to the same element.

data-placeholderShown when the text is empty. Default Add a name.
data-nameAdds a hidden input with this name holding the text, so it posts with a form.
data-colorUnderline and button colour. Default #6a0dad.
data-autoplayIt opens, types the next of data-words and saves, over and over while in view. It stops as soon as you use it.
data-wordsWhat autoplay types, separated by |.

The text is a button labelled with its value, so it’s reachable by keyboard, and focus returns to it after saving. Listen for the change event to store the new value: e.detail.value.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Inline Edit Field: click a piece of text and it turns into an input in
    // place. An underline grows beneath it and a round save button pops in
    // beside it; save and the tick draws itself, the text settles back with a
    // soft highlight and the button shrinks away.
    // Usage: <span data-inline-edit-field aria-label="Display name">Jane Doe</span>
    //   Enter or the tick saves, Escape cancels, clicking away saves.
    // Optional: data-name (adds a hidden input with this name holding the
    // text, so it posts with a form), data-placeholder (shown when empty,
    // default "Add a name"), data-color (underline and button colour, default
    // #6a0dad), data-autoplay (it edits itself to the next of data-words, over
    // and over while in view; it stops as soon as you use it), data-words
    // (what autoplay types, separated by "|").
    // Listen for the "change" event: e.detail.value is the saved text.
    var DEFAULTS = { color: '#6a0dad', placeholder: 'Add a name', words: 'Jane Doe|Jane Q. Doe|J. Doe|Jane' };

    var PENCIL = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zM13.5 6.5l4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
    var TICK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path class="ief-tick" d="M6 12.5l4 4 8-9" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" pathLength="1"/></svg>';

    var css =
      '.ief{position:relative;display:inline-flex;align-items:center;gap:.35em;max-width:calc(100% + .6em);margin:0 -.3em;vertical-align:middle}' +
      '.ief-view{position:relative;display:inline-flex;align-items:center;gap:.4em;max-width:100%;margin:0;padding:.12em .3em;border:0;border-radius:.35em;background:none;color:inherit;font:inherit;text-align:left;cursor:text;transition:background-color .2s}' +
      '.ief-view:hover,.ief-view:focus-visible,.ief-press .ief-view{background:color-mix(in srgb,var(--ief-color) 8%,transparent)}' +
      '.ief-view:focus-visible{outline:2px solid var(--ief-color);outline-offset:1px}' +
      '.ief-text{overflow:hidden;text-overflow:ellipsis;white-space:pre}' +
      '.ief-empty .ief-text{color:#8a8a93}' +
      '.ief-pen{display:inline-grid;width:.85em;height:.85em;color:#a1a1aa;opacity:.7;transition:opacity .2s,color .2s}' +
      '.ief-pen svg{width:100%;height:100%}' +
      '.ief-view:hover .ief-pen{opacity:1;color:var(--ief-color)}' +
      '.ief-edit{position:relative;display:none;max-width:100%;padding:.12em .3em}' +
      '.ief-editing .ief-view{display:none}' +
      '.ief-editing .ief-edit{display:inline-block}' +
      '.ief-input{display:inline-block;box-sizing:content-box;min-width:2em;max-width:100%;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;line-height:inherit;outline:none;white-space:pre;overflow:hidden;vertical-align:top}' +
      '.ief-input::placeholder{color:#8a8a93}' +
      '.ief-sel{background:color-mix(in srgb,var(--ief-color) 22%,transparent)}' +
      '.ief-caret{display:inline-block;width:1.5px;height:1em;margin-left:1px;vertical-align:-.1em;background:currentColor;animation:ief-blink 1s steps(1) infinite}' +
      '@keyframes ief-blink{50%{opacity:0}}' +
      '.ief-line{position:absolute;left:.3em;right:.3em;bottom:-.05em;height:2px;border-radius:2px;background:var(--ief-color);transform:scaleX(0);transform-origin:0 50%}' +
      '.ief-save{display:none;place-items:center;flex:none;width:1.5em;height:1.5em;margin:0;padding:0;border:0;border-radius:50%;background:var(--ief-color);color:#fff;cursor:pointer;transform:scale(0);transition:background-color .25s}' +
      '.ief-save svg{width:62%;height:62%}' +
      '.ief-tick{stroke-dasharray:1 1}' +
      '.ief-editing .ief-save,.ief-saving .ief-save{display:inline-grid}' +
      '.ief-saving .ief-save{background:#16a34a}' +
      '.ief-save:focus-visible{outline:2px solid var(--ief-color);outline-offset:2px}' +
      '.ief-sizer{position:absolute;left:0;top:0;visibility:hidden;white-space:pre;pointer-events:none}' +
      '.ief-flash{position:absolute;inset:0;border-radius:.35em;background:color-mix(in srgb,var(--ief-color) 18%,transparent);pointer-events:none;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var value = el.textContent.trim();
      var ph = d.placeholder || DEFAULTS.placeholder;
      var label = el.getAttribute('aria-label') || 'Edit';
      el.classList.add('ief');
      el.style.setProperty('--ief-color', d.color || DEFAULTS.color);
      el.removeAttribute('aria-label');
      el.textContent = '';

      var view = document.createElement(mock ? 'span' : 'button');
      view.className = 'ief-view';
      view.innerHTML = '<span class="ief-text"></span><span class="ief-pen">' + PENCIL + '</span><span class="ief-flash"></span>';
      var textEl = view.firstChild, flash = view.querySelector('.ief-flash');
      var edit = document.createElement('span');
      edit.className = 'ief-edit';
      var input;
      if (mock) {
        input = document.createElement('span');
        input.className = 'ief-input';
        input.innerHTML = '<span class="ief-val"></span><span class="ief-caret"></span>';
      } else {
        input = document.createElement('input');
        input.type = 'text';
        input.className = 'ief-input';
        input.placeholder = ph;
        input.setAttribute('aria-label', label);
        input.autocomplete = 'off';
      }
      var line = document.createElement('span');
      line.className = 'ief-line';
      var sizer = document.createElement('span');
      sizer.className = 'ief-sizer';
      sizer.setAttribute('aria-hidden', 'true');
      edit.appendChild(input);
      edit.appendChild(line);
      edit.appendChild(sizer);
      var save = document.createElement(mock ? 'span' : 'button');
      save.className = 'ief-save';
      save.innerHTML = TICK;
      if (!mock) { view.type = 'button'; save.type = 'button'; save.setAttribute('aria-label', 'Save'); }
      el.appendChild(view);
      el.appendChild(edit);
      el.appendChild(save);
      var tick = save.querySelector('.ief-tick');
      var valEl = mock ? input.querySelector('.ief-val') : null;
      var hidden = null;
      if (!mock && d.name) {
        hidden = document.createElement('input');
        hidden.type = 'hidden';
        hidden.name = d.name;
        el.appendChild(hidden);
      }

      function showValue() {
        textEl.textContent = value || ph;
        el.classList.toggle('ief-empty', !value);
        if (hidden) hidden.value = value;
        if (!mock) view.setAttribute('aria-label', label + ': ' + (value || 'empty') + '. Edit');
      }
      showValue();

      function getText() { return mock ? valEl.textContent : input.value; }
      function setText(v) { if (mock) valEl.textContent = v; else input.value = v; fitWidth(); }
      function fitWidth() {
        sizer.textContent = getText() || (mock ? '' : ph);
        if (!mock) input.style.width = Math.ceil(sizer.offsetWidth + 2) + 'px';
      }

      var editing = false;
      function open(focus) {
        if (editing) return;
        editing = true;
        el.classList.remove('ief-saving');
        el.classList.add('ief-editing');
        setText(value);
        if (!mock && focus) { input.focus(); input.select(); }
        if (reduced()) { anime.utils.set(line, { scaleX: 1 }); anime.utils.set(save, { scale: 1 }); anime.utils.set(tick, { strokeDashoffset: 0 }); return; }
        anime.animate(line, { scaleX: [0, 1], duration: 420, ease: 'out(4)' });
        anime.animate(save, { scale: [0, 1], rotate: [-90, 0], duration: 520, delay: 120, ease: 'outBack(1.8)' });
        anime.animate(tick, { strokeDashoffset: [1, 0], duration: 380, delay: 300, ease: 'out(2)' });
      }
      function close(commit) {
        if (!editing) return;
        editing = false;
        var next = getText().trim();
        var changed = commit && next !== value;
        if (commit) value = next;
        el.classList.remove('ief-editing');
        showValue();
        if (reduced()) { anime.utils.set(save, { scale: 0 }); }
        else if (commit) {
          // The button turns green, the tick redraws, then it all shrinks away.
          el.classList.add('ief-saving');
          anime.utils.set(save, { scale: 1, rotate: 0 });
          anime.animate(tick, { strokeDashoffset: [1, 0], duration: 360, ease: 'out(2)' });
          anime.animate(save, { scale: [1.22, 1], duration: 420, ease: 'outBack(2)' });
          anime.animate(save, { scale: 0, opacity: [1, 0], delay: 800, duration: 320, ease: 'in(2)', onComplete: function () {
            el.classList.remove('ief-saving');
            anime.utils.set(save, { opacity: 1 });
          } });
          anime.animate(flash, { opacity: [1, 0], duration: 1100, ease: 'out(2)' });
        } else {
          anime.utils.set(save, { scale: 0 });
        }
        if (changed && !mock) el.dispatchEvent(new CustomEvent('change', { detail: { value: value }, bubbles: true }));
      }

      if (!mock) {
        view.addEventListener('click', function () { stop(); open(true); });
        input.addEventListener('input', fitWidth);
        input.addEventListener('keydown', function (e) {
          if (e.key === 'Enter') { e.preventDefault(); close(true); view.focus(); }
          else if (e.key === 'Escape') { e.preventDefault(); close(false); view.focus(); }
        });
        // Clicking away saves; clicking the tick saves too (pointerdown keeps focus).
        save.addEventListener('pointerdown', function (e) { e.preventDefault(); });
        save.addEventListener('click', function () { stop(); close(true); view.focus(); });
        input.addEventListener('blur', function () { if (editing && !playing) close(true); });
      }

      // Autoplay: click, select all, type the next word, save.
      var timer = 0, stopped = false, playing = false, auto = on(d.autoplay) && !reduced();
      var words = (d.words || DEFAULTS.words).split('|'), wi = 0, start = value;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (playing) { playing = false; input.classList.remove('ief-sel'); if (editing) close(false); }
      }
      function later(fn, ms) { timer = setTimeout(function () { if (!stopped) fn(); }, ms); }
      function cycle() {
        var word = words[wi++ % words.length];
        if (word === value) word = words[wi++ % words.length];
        playing = true;
        el.classList.add('ief-press');
        later(function () {
          el.classList.remove('ief-press');
          open(false);
          input.classList.add('ief-sel');
          later(function () {
            input.classList.remove('ief-sel');
            setText('');
            var n = 0;
            (function type() {
              n++;
              setText(word.slice(0, n));
              if (n < word.length) later(type, 60 + Math.random() * 80);
              else later(function () { close(true); playing = false; later(cycle, 2200); }, 700);
            })();
          }, 650);
        }, 350);
      }
      if (auto) {
        if (!mock) {
          view.addEventListener('pointerdown', stop);
          input.addEventListener('pointerdown', stop);
        }
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (playing) { playing = false; input.classList.remove('ief-sel'); close(false); }
          if (entries[0].isIntersecting) {
            value = start; showValue();
            later(cycle, 900);
          }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-inline-edit-field]').forEach(function (el) {
        if (el._ief) return;
        el._ief = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h2><span data-inline-edit-field aria-label="Display name">Jane Doe</span></h2>

<script>
  document.querySelector('[data-inline-edit-field]').addEventListener('change', function (e) {
    console.log('Saved:', e.detail.value);
  });
</script>