← Forms

Floating Label

The placeholder rises out of the way when the field gets focus, shrinking onto the top border and taking the accent colour. Leave the field empty and it drops back into place. Click into either field to try it.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an input (or textarea) in an element with data-floating-label.
  3. Give the input a placeholder: it becomes the label.

Options

Add any of these attributes to the same element.

data-labelThe label text. Default: the input’s placeholder.
data-colorLabel colour while the field has focus. Default #6a0dad.
data-bgThe colour behind the risen label, so it can sit on the border. Default: the field’s own background.
data-durationHow long the label takes to rise. Default 260 ms.
data-autoplayIt focuses, types and clears itself over and over while in view, and stops as soon as you use the field. Handy for demos.
data-textWhat autoplay types. Default [email protected].

The label is a real <label> linked to the input, so screen readers announce it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Floating Label: the placeholder rises out of the way to become a label
    // when the field gets focus, shrinking onto the top border and taking the
    // accent colour. Leave the field empty and it drops back down into place.
    // It's a real <label> linked to the input, so screen readers read it.
    // Usage: <div data-floating-label><input placeholder="Email address"></div>
    //   The wrapper holds one input or textarea; its placeholder becomes the
    //   label (or set data-label).
    // Optional: data-label (the label text), data-color (label colour on focus,
    // default #6a0dad), data-bg (the patch behind the risen label, so it can sit
    // on the border; default: the field's own background), data-duration (ms,
    // default 260), data-autoplay (it focuses, types data-text, clears and
    // leaves, over and over while in view; it stops as soon as you use the
    // field), data-text (what autoplay types, default "[email protected]").
    var DEFAULTS = { color: '#6a0dad', duration: 260, text: '[email protected]', scale: 0.78 };
    var count = 0;

    var css =
      '.flb{position:relative}' +
      '.flb-label{position:absolute;left:0;top:0;padding:0 0.3em;border-radius:0.3em;line-height:1.2;white-space:nowrap;' +
      'color:#8a8a93;pointer-events:none;transform-origin:0 50%;cursor:text}' +
      '.flb-text{white-space:pre}' +
      '.flb-caret{display:none;width:1.5px;height:1.15em;margin-left:1px;vertical-align:middle;background:currentColor;animation:flb-blink 1s steps(1) infinite}' +
      '.flb-focus .flb-caret{display:inline-block}' +
      '@keyframes flb-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'; }

    // The colour behind the field, so the risen label can cover the border.
    function backdrop(node) {
      while (node && node.nodeType === 1) {
        var bg = getComputedStyle(node).backgroundColor;
        if (bg && bg !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(bg)) return bg;
        node = node.parentNode;
      }
      return '#fff';
    }

    function setup(el) {
      var d = el.dataset;
      var input = el.querySelector('input, textarea');
      var field = input || el.querySelector('[data-field]') || el.firstElementChild;
      if (!field) return;
      var real = !!input;
      var color = d.color || DEFAULTS.color;
      var dur = parseFloat(d.duration) || DEFAULTS.duration;
      el.classList.add('flb');

      var label = document.createElement(real ? 'label' : 'span');
      label.className = 'flb-label';
      label.textContent = d.label || (real && (input.getAttribute('placeholder') || input.getAttribute('aria-label'))) || 'Label';
      if (real) {
        if (!input.id) input.id = 'flb-field-' + ++count;
        label.htmlFor = input.id;
        input.removeAttribute('placeholder');
      } else {
        // A mock-up field (a span): show its text and a caret.
        var text = document.createElement('span');
        text.className = 'flb-text';
        text.textContent = field.textContent;
        field.textContent = '';
        field.appendChild(text);
        var caret = document.createElement('span');
        caret.className = 'flb-caret';
        field.appendChild(caret);
      }
      el.appendChild(label);

      var s = { focus: false, up: false, rest: 0, top: 0 };
      var get = function () { return real ? input.value : text.textContent; };
      var set = function (v) { if (real) input.value = v; else text.textContent = v; };

      function measure() {
        var cs = getComputedStyle(field);
        var left = field.offsetLeft + parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft);
        var lh = label.offsetHeight;
        // Resting: centred in a one-line field (or on the first line of a textarea).
        var fh = field.tagName === 'TEXTAREA'
          ? parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop) + (parseFloat(cs.lineHeight) || lh) / 2
          : field.offsetHeight / 2;
        label.style.left = left + 'px';
        label.style.marginLeft = '-0.3em';
        label.style.top = field.offsetTop + 'px';
        s.rest = fh - lh / 2;
        s.top = -lh / 2;
        place(false);
      }

      function place(animate) {
        var up = s.focus || get() !== '';
        var props = {
          y: up ? s.top : s.rest,
          scale: up ? DEFAULTS.scale : 1,
          color: s.focus ? color : up ? '#52525b' : '#8a8a93'
        };
        s.up = up;
        if (!animate || reduced()) anime.utils.set(label, props);
        else anime.animate(label, Object.assign(props, { duration: dur, ease: 'out(3)' }));
        // Hide the patch behind the label while it rests inside the field.
        label.style.backgroundColor = up ? (d.bg || backdrop(field)) : 'transparent';
      }

      function focus(v) {
        s.focus = v;
        el.classList.toggle('flb-focus', v);
        place(true);
      }

      measure();
      if (window.ResizeObserver) new ResizeObserver(measure).observe(field);
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);

      if (real) {
        input.addEventListener('focus', function () { stop(); focus(true); });
        input.addEventListener('blur', function () { focus(false); });
        input.addEventListener('input', function () { place(true); });
        input.addEventListener('pointerdown', stop);
      }

      // Autoplay: a pretend visitor focuses, types, clears and leaves.
      var timer = null, stopped = false;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (on(d.autoplay) && (!real || document.activeElement !== input)) { set(''); focus(false); }
      }
      // Using any other field on the page ends the pretend visitor too.
      if (on(d.autoplay)) document.addEventListener('focusin', function (e) {
        if (e.target !== input && e.target.matches && e.target.matches('input, textarea, select')) stop();
      });
      if (on(d.autoplay) && !reduced()) {
        var word = d.text || DEFAULTS.text;
        var steps = [[function () { focus(true); }, 700]];
        for (var i = 1; i <= word.length; i++) {
          (function (n) { steps.push([function () { set(word.slice(0, n)); }, 60 + Math.random() * 70]); })(i);
        }
        steps.push([function () {}, 1300]);
        steps.push([function () { focus(false); }, 1200]);
        steps.push([function () { focus(true); }, 400]);
        steps.push([function () { set(''); place(true); }, 600]);
        steps.push([function () { focus(false); }, 1300]);
        var run = function (i) {
          if (stopped) return;
          steps[i][0]();
          timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) {
            set('');
            s.focus = false;
            el.classList.remove('flb-focus');
            place(false);
            timer = setTimeout(function () { run(0); }, 500);
          }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-floating-label]').forEach(function (el) {
        if (el._flb) return;
        el._flb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-floating-label>
  <input type="email" placeholder="Email address">
</div>