← Forms

Elastic Slider

Drag the slider past either end and the track stretches after you like rubber, getting thinner as it goes, while the speaker icon at that end is tugged outwards. Let go and it snaps back with a wobble. The track also thickens while you hold it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-elastic-slider and an aria-label: the slider is built inside it.
  3. Set the range with data-min, data-max and data-value.

Options

Add any of these attributes to the same element.

data-minThe lowest value. Default 0.
data-maxThe highest value. Default 100.
data-stepThe step between values. Default 1.
data-valueThe starting value. Default 50.
data-suffixText after the number, such as %.
data-iconsvolume for speaker icons at the ends, or none. Default volume.
data-nameAdds a hidden input with this name, so the value posts with a form.
data-colorFill colour. Default #6a0dad.
data-autoplayA pretend finger drags it past both ends, over and over 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 pushing past an end with the keys gives a small stretch. Listen for the input event: e.detail.value is the new value.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Elastic Slider: a slider that stretches like rubber when you pull past
    // its ends. Keep dragging beyond the end and the track stretches after
    // you, getting thinner, while the end icon is tugged outwards. Let go and
    // it snaps back with a wobble. The track thickens while you hold it.
    // Usage: <div data-elastic-slider data-value="40" aria-label="Volume"></div>
    // Optional: data-min (default 0), data-max (default 100), data-step
    // (default 1), data-value (default 50), data-name (adds a hidden input
    // with this name so the value posts with a form), data-icons ("volume"
    // for speaker icons at the ends, or "none"; default "volume"), data-suffix
    // (text after the number, e.g. "%"), data-color (fill colour, default
    // #6a0dad), data-autoplay (a pretend finger drags it past both ends,
    // over and over 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, and
    // pushing past an end with the keys gives a small stretch.
    // Listen for the "input" event: e.detail.value is the new value.
    var DEFAULTS = { min: 0, max: 100, step: 1, value: 50, color: '#6a0dad', icons: 'volume' };

    var QUIET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
      '<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5"/></svg>';
    var LOUD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
      '<path d="M3 9.5h3.5L11 5.5v13l-4.5-4H3z" fill="currentColor"/><path d="M14.5 9.5a3.5 3.5 0 0 1 0 5M17.5 6.5a7.5 7.5 0 0 1 0 11"/></svg>';

    var css =
      '.esl{position:relative;box-sizing:border-box;color:#71717a;outline:none;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none}' +
      '.esl-row{display:flex;align-items:center;gap:.7em}' +
      '.esl-icon{flex:none;display:grid;width:1.4em;height:1.4em;transition:color .2s}.esl-icon svg{width:100%;height:100%}' +
      '.esl-rail{position:relative;flex:1;min-width:0;height:2.4em;cursor:pointer;touch-action:none}' +
      '.esl-track{position:absolute;left:0;right:0;top:50%;height:.5em;margin-top:-.25em;border-radius:1em;background:#e4e4e7;overflow:hidden;' +
      'transition:height .25s,margin-top .25s}' +
      '.esl-active .esl-track{height:.85em;margin-top:-.425em}' +
      '.esl-fill{position:absolute;left:0;top:0;bottom:0;background:var(--esl-color)}' +
      '.esl-value{display:block;margin-top:.15em;text-align:center;font-size:.85em;font-weight:600;font-variant-numeric:tabular-nums;transition:color .2s}' +
      '.esl-active .esl-value,.esl-active .esl-icon{color:#3f3f46}' +
      '.esl:focus-visible .esl-track{box-shadow:0 0 0 2px #fff,0 0 0 4px var(--esl-color)}' +
      '.esl-touch{position:absolute;top:50%;left:0;width:1.9em;height:1.9em;margin:-.95em 0 0 -.95em;border-radius:50%;background:rgba(24,24,27,.16);' +
      'box-shadow:0 0 0 1px rgba(255,255,255,.6) inset;opacity:0;pointer-events:none}';
    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 num(v, f) { v = parseFloat(v); return isNaN(v) ? f : v; }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var min = num(d.min, DEFAULTS.min), max = num(d.max, DEFAULTS.max), step = num(d.step, DEFAULTS.step) || 1;
      var value = num(d.value, DEFAULTS.value);
      var suffix = d.suffix || '';
      var decimals = step % 1 ? String(step).split('.')[1].length : 0;
      var icons = (d.icons || DEFAULTS.icons) !== 'none';
      el.classList.add('esl');
      el.style.setProperty('--esl-color', d.color || DEFAULTS.color);
      el.innerHTML = '<div class="esl-row">' + (icons ? '<span class="esl-icon">' + QUIET + '</span>' : '') +
        '<div class="esl-rail"><span class="esl-track"><span class="esl-fill"></span></span><span class="esl-touch"></span></div>' +
        (icons ? '<span class="esl-icon">' + LOUD + '</span>' : '') + '</div><span class="esl-value" aria-hidden="true"></span>';
      var rail = el.querySelector('.esl-rail'), track = el.querySelector('.esl-track'), fill = el.querySelector('.esl-fill');
      var touch = el.querySelector('.esl-touch'), readout = el.querySelector('.esl-value');
      var iconEls = el.querySelectorAll('.esl-icon');
      var hidden = null;
      if (!mock) {
        el.setAttribute('role', 'slider');
        el.tabIndex = 0;
        el.setAttribute('aria-valuemin', min);
        el.setAttribute('aria-valuemax', max);
        if (!el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby')) el.setAttribute('aria-label', 'Volume');
        if (d.name) {
          hidden = document.createElement('input');
          hidden.type = 'hidden';
          hidden.name = d.name;
          el.appendChild(hidden);
        }
      }

      function setValue(v, quiet) {
        v = Math.max(min, Math.min(max, Math.round((v - min) / step) * step + min));
        var changed = v !== value;
        value = v;
        var p = max > min ? (v - min) / (max - min) : 0;
        fill.style.width = (p * 100) + '%';
        var text = v.toFixed(decimals);
        readout.textContent = text + suffix;
        if (!mock) {
          el.setAttribute('aria-valuenow', text);
          el.setAttribute('aria-valuetext', text + suffix);
          if (hidden) hidden.value = text;
          if (changed && !quiet) el.dispatchEvent(new CustomEvent('input', { bubbles: true, detail: { value: v } }));
        }
      }
      setValue(value, true);

      // The stretch: o px past an end (negative squeezes, during the wobble back).
      var side = 1, pull = 0, spring = null;
      function maxPull() { return Math.min(38, rail.offsetWidth * 0.14); }
      function apply(o) {
        pull = o;
        var m = maxPull() || 1, thin = Math.max(0.6, 1 - 0.32 * Math.max(0, o) / m);
        track.style.left = side < 0 ? -o + 'px' : '0px';
        track.style.right = side > 0 ? -o + 'px' : '0px';
        track.style.transform = 'scaleY(' + thin.toFixed(3) + ')';
        var icon = iconEls[side < 0 ? 0 : iconEls.length - 1];
        if (icon) icon.style.transform = 'translateX(' + (side * o * 0.75).toFixed(2) + 'px) scale(' + (1 + 0.22 * Math.max(0, o) / m).toFixed(3) + ')';
      }
      function rubber(over) {
        var m = maxPull();
        return m * (1 - Math.exp(-over / (m * 1.7)));
      }
      function release() {
        if (spring) spring.pause();
        if (!pull) return;
        if (reduced()) { apply(0); return; }
        var o = { v: pull };
        spring = anime.animate(o, { v: 0, duration: 950, ease: 'outElastic(1, .32)', onUpdate: function () { apply(o.v); }, onComplete: function () { spring = null; apply(0); } });
      }

      var holding = false;
      function moveTo(x, r) {
        r = r || rail.getBoundingClientRect();
        var dx = x - r.left, w = r.width || 1;
        setValue(min + Math.max(0, Math.min(1, dx / w)) * (max - min));
        var over = dx < 0 ? -dx : dx > w ? dx - w : 0;
        if (over) {
          if (spring) { spring.pause(); spring = null; }
          side = dx < 0 ? -1 : 1;
          apply(reduced() ? 0 : rubber(over));
        } else if (pull && !spring) apply(0);
        anime.utils.set(touch, { x: dx });
      }
      function press(next) {
        holding = next;
        el.classList.toggle('esl-active', next);
        if (!next) release();
      }

      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 big = Math.max(step, (max - min) / 10);
          var map = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: big, PageDown: -big };
          var next = value;
          if (e.key in map) next = value + map[e.key];
          else if (e.key === 'Home') next = min;
          else if (e.key === 'End') next = max;
          else return;
          e.preventDefault();
          stop();
          if ((next > max && value === max) || (next < min && value === min)) {
            // Pushing past the end with the keys: a little stretch and back.
            side = next > max ? 1 : -1;
            if (!reduced()) { if (spring) spring.pause(); apply(maxPull() * 0.45); release(); }
            return;
          }
          setValue(next);
        });
      }

      // Autoplay: a pretend finger drags past the right end, then the left.
      var stopped = false, timer = null, tween = null;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (tween) tween.pause();
        anime.utils.set(touch, { opacity: 0 });
        if (holding) press(false);
      }
      var plan = [
        ['press'], ['to', 1, 900], ['to', 1, 520, 1], ['wait', 300], ['release'], ['wait', 1300],
        ['press'], ['to', 0.3, 900], ['to', 0, 520, -1], ['wait', 300], ['release'], ['wait', 1300],
        ['press'], ['to', 0.62, 900], ['release'], ['wait', 1500]
      ];
      var fx = 0;
      function run(i) {
        if (stopped) return;
        var s = plan[i], next = function () { run((i + 1) % plan.length); };
        var w = rail.offsetWidth;
        if (s[0] === 'press') {
          fx = (value - min) / (max - min) * w;
          anime.utils.set(touch, { x: fx });
          anime.animate(touch, { opacity: [0, 1], scale: [0.4, 1], duration: 220, ease: 'out(2)' });
          press(true);
          timer = setTimeout(next, 260);
        } else if (s[0] === 'release') {
          press(false);
          anime.animate(touch, { opacity: 0, scale: 0.6, duration: 260, ease: 'out(2)' });
          timer = setTimeout(next, 10);
        } else if (s[0] === 'wait') {
          timer = setTimeout(next, s[1]);
        } else {
          var extra = s[3] ? s[3] * Math.min(64, w * 0.2) : 0;
          var o = { x: fx };
          tween = anime.animate(o, {
            x: s[1] * w + extra, duration: s[2], ease: s[3] ? 'out(2)' : 'inOut(2)',
            onUpdate: function () { fx = o.x; var r = rail.getBoundingClientRect(); moveTo(r.left + o.x, r); },
            onComplete: next
          });
        }
      }
      if (on(d.autoplay) && !reduced()) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (tween) tween.pause();
          if (stopped) return;
          if (entries[0].isIntersecting) timer = setTimeout(function () { run(0); }, 600);
          else { anime.utils.set(touch, { opacity: 0 }); if (holding) press(false); }
        }).observe(el);
      }
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-elastic-slider data-value="40" data-suffix="%" aria-label="Volume"></div>