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
- Paste the snippet just before
</head>. - Add an empty element with
data-emoji-mood-sliderand anaria-label: the face and slider are built inside it. - Size it with
widthandfont-size: the face is 5em across and the slider fills the width.
Options
Add any of these attributes to the same element.
data-value | The starting mood, from 0 (sad) to 100 (happy). Default 50. |
data-words | Words from sad to happy, comma-separated, shown under the face. 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 (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.
<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><div data-emoji-mood-slider data-name="mood" aria-label="How was your day?" style="width: 280px"></div>