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.
A gentle warmth. Most people order this.
How it works
- 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. - One number drives the face. On every
inputevent 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. - 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
--xto match. The face ignores pointer events, so it never blocks a drag. - 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.
- Words for everyone. Each level has a plain description underneath, and
aria-valuetextreads "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.
<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>.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; }
}(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
levelsarray in the script and the matchingaw-tickslabels in the HTML, then set the rangemaxto the number of levels minus one. - How long before it gets awkward: change
LINGER, in milliseconds. If you change the face size, updateTHUMBand the 52px widths in the CSS together. - Colours: the heat gradient lives on
.aw-fill, the blush on.aw-cheekand 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.


