← Buttons

Lever Switch

A chunky lever on a metal plate. Click it, or drag the knob, and the lever swings through the middle, looming towards you as it points straight out, then slams into place with a clunk: it bounces off the stop, the plate jolts and the little light beside “ON” comes on or goes out.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-lever-switch to an empty button, with an aria-label.
  3. Listen for its change event; event.detail.on says which way it went.

Options

Add any of these attributes to the same element.

data-onStart switched on (lever up).
data-colorColour of the knob. Default #ef4444.
data-lightColour of the light when on. Default #22c55e.
data-autoplayIt flips itself every couple of seconds while in view, until someone touches it, without firing the event. Handy for demos.

Size it with font-size: it is 4em wide and 6em tall. It is a switch for screen readers, so Enter and Space flip it too. With reduced motion the lever just jumps.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lever Switch: a chunky lever on a metal plate. Click it, or drag the
    // knob, and the lever swings through the middle (looming towards you as
    // it points straight out) and slams into place with a clunk: it bounces
    // off the stop, the plate jolts and the light beside "ON" comes on or
    // goes out.
    // Usage: <button data-lever-switch aria-label="Power"></button>
    // Optional: data-on (start switched on), data-color (colour of the knob,
    // default #ef4444), data-light (colour of the light when on, default
    // #22c55e), data-autoplay (it flips itself every couple of seconds while
    // in view, without firing the event, until someone touches it; handy
    // for demos).
    // Events: fires "change" on the switch with event.detail.on.
    // Size it with font-size: it is 4em wide and 6em tall.
    var DEFAULTS = { color: '#ef4444', light: '#22c55e', every: 2200 };
    var PX = 50, PY = 75, R = 36;
    var uid = 0;

    var css =
      ':where(.lvr){font-size:inherit}' +
      '.lvr{position:relative;display:inline-block;width:4em;height:6em;padding:0;border:0;background:none;cursor:pointer;' +
      'touch-action:none;-webkit-tap-highlight-color:transparent;vertical-align:middle}' +
      '.lvr:focus-visible{outline:3px solid #f59e0b;outline-offset:4px;border-radius:.6em}' +
      '.lvr svg{display:block;width:100%;height:100%;overflow:visible;pointer-events:none;filter:drop-shadow(0 .25em .4em rgba(15,15,30,.25))}' +
      '.lvr-txt{font:800 9px/1 system-ui,sans-serif;letter-spacing:.08em;fill:#71717a;text-anchor:middle}' +
      '.lvr-led{transition:fill .15s}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function build(id, color) {
      return '<svg viewBox="0 0 100 150" aria-hidden="true"><defs>' +
        '<linearGradient id="' + id + '-plate" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f4f4f5"/><stop offset="1" stop-color="#c4c4cc"/></linearGradient>' +
        '<linearGradient id="' + id + '-arm" gradientUnits="userSpaceOnUse" x1="44" y1="0" x2="56" y2="0"><stop offset="0" stop-color="#52525b"/><stop offset=".4" stop-color="#d4d4d8"/><stop offset="1" stop-color="#3f3f46"/></linearGradient>' +
        '<radialGradient id="' + id + '-knob" cx=".35" cy=".3" r=".75"><stop offset="0" stop-color="#fff" stop-opacity=".85"/><stop offset=".25" stop-color="' + color + '"/><stop offset="1" stop-color="' + color + '"/></radialGradient>' +
        '</defs>' +
        '<g class="lvr-body">' +
        '<rect x="8" y="4" width="84" height="142" rx="14" fill="url(#' + id + '-plate)" stroke="#a1a1aa" stroke-width="1"/>' +
        '<circle cx="18" cy="14" r="2.6" fill="#a1a1aa"/><circle cx="82" cy="14" r="2.6" fill="#a1a1aa"/>' +
        '<circle cx="18" cy="136" r="2.6" fill="#a1a1aa"/><circle cx="82" cy="136" r="2.6" fill="#a1a1aa"/>' +
        '<text class="lvr-txt" x="50" y="22">ON</text><text class="lvr-txt" x="50" y="137">OFF</text>' +
        '<circle class="lvr-led" cx="72" cy="19" r="3.6" fill="#d4d4d8" stroke="#a1a1aa" stroke-width=".8"/>' +
        '<rect x="41" y="31" width="18" height="88" rx="9" fill="#27272a"/>' +
        '<rect x="43" y="33" width="14" height="84" rx="7" fill="#18181b"/>' +
        '<ellipse class="lvr-shadow" cx="50" cy="75" rx="13" ry="9" fill="#000" opacity=".25"/>' +
        '<line class="lvr-arm" x1="50" y1="75" x2="50" y2="40" stroke="url(#' + id + '-arm)" stroke-width="9" stroke-linecap="round"/>' +
        '<circle class="lvr-knob" cx="50" cy="40" r="13" fill="url(#' + id + '-knob)" stroke="rgba(0,0,0,.2)" stroke-width="1"/>' +
        '</g></svg>';
    }

    function setup(el) {
      var d = el.dataset;
      var id = 'lvr-' + (++uid);
      el.classList.add('lvr');
      el.setAttribute('role', 'switch');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Power');
      el.innerHTML = build(id, d.color || DEFAULTS.color);
      var body = el.querySelector('.lvr-body');
      var arm = el.querySelector('.lvr-arm');
      var knob = el.querySelector('.lvr-knob');
      var shadow = el.querySelector('.lvr-shadow');
      var led = el.querySelector('.lvr-led');
      var light = d.light || DEFAULTS.light;
      var on = d.on !== undefined && d.on !== 'false';
      // t runs from -1 (lever up, on) to 1 (lever down, off).
      var st = { t: on ? -1 : 1 };

      function draw() {
        var t = st.t, near = 1 - Math.min(1, Math.abs(t));
        var y = PY + t * R;
        arm.setAttribute('y2', y);
        arm.setAttribute('stroke-width', 9 + near * 2);
        knob.setAttribute('cy', y);
        knob.setAttribute('r', 13 * (1 + 0.28 * near));
        shadow.setAttribute('cy', y + 6 + near * 4);
        shadow.setAttribute('cx', 50 + 3 + near * 3);
        shadow.setAttribute('opacity', 0.18 + near * 0.14);
      }

      function paint() {
        el.setAttribute('aria-checked', on ? 'true' : 'false');
        led.setAttribute('fill', on ? light : '#d4d4d8');
        led.style.filter = on ? 'drop-shadow(0 0 3px ' + light + ')' : '';
      }

      // Swing to the end, slam into the stop and bounce off it.
      function swing(fire) {
        var end = on ? -1 : 1;
        anime.utils.remove(st);
        if (reduced()) { st.t = end; draw(); paint(); return; }
        var dist = Math.abs(end - st.t);
        anime.animate(st, {
          t: end, duration: 120 + 140 * dist / 2, ease: 'in(3)', onUpdate: draw,
          onComplete: function () {
            paint();
            anime.animate(st, { t: [end - end * 0.16, end], duration: 420, ease: 'outElastic(1, .4)', onUpdate: draw });
            anime.animate(body, { y: [0, end * -2.2, end * 1, 0], duration: 220, ease: 'out(2)' });
            anime.animate(el, { scale: [1, 0.985, 1], duration: 200, ease: 'out(2)' });
          }
        });
        if (fire) el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { on: on } }));
      }

      function toggle(fire) {
        on = !on;
        swing(fire);
      }

      draw();
      paint();

      var userTouched = false, drag = null, skipClick = false;
      el.addEventListener('pointerdown', function (e) {
        if (e.button !== 0) return;
        userTouched = true;
        var r = el.getBoundingClientRect();
        drag = { y: e.clientY, t: st.t, scale: 150 / r.height, moved: false };
        anime.utils.remove(st);
        try { el.setPointerCapture(e.pointerId); } catch (err) {}
      });
      el.addEventListener('pointermove', function (e) {
        if (!drag) return;
        var dy = (e.clientY - drag.y) * drag.scale;
        if (Math.abs(dy) > 3) drag.moved = true;
        if (!drag.moved) return;
        st.t = Math.max(-1, Math.min(1, drag.t + dy / R));
        draw();
      });
      function end() {
        if (!drag) return;
        var moved = drag.moved;
        drag = null;
        if (!moved) { skipClick = false; return; }
        skipClick = true;
        var want = st.t < 0;
        if (want !== on) toggle(true);
        else swing(false);
      }
      el.addEventListener('pointerup', end);
      el.addEventListener('pointercancel', end);
      el.addEventListener('click', function (e) {
        if (skipClick) { skipClick = false; return; }
        if (e.isTrusted) userTouched = true;
        toggle(true);
      });

      if (!reduced() && d.autoplay !== undefined && d.autoplay !== 'false') {
        var timer = null;
        var loop = function () {
          if (userTouched) return;
          toggle(false);
          timer = setTimeout(loop, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(loop, 800);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-lever-switch]').forEach(function (el) {
        if (el._lvr) return;
        el._lvr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button id="power" data-lever-switch aria-label="Power"></button>
<script>
  document.getElementById("power").addEventListener("change", (e) => {
    console.log(e.detail.on ? "On" : "Off");
  });
</script>