← Buttons

Toggle Switch

An on/off switch whose knob springs across, stretching as it goes and wobbling to a stop, while the track floods with colour. It’s a real switch, with role="switch" and aria-checked.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-toggle-switch to a button. Any text inside becomes its label.

Options

Add any of these attributes to the same element.

data-onStart switched on.
data-colorTrack colour when on. Default #6a0dad.
data-autoplayIt flips itself every couple of seconds while in view. Handy for demos.

Size it with font-size. It fires a change event: e.detail.checked is true or false. From code, animacious.toggle(button) flips it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Toggle Switch: an on/off switch whose knob springs across, stretching as
    // it goes and wobbling to a stop, while the track floods with colour. It's
    // a real switch: role="switch" and aria-checked follow it, and Space or
    // Enter flip it.
    // Usage: <button data-toggle-switch>Dark mode</button>
    //   Any text inside becomes the switch's label, shown before it.
    // Optional: data-on (start switched on), data-color (track colour when on,
    // default #6a0dad), data-autoplay (it flips itself every couple of seconds
    // while in view; handy for demos). Size it with font-size.
    // Listen for the "change" event: e.detail.checked is true or false.
    // Script use: animacious.toggle(button) flips it from code.
    var DEFAULTS = { color: '#6a0dad', off: '#d4d0de', every: 2000 };
    var TRAVEL = 1.1; // em the knob moves

    var css =
      '.tsw{display:inline-flex;align-items:center;gap:0.6em;padding:0;border:0;background:none;font:inherit;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.tsw-track{position:relative;flex:none;width:2.7em;height:1.6em;border-radius:0.8em;background:var(--tsw-off)}' +
      '.tsw-knob{position:absolute;left:0.2em;top:0.2em;width:1.2em;height:1.2em;border-radius:0.6em;background:#fff;box-shadow:0 0.08em 0.25em rgba(15,15,30,.3)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(btn) {
      var d = btn.dataset;
      btn.classList.add('tsw');
      if (btn.tagName === 'BUTTON' && !btn.getAttribute('type')) btn.type = 'button';
      btn.setAttribute('role', 'switch');
      btn.style.setProperty('--tsw-off', DEFAULTS.off);
      var label = btn.textContent.trim();
      btn.textContent = '';
      if (label) {
        var text = document.createElement('span');
        text.textContent = label;
        btn.appendChild(text);
      }
      var track = document.createElement('span');
      track.className = 'tsw-track';
      track.setAttribute('aria-hidden', 'true');
      var knob = document.createElement('span');
      knob.className = 'tsw-knob';
      track.appendChild(knob);
      btn.appendChild(track);
      var on = d.on !== undefined && d.on !== 'false';
      btn._tsw = { track: track, knob: knob, on: on, color: d.color || DEFAULTS.color };
      btn.setAttribute('aria-checked', on);
      anime.utils.set(knob, { x: on ? TRAVEL + 'em' : '0em' });
      anime.utils.set(track, { backgroundColor: on ? btn._tsw.color : DEFAULTS.off });
    }

    function toggle(btn) {
      var s = btn._tsw;
      var on = (s.on = !s.on);
      btn.setAttribute('aria-checked', on);
      btn.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { checked: on } }));
      var to = { x: on ? TRAVEL + 'em' : '0em' };
      var bg = on ? s.color : DEFAULTS.off;
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        anime.utils.set(s.knob, to);
        anime.utils.set(s.track, { backgroundColor: bg });
        return;
      }
      // The knob stretches towards where it's going (leading edge first), then
      // springs past and wobbles to a stop.
      anime.utils.set(s.knob, { transformOrigin: on ? '100% 50%' : '0% 50%' });
      anime.animate(s.knob, {
        x: { to: to.x, duration: 650, ease: 'outElastic(1, .55)' },
        scaleX: [{ to: 1.35, duration: 120, ease: 'out(2)' }, { to: 1, duration: 380, ease: 'outElastic(1, .5)' }],
        scaleY: [{ to: 0.88, duration: 120, ease: 'out(2)' }, { to: 1, duration: 380, ease: 'outElastic(1, .5)' }]
      });
      anime.animate(s.track, { backgroundColor: bg, duration: 300, ease: 'out(2)' });
    }

    function init() {
      document.querySelectorAll('[data-toggle-switch]').forEach(function (btn) {
        if (btn._tsw) return;
        setup(btn);
        btn.addEventListener('click', function () { toggle(btn); });
        if (btn.dataset.autoplay !== undefined && btn.dataset.autoplay !== 'false' &&
            !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
          var timer = null;
          var tick = function () {
            if (!btn.matches(':hover') && document.activeElement !== btn) toggle(btn);
            timer = setTimeout(tick, DEFAULTS.every);
          };
          new IntersectionObserver(function (entries) {
            clearTimeout(timer);
            if (entries[0].isIntersecting) timer = setTimeout(tick, 600);
          }).observe(btn);
        }
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.toggle = function (btn) { if (btn._tsw) toggle(btn); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-toggle-switch>Dark mode</button>

<!-- on to begin with, in green: -->
<button data-toggle-switch data-on data-color="#16a34a">Notifications</button>