Awkward slider: a spice level picker that starts to sweat

Slide towards the hot end and the face gets more and more uneasy. The order still goes through as chosen; the face just makes sure it was on purpose.

A noodle bar takes orders online, and the spice level is an easy question to get wrong. A plain dropdown says "Fire" in the same calm grey as "Mild", so nobody pauses before picking it. This slider gives the choice a face. Slide towards the hot end and the face blushes, its smile turns into a nervous wobble, a bead of sweat appears, and if you stop on the top level it shivers and glances away. The order still goes through exactly as chosen; the face just makes sure the choice was made on purpose.

Dan dan noodles, made to order. Pick a spice level.

Medium A gentle warmth. Most people order this.

Live demo. It runs on exactly the code further down this page.

How it works

  1. A real slider underneath. The control is a native input type="range" with five steps. Its track and thumb are made transparent, so keyboard, touch, screen readers and form submission all work as they normally do.
  2. One number drives the face. On every input event the script turns the value into --t, a CSS custom property from 0 to 1. The blush, the eye position, the tilt and the swap from smile to wobbly mouth are all read from that one number in the stylesheet.
  3. The face follows the thumb. A small inline SVG sits on top of the invisible thumb. The script works out where the thumb is, allowing for its width at both ends, and sets --x to match. The face ignores pointer events, so it never blocks a drag.
  4. Awkward on purpose, once. Landing on the top level plays a short shiver. Stay there a little over a second and the eyes dart sideways while the sweat drop slides down. Both are one shot CSS animations, so nothing keeps running while the page sits idle.
  5. Words for everyone. Each level has a plain description underneath, and aria-valuetext reads "Very hot" instead of "3", so the meaning never depends on seeing the face.

The code

Three short blocks, no libraries, and the face is drawn in a few lines of SVG.

HTML
<div class="aw" data-lab="awkward-slider">
  <label class="aw-q" for="aw-range">How hot do you want your noodles?</label>
  <p class="aw-sub">Dan dan noodles, made to order. Pick a spice level.</p>
  <div class="aw-wrap">
    <div class="aw-rail" aria-hidden="true"><span class="aw-fill"></span></div>
    <svg class="aw-face" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
      <circle class="aw-head" cx="24" cy="24" r="21"></circle>
      <ellipse class="aw-cheek" cx="12" cy="29" rx="5" ry="3"></ellipse>
      <ellipse class="aw-cheek" cx="36" cy="29" rx="5" ry="3"></ellipse>
      <g class="aw-eyes">
        <circle cx="17" cy="20" r="2.6"></circle>
        <circle cx="31" cy="20" r="2.6"></circle>
      </g>
      <path class="aw-smile" d="M15 29 Q24 37 33 29"></path>
      <path class="aw-wobble" d="M15 31 Q18.5 28 22 31 T29 31 T34 30"></path>
      <path class="aw-drop" d="M40 9 Q43 15 40 17 Q37 15 40 9 Z"></path>
    </svg>
    <input class="aw-range" id="aw-range" type="range" min="0" max="4" step="1" value="1" aria-valuetext="Medium" aria-describedby="aw-say">
  </div>
  <div class="aw-ticks" aria-hidden="true">
    <span>Mild</span><span>Medium</span><span>Hot</span><span>Very hot</span><span>Fire</span>
  </div>
  <p class="aw-out"><output class="aw-level" for="aw-range">Medium</output> <span class="aw-say" id="aw-say">A gentle warmth. Most people order this.</span></p>
</div>
CSS
.aw {
  --t: .25;              /* 0 to 1, set by the script from the slider value */
  --x: 25%;              /* where the face sits along the rail */
  max-width: 440px;
  margin: 0 auto;
  padding: 8px 4px;
  font-family: system-ui, sans-serif;
  color: #101a14;
}
.aw .aw-q { display: block; font: 700 19px/1.3 system-ui, sans-serif; color: #133621; }
.aw .aw-sub { margin: 4px 0 22px; font-size: 14px; line-height: 1.45; color: #46534a; }
.aw-wrap { position: relative; height: 64px; margin: 0 6px; }
.aw-rail {
  position: absolute; left: 0; right: 0; top: 50%;
  height: 10px; margin-top: -5px;
  border-radius: 99px; background: #e4e7df; border: 1px solid #c9cec4; overflow: hidden;
}
.aw-fill {
  display: block; height: 100%; width: var(--x);
  background: linear-gradient(90deg, #f4d96c, #e0892f 60%, #b8321f);
}
/* The face rides on top of the real thumb; it never takes pointer events. */
.aw-face {
  position: absolute; top: 50%; left: var(--x);
  width: 52px; height: 52px; margin: -26px 0 0 -26px;
  overflow: visible; pointer-events: none;
  transform: rotate(calc(var(--t) * -10deg));
  transition: left .12s ease-out, transform .3s;
}
.aw-head { fill: #fdf3c4; stroke: #133621; stroke-width: 2.4; }
.aw-cheek { fill: #e0563c; opacity: calc(var(--t) * var(--t)); transition: opacity .3s; }
.aw-eyes { fill: #101a14; transform: translateX(calc(var(--t) * -3px)); transition: transform .3s; }
.aw-smile, .aw-wobble { fill: none; stroke: #101a14; stroke-width: 2.4; stroke-linecap: round; transition: opacity .25s; }
.aw-smile { opacity: calc(1.6 - var(--t) * 2); }
.aw-wobble { opacity: calc(var(--t) * 4 - 2.6); }
.aw-drop { fill: #7cc3e8; stroke: #133621; stroke-width: 1.2; opacity: 0; }
.aw.is-sweaty .aw-drop { opacity: 1; }
.aw.is-glance .aw-eyes { animation: aw-glance 1.4s ease-in-out; }
.aw.is-glance .aw-drop { animation: aw-slide 1.4s ease-in; }
.aw.is-shy .aw-face { animation: aw-shiver .1s linear 6; }
@keyframes aw-glance {
  0%, 100% { transform: translateX(-3px); }
  25%, 45% { transform: translateX(3px); }
  60%, 80% { transform: translate(-3px, 1px); }
}
@keyframes aw-slide { from { transform: translateY(0); } to { transform: translateY(7px); opacity: .2; } }
@keyframes aw-shiver {
  50% { transform: rotate(-12deg) translateX(1px); }
}
/* Long selectors on purpose: they outrank range styles a site theme may already have. */
.aw .aw-wrap .aw-range[type=range] {
  -webkit-appearance: none; appearance: none;
  position: absolute; inset: 0; width: 100%; height: 64px; margin: 0; padding: 0;
  background: transparent; border: 0; box-shadow: none; cursor: grab; touch-action: pan-y;
}
.aw .aw-wrap .aw-range[type=range]:active { cursor: grabbing; }
.aw .aw-wrap .aw-range[type=range]::-webkit-slider-runnable-track { height: 64px; border: 0; background: transparent; }
.aw .aw-wrap .aw-range[type=range]::-moz-range-track { height: 64px; border: 0; background: transparent; }
.aw .aw-wrap .aw-range[type=range]::-moz-range-progress { background: transparent; }
.aw .aw-wrap .aw-range[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 52px; height: 52px; margin-top: 6px; border: 0; border-radius: 50%; background: transparent;
}
.aw .aw-wrap .aw-range[type=range]::-moz-range-thumb { width: 52px; height: 52px; border: 0; border-radius: 50%; background: transparent; }
.aw .aw-wrap .aw-range[type=range]:focus { outline: none; }
.aw .aw-wrap .aw-range[type=range]:focus-visible { outline: 3px solid #f4d96c; outline-offset: 2px; border-radius: 34px; }
/* Labels sit under the thumb centres: 26px in from each end, the rest shared evenly. */
.aw .aw-ticks {
  position: relative; height: 18px;
  margin: 6px 6px 0;
  font-size: 12px; color: #46534a;
}
.aw-ticks span { position: absolute; top: 0; line-height: 18px; white-space: nowrap; transform: translateX(-50%); }
.aw-ticks span:nth-child(1) { left: 26px; }
.aw-ticks span:nth-child(2) { left: calc(26px + (100% - 52px) * .25); }
.aw-ticks span:nth-child(3) { left: calc(26px + (100% - 52px) * .5); }
.aw-ticks span:nth-child(4) { left: calc(26px + (100% - 52px) * .75); }
.aw-ticks span:nth-child(5) { left: calc(100% - 26px); }
.aw .aw-out { min-height: 2.9em; margin: 14px 0 0; font-size: 15px; line-height: 1.45; }
.aw-level { font-weight: 700; color: #133621; margin-right: 4px; }
.aw-say { color: #46534a; }
@media (prefers-reduced-motion: reduce) {
  .aw-face, .aw-cheek, .aw-eyes, .aw-smile, .aw-wobble { transition: none; }
  .aw.is-glance .aw-eyes, .aw.is-glance .aw-drop, .aw.is-shy .aw-face { animation: none; }
}
JavaScript
(function () {
  var root = document.querySelector('[data-lab="awkward-slider"]');
  if (!root) return;
  var range = root.querySelector('.aw-range');
  var level = root.querySelector('.aw-level');
  var say = root.querySelector('.aw-say');
  var THUMB = 52;        // thumb width in px, must match the CSS
  var LINGER = 1200;     // ms at the top before the face gets awkward
  var levels = [
    ['Mild', 'No chili at all. Sesame and peanut only.'],
    ['Medium', 'A gentle warmth. Most people order this.'],
    ['Hot', 'Our house chili oil. You will notice it.'],
    ['Very hot', 'Extra chili oil and Sichuan pepper. Keep water close.'],
    ['Fire', 'Double everything. The kitchen will ask if you are sure.']
  ];
  var timer = 0, last = -1;

  // Place the face over the thumb. The thumb travels inside the input
  // minus its own width, so map the value onto that shorter track.
  function place() {
    var v = +range.value, max = +range.max;
    var t = v / max;
    var w = range.offsetWidth || 1;
    var px = THUMB / 2 + (w - THUMB) * t;
    root.style.setProperty('--x', (px / w * 100) + '%');
    root.style.setProperty('--t', t);
    root.classList.toggle('is-sweaty', v >= max - 1);
    level.textContent = levels[v][0];
    say.textContent = levels[v][1];
    range.setAttribute('aria-valuetext', levels[v][0]);
    if (v !== last) react(v, max);
    last = v;
  }

  // A one shot fidget: never a loop, so nothing runs while the page is idle.
  function react(v, max) {
    clearTimeout(timer);
    root.classList.remove('is-glance', 'is-shy');
    if (v === max) {
      void root.offsetWidth;          // restart the CSS animation
      root.classList.add('is-shy');
      timer = setTimeout(function () { root.classList.add('is-glance'); }, LINGER);
    }
  }

  root.addEventListener('animationend', function (e) {
    if (e.animationName === 'aw-glance') root.classList.remove('is-glance');
    if (e.animationName === 'aw-shiver') root.classList.remove('is-shy');
  });
  range.addEventListener('input', place);
  window.addEventListener('resize', place);
  place();
})();

When to use it, and when not to

It fits choices where the top of the scale is a real commitment and a second look saves a bad experience: spice levels, the firmness of a massage, the intensity of a fitness class, how short a haircut should be. The face does the job a friendly server does across the counter, raising an eyebrow without saying no. It also gives a plain order form a little personality.

Do not use it where the expression could feel like judgement. A face that winces at a low tip or a low star rating pressures people, and that is the kind of trick this Lab avoids. Skip it on serious forms, such as medical intake or anything about money owed, where a playful face reads as careless. And keep the scale short: five steps is easy to read, while a slider with fifty values makes the face change so often that it turns into noise.

On accessibility, the face is decorative and hidden from assistive technology, and the text below the slider carries the meaning. The motion is small and short, and with reduced motion turned on the face still changes expression but never shivers or darts its eyes.

Make it yours

  • The levels and wording: edit the levels array in the script and the matching aw-ticks labels in the HTML, then set the range max to the number of levels minus one.
  • How long before it gets awkward: change LINGER, in milliseconds. If you change the face size, update THUMB and the 52px widths in the CSS together.
  • Colours: the heat gradient lives on .aw-fill, the blush on .aw-cheek and the face fill on .aw-head.

Frequently asked questions

Will the value submit with my order form?

Yes. It is an ordinary range input, so give it a name attribute and the number from 0 to 4 is sent with the rest of the form.

Does it work with a keyboard and on phones?

Yes. The arrow keys, Home and End move it like any slider, and on a phone you drag the face with a finger because the real thumb sits right under it.

Can I use a different face or an icon?

Yes. Replace the SVG with your own drawing and keep the class names on the parts you want to react, or drop the parts you do not need from the stylesheet.

What should your website do?

Describe a feature you wish your site had. If it is useful to other owners too, we build it here as a free Lab post with a live demo and code.

We never publish your name, email or website. Privacy

More from the Website Lab

All experiments