← Forms

Emoji Mood Slider

A face sits above the slider and takes on your mood as you drag. Slide left and it frowns, its brows tip up and it turns a cool grey-blue, with a tear at the very end. Slide right and it smiles, squints, blushes and warms to sunny yellow. The word underneath rolls over as the mood changes.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-emoji-mood-slider and an aria-label: the face and slider are built inside it.
  3. Size it with width and font-size: the face is 5em across and the slider fills the width.

Options

Add any of these attributes to the same element.

data-valueThe starting mood, from 0 (sad) to 100 (happy). Default 50.
data-wordsWords from sad to happy, comma-separated, shown under the face. Default Awful,Bad,Okay,Good,Great!. none hides them.
data-nameAdds a hidden input with this name, so the value posts with a form.
data-autoplayA pretend finger slides it back and forth while in view. It stops as soon as you use it.

It’s a real slider (role="slider"): arrow keys, Page Up, Page Down, Home and End work, and screen readers hear the word as well as the number. Listen for the input event: e.detail.value is 0 to 100 and e.detail.word the word.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Emoji Mood Slider: a face above a slider. Slide left and it frowns,
    // its brows tip up and it turns a cool grey-blue (with a tear at the very
    // end); slide right and it smiles, its eyes squint, its cheeks blush and
    // it warms to sunny yellow. A word under the face rolls over as the mood
    // changes, and the face gives a little bounce each time.
    // Usage: <div data-emoji-mood-slider aria-label="How was your day?"></div>
    // Optional: data-value (0 to 100, default 50), data-words (five words from
    // sad to happy, comma-separated, default "Awful,Bad,Okay,Good,Great!";
    // "none" hides them), data-name (adds a hidden input with this name so
    // the value posts with a form), data-autoplay (a pretend finger slides it
    // back and forth while in view; it stops as soon as you use it).
    // It's a real slider: arrow keys, Page Up/Down, Home and End work.
    // Listen for the "input" event: e.detail.value (0 to 100) and e.detail.word.
    var DEFAULTS = { value: 50, words: 'Awful,Bad,Okay,Good,Great!' };
    var STOPS = [[142, 166, 212], [255, 214, 92], [255, 182, 39]];

    var css =
      '.ems{display:flex;flex-direction:column;align-items:center;gap:.5em;outline:none;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}' +
      '.ems-face{display:block;width:5em;height:5em;overflow:visible}' +
      '.ems-word{position:relative;display:block;align-self:stretch;height:1.4em;line-height:1.4em;overflow:hidden;text-align:center;font-weight:700;color:#3f3f46;white-space:nowrap}' +
      '.ems-word span{display:block}' +
      '.ems-rail{position:relative;align-self:stretch;height:2.2em;cursor:pointer;touch-action:none}' +
      '.ems-track{position:absolute;left:0;right:0;top:50%;height:.5em;margin-top:-.25em;border-radius:1em;background:#e4e4e7;overflow:hidden}' +
      '.ems-fill{position:absolute;left:0;top:0;bottom:0;border-radius:1em}' +
      '.ems-knob{position:absolute;top:50%;left:0;width:1.5em;height:1.5em;margin:-.75em 0 0 -.75em;box-sizing:border-box;border-radius:50%;background:#fff;' +
      'border:.22em solid;box-shadow:0 2px 8px rgba(15,15,30,.18);transition:transform .2s}' +
      '.ems-active .ems-knob{transform:scale(1.15)}' +
      '.ems:focus-visible .ems-knob{box-shadow:0 0 0 3px #fff,0 0 0 5px #6a0dad}';
    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 lerp(a, b, t) { return a + (b - a) * t; }
    function clamp(v) { return Math.max(0, Math.min(1, v)); }
    function colour(t) {
      var i = t < 0.5 ? 0 : 1, k = t < 0.5 ? t * 2 : (t - 0.5) * 2;
      var a = STOPS[i], b = STOPS[i + 1];
      return 'rgb(' + Math.round(lerp(a[0], b[0], k)) + ',' + Math.round(lerp(a[1], b[1], k)) + ',' + Math.round(lerp(a[2], b[2], k)) + ')';
    }

    var FACE =
      '<svg class="ems-face" viewBox="0 0 100 100" aria-hidden="true"><g class="ems-tilt">' +
      '<circle class="ems-head" cx="50" cy="50" r="46"/>' +
      '<ellipse class="ems-cheek" cx="24" cy="62" rx="8" ry="5" fill="#ff7a8a"/><ellipse class="ems-cheek" cx="76" cy="62" rx="8" ry="5" fill="#ff7a8a"/>' +
      '<path class="ems-brow" fill="none" stroke="#3f2d20" stroke-width="3.5" stroke-linecap="round"/><path class="ems-brow" fill="none" stroke="#3f2d20" stroke-width="3.5" stroke-linecap="round"/>' +
      '<ellipse class="ems-eye" cx="36" cy="43" rx="5" ry="6" fill="#3f2d20"/><ellipse class="ems-eye" cx="64" cy="43" rx="5" ry="6" fill="#3f2d20"/>' +
      '<path class="ems-mouth" fill="none" stroke="#3f2d20" stroke-width="4" stroke-linecap="round"/>' +
      '<path class="ems-open" fill="#3f2d20" stroke="#3f2d20" stroke-width="4" stroke-linejoin="round"/>' +
      '<path class="ems-tear" d="M33 52c-2.6 4-4 6.4-4 8.3a4 4 0 0 0 8 0c0-1.9-1.4-4.3-4-8.3z" fill="#5fb0f0"/>' +
      '</g></svg>';

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var words = d.words === 'none' ? null : (d.words || DEFAULTS.words).split(',').map(function (w) { return w.trim(); });
      if (words && !words.length) words = null;
      var value = Math.max(0, Math.min(100, parseFloat(d.value)));
      if (isNaN(value)) value = DEFAULTS.value;
      el.classList.add('ems');
      el.innerHTML = FACE + (words ? '<span class="ems-word" aria-hidden="true"><span></span></span>' : '') +
        '<div class="ems-rail"><span class="ems-track"><span class="ems-fill"></span></span><span class="ems-knob"></span></div>';
      var face = el.querySelector('.ems-face'), head = el.querySelector('.ems-head');
      var cheeks = el.querySelectorAll('.ems-cheek'), brows = el.querySelectorAll('.ems-brow'), eyes = el.querySelectorAll('.ems-eye');
      var tilt = el.querySelector('.ems-tilt'), open = el.querySelector('.ems-open');
      var mouth = el.querySelector('.ems-mouth'), tear = el.querySelector('.ems-tear');
      var word = el.querySelector('.ems-word'), rail = el.querySelector('.ems-rail');
      var fill = el.querySelector('.ems-fill'), knob = el.querySelector('.ems-knob');
      var hidden = null;
      if (!mock) {
        el.setAttribute('role', 'slider');
        el.tabIndex = 0;
        el.setAttribute('aria-valuemin', 0);
        el.setAttribute('aria-valuemax', 100);
        if (!el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby')) el.setAttribute('aria-label', 'Mood');
        if (d.name) {
          hidden = document.createElement('input');
          hidden.type = 'hidden';
          hidden.name = d.name;
          el.appendChild(hidden);
        }
      }

      function wordAt(v) { return words ? words[Math.min(words.length - 1, Math.floor(v / 100 * words.length))] : ''; }
      var shown = null, current = null;
      function showWord(w, dir) {
        if (!word || w === shown) return;
        var first = shown === null;
        shown = w;
        var old = current, next = document.createElement('span');
        next.textContent = w;
        current = next;
        if (first || reduced()) { word.textContent = ''; word.appendChild(next); return; }
        next.style.position = 'absolute';
        next.style.left = next.style.right = '0';
        next.style.top = '0';
        word.appendChild(next);
        anime.utils.remove(old);
        anime.animate(old, { y: ['0%', (dir > 0 ? -100 : 100) + '%'], opacity: [1, 0], duration: 260, ease: 'out(2)', onComplete: function () { old.remove(); } });
        anime.animate(next, { y: [(dir > 0 ? 100 : -100) + '%', '0%'], opacity: [0, 1], duration: 300, ease: 'out(3)' });
        anime.animate(face, { scale: [1.1, 1], duration: 520, ease: 'outElastic(1, .5)' });
      }

      function draw(v) {
        var t = v / 100, c = colour(t);
        head.setAttribute('fill', c);
        fill.style.width = v + '%';
        fill.style.background = 'linear-gradient(90deg,' + colour(0) + ',' + c + ')';
        knob.style.left = v + '%';
        knob.style.borderColor = c;
        tilt.setAttribute('transform', 'rotate(' + lerp(-7, 5, t).toFixed(2) + ' 50 50)');
        // Brows: inner ends rise when sad, the whole brow lifts a little when happy.
        var sad = clamp((0.45 - t) / 0.45), up = clamp((t - 0.6) / 0.4);
        var by = 29 - up * 3;
        brows[0].setAttribute('d', 'M27 ' + (by + sad * 3).toFixed(2) + 'L42 ' + (by - sad * 6).toFixed(2));
        brows[1].setAttribute('d', 'M58 ' + (by - sad * 6).toFixed(2) + 'L73 ' + (by + sad * 3).toFixed(2));
        var ry = (6 - up * 2.6).toFixed(2);
        eyes[0].setAttribute('ry', ry);
        eyes[1].setAttribute('ry', ry);
        // Mouth: a curve that bends from a frown to a smile, opening when very happy.
        var w = lerp(16, 21, t), cy = 70 + (0.5 - t) * 8, ctrl = 70 + (t - 0.5) * 46;
        var md = 'M' + (50 - w).toFixed(2) + ' ' + cy.toFixed(2) + 'Q50 ' + ctrl.toFixed(2) + ' ' + (50 + w).toFixed(2) + ' ' + cy.toFixed(2);
        mouth.setAttribute('d', md);
        open.setAttribute('d', md + 'Z');
        open.setAttribute('opacity', clamp((t - 0.72) / 0.2).toFixed(3));
        var cheek = clamp((t - 0.55) / 0.35) * 0.55;
        cheeks[0].setAttribute('opacity', cheek);
        cheeks[1].setAttribute('opacity', cheek);
        var tr = clamp((0.16 - t) / 0.12);
        tear.setAttribute('opacity', tr);
        tear.setAttribute('transform', 'translate(0 ' + ((1 - tr) * -6).toFixed(2) + ')');
      }

      function setValue(v, quiet, dir) {
        v = Math.max(0, Math.min(100, v));
        var prev = value;
        value = v;
        draw(v);
        var w = wordAt(v);
        showWord(w, dir || (v >= prev ? 1 : -1));
        if (!mock) {
          var r = Math.round(v);
          el.setAttribute('aria-valuenow', r);
          el.setAttribute('aria-valuetext', w ? w + ', ' + r : String(r));
          if (hidden) hidden.value = r;
          if (!quiet && Math.round(prev) !== r) el.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: r, word: w } }));
        }
      }
      setValue(value, true);

      var holding = false;
      function moveTo(x) {
        var r = rail.getBoundingClientRect();
        setValue((x - r.left) / (r.width || 1) * 100);
      }
      function press(next) { holding = next; el.classList.toggle('ems-active', next); }

      if (!mock) {
        rail.addEventListener('pointerdown', function (e) {
          stop();
          e.preventDefault();
          rail.setPointerCapture(e.pointerId);
          press(true);
          moveTo(e.clientX);
          el.focus({ preventScroll: true });
        });
        rail.addEventListener('pointermove', function (e) { if (holding) moveTo(e.clientX); });
        ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(function (t) {
          rail.addEventListener(t, function () { if (holding) press(false); });
        });
        el.addEventListener('keydown', function (e) {
          var map = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5, PageUp: 20, PageDown: -20 };
          var next;
          if (e.key in map) next = Math.round(value) + map[e.key];
          else if (e.key === 'Home') next = 0;
          else if (e.key === 'End') next = 100;
          else return;
          e.preventDefault();
          stop();
          setValue(next);
        });
      }

      // Autoplay: glides between moods, pausing on each.
      var stopped = false, timer = null, tween = null, plan = [88, 100, 12, 0, 55, 72, 30];
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (tween) tween.pause();
        press(false);
      }
      function run(i) {
        if (stopped) return;
        var o = { v: value };
        press(true);
        tween = anime.animate(o, {
          v: plan[i], duration: 700 + Math.abs(plan[i] - value) * 12, ease: 'inOut(3)',
          onUpdate: function () { setValue(o.v); },
          onComplete: function () {
            press(false);
            timer = setTimeout(function () { run((i + 1) % plan.length); }, 900);
          }
        });
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          if (stopped) return;
          clearTimeout(timer);
          if (tween) tween.pause();
          press(false);
          if (entries[0].isIntersecting) timer = setTimeout(function () { run(0); }, 500);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-emoji-mood-slider]').forEach(function (el) {
        if (el._ems) return;
        el._ems = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-emoji-mood-slider data-name="mood" aria-label="How was your day?" style="width: 280px"></div>