← Forms

Auto-growing Textarea

A text box that starts one line tall and grows smoothly as you add lines, easing to its new height rather than jumping or showing a scrollbar. Delete lines and it shrinks back. Past a maximum it stops growing and scrolls instead.

Press Enter for a new line.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a textarea in an element with data-auto-growing-textarea.
  3. Set the smallest size with the textarea’s rows attribute and style it as you like.

Options

Add any of these attributes to the same element.

data-max-rowsThe most lines it grows to before scrolling. Default 8.
data-durationHow long each change of height takes. Default 220 ms.
data-autoplayIt types a short message line by line and deletes it while in view, and stops as soon as you use the field.
data-textWhat autoplay types. A | starts a new line.

It measures with a hidden copy of the textarea, so it copes with wrapping, pasting and resizing the window.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Auto-growing Textarea: the text box grows smoothly as you add lines and
    // shrinks back as you remove them, instead of growing a scrollbar. Past
    // the maximum it stops growing and scrolls.
    // Usage: <div data-auto-growing-textarea><textarea rows="1"></textarea></div>
    //   Style the textarea however you like; its rows attribute is the
    //   smallest it gets.
    // Optional: data-max-rows (the tallest it grows before scrolling, default
    // 8), data-duration (ms, default 220), data-autoplay (it types a short
    // message line by line and clears it, over and over while in view; it
    // stops as soon as you use the field), data-text (what autoplay types;
    // "|" starts a new line).
    var DEFAULTS = { maxRows: 8, duration: 220, text: 'Hi Sam,|Lunch on Friday?|There’s a new place by the river.|See you there!' };

    var css =
      '.agt textarea{resize:none;overflow-y:hidden;box-sizing:border-box}' +
      '.agt-scroll textarea{overflow-y:auto}' +
      '.agt-mirror{position:absolute!important;visibility:hidden!important;pointer-events:none;left:0;top:0;height:auto!important;min-height:0!important;max-height:none!important;overflow:hidden!important;z-index:-1}' +
      '.agt [data-field]{box-sizing:border-box;overflow:hidden}' +
      '.agt-inner{display:block;white-space:pre-wrap;word-wrap:break-word}' +
      '.agt-caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;vertical-align:-.15em;background:currentColor;animation:agt-blink 1s steps(1) infinite}' +
      '@keyframes agt-blink{50%{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 ta = el.querySelector('textarea');
      var field = ta || el.querySelector('[data-field]');
      if (!field) return;
      var dur = parseFloat(d.duration) || DEFAULTS.duration;
      var maxRows = parseInt(d.maxRows, 10) || DEFAULTS.maxRows;
      el.classList.add('agt');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';

      var mirror = null, inner = null, caret = null;
      if (ta) {
        // A hidden twin with height:auto tells us the height the text needs.
        mirror = ta.cloneNode(false);
        mirror.removeAttribute('id');
        mirror.removeAttribute('name');
        mirror.setAttribute('aria-hidden', 'true');
        mirror.tabIndex = -1;
        mirror.className = (ta.className + ' agt-mirror').trim();
        el.appendChild(mirror);
      } else {
        inner = document.createElement('span');
        inner.className = 'agt-inner';
        var txt = document.createElement('span');
        caret = document.createElement('span');
        caret.className = 'agt-caret';
        inner.appendChild(txt);
        inner.appendChild(caret);
        field.textContent = '';
        field.appendChild(inner);
        inner = txt;
      }

      function px(v) { return parseFloat(v) || 0; }
      function needed() {
        var cs = getComputedStyle(field);
        var lh = px(cs.lineHeight) || px(cs.fontSize) * 1.4;
        var chrome = px(cs.paddingTop) + px(cs.paddingBottom) + px(cs.borderTopWidth) + px(cs.borderBottomWidth);
        var rows = ta ? (parseInt(ta.getAttribute('rows'), 10) || 1) : 1;
        var h;
        if (ta) {
          mirror.style.width = ta.offsetWidth + 'px';
          mirror.value = ta.value || ' ';
          mirror.rows = rows;
          h = mirror.scrollHeight + px(cs.borderTopWidth) + px(cs.borderBottomWidth);
        } else {
          h = inner.parentNode.offsetHeight + chrome;
        }
        var min = rows * lh + chrome, max = maxRows * lh + chrome;
        el.classList.toggle('agt-scroll', h > max + 1);
        return Math.round(Math.min(max, Math.max(min, h)));
      }

      var target = 0, anim = null;
      function fit(instant) {
        var h = needed();
        if (h === target) return;
        target = h;
        if (anim) anim.pause();
        if (instant || reduced()) { field.style.height = h + 'px'; return; }
        anim = anime.animate(field, { height: h, duration: dur, ease: 'out(3)' });
      }
      fit(true);
      if (window.ResizeObserver) {
        var lastW = el.clientWidth;
        new ResizeObserver(function () { if (el.clientWidth !== lastW) { lastW = el.clientWidth; target = 0; fit(true); } }).observe(el);
      }
      if (ta) {
        ta.addEventListener('input', function (e) { if (e.isTrusted) stop(); fit(); });
        ta.addEventListener('pointerdown', stop);
        ta.addEventListener('focus', stop);
      }

      function set(v) {
        if (ta) ta.value = v; else inner.textContent = v;
        fit();
        if (!ta) field.scrollTop = field.scrollHeight;
      }

      // Autoplay: type a message one character at a time, then clear it.
      var timer = 0, stopped = false, auto = on(d.autoplay) && !reduced();
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (auto && ta && document.activeElement === ta && ta.value === typed) set('');
      }
      var msg = (d.text || DEFAULTS.text).split('|').join('\n'), typed = '';
      var steps = [];
      for (var i = 1; i <= msg.length; i++) {
        (function (n) {
          steps.push([function () { typed = msg.slice(0, n); set(typed); }, msg[n - 1] === '\n' ? 420 : 45 + Math.random() * 70]);
        })(i);
      }
      steps.push([function () {}, 1400]);
      // Delete line by line so it shrinks a step at a time.
      var lines = msg.split('\n');
      for (var k = lines.length - 1; k >= 0; k--) {
        (function (n) { steps.push([function () { typed = lines.slice(0, n).join('\n'); set(typed); }, 380]); })(k);
      }
      steps.push([function () {}, 700]);
      function run(i) {
        if (stopped) return;
        steps[i][0]();
        timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
      }
      if (auto) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) { set(''); timer = setTimeout(function () { run(0); }, 600); }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-auto-growing-textarea]').forEach(function (el) {
        if (el._agt) return;
        el._agt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-auto-growing-textarea data-max-rows="6">
  <textarea rows="1" placeholder="Write a message…" aria-label="Message"></textarea>
</div>