← Buttons

Pull-cord Light Switch

A theme switch drawn as a hanging lamp. Drag the bead on its cord down and let go, or just click it: the cord stretches and springs back, the lamp sways on its wire, and the light flickers on or goes out as the page switches between light and dark.

Lights onLights off · pull the cord

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pull-cord-light-switch to an empty button, with an aria-label. Put it at the top of a section so the lamp hangs from the edge.
  3. Write your dark theme under html.dark. The switch adds that class when the light goes out, and takes it away when it comes back on.

Options

Add any of these attributes to the same element.

data-targetCSS selector of the element whose class it toggles. A surrounding element that matches wins, then the first match on the page. Default html.
data-classThe class it adds while the light is off. Default dark.
data-offStart with the light off.
data-colorColour of the lampshade. Default #6a0dad.
data-autoplayIt pulls itself every few seconds while in view, until someone touches it, without firing the event. Handy for demos.

Size it with font-size: it is 6em wide and 10em tall, and its wire and light spill past its box, so give its container overflow: hidden. It is a switch for screen readers (Enter or Space pulls the cord) and fires a change event with event.detail.on.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pull-cord Light Switch: a hanging lamp with a pull cord. Drag the bead
    // down and let go (or just click it) and the cord stretches and springs
    // back, the lamp sways, and the light flickers on or goes out, switching
    // the page between light and dark themes.
    // Usage: <button data-pull-cord-light-switch aria-label="Light"></button>
    // Optional: data-target (CSS selector of the element whose class it
    // toggles, default "html"; an ancestor matching it wins, then the first
    // match in the page), data-class (the class it adds there while the light
    // is off, default "dark"), data-off (start with the light off),
    // data-color (colour of the shade, default #6a0dad), data-autoplay (it
    // pulls itself every few 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 6em wide and 10em tall, and hangs from its top edge.
    var DEFAULTS = { target: 'html', cls: 'dark', color: '#6a0dad', every: 3200 };
    var REST = 46, MAX = 34, TRIP = 14;
    var uid = 0;

    var css =
      ':where(.pcd){font-size:inherit}' +
      '.pcd{position:relative;display:inline-block;width:6em;height:10em;padding:0;border:0;background:none;cursor:grab;' +
      'touch-action:none;-webkit-tap-highlight-color:transparent;vertical-align:top}' +
      '.pcd:active{cursor:grabbing}' +
      '.pcd:focus-visible{outline:none}' +
      '.pcd:focus-visible .pcd-bead{stroke:#f59e0b;stroke-width:3}' +
      '.pcd svg{display:block;width:100%;height:100%;overflow:visible;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 build(id, color) {
      return '<svg viewBox="0 0 120 200" aria-hidden="true">' +
        '<defs>' +
        '<radialGradient id="' + id + '-glow"><stop offset="0" stop-color="#fff7d6" stop-opacity=".95"/><stop offset=".35" stop-color="#fde68a" stop-opacity=".55"/><stop offset="1" stop-color="#fde68a" stop-opacity="0"/></radialGradient>' +
        '<linearGradient id="' + id + '-cone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fde68a" stop-opacity=".55"/><stop offset="1" stop-color="#fde68a" stop-opacity="0"/></linearGradient>' +
        '<linearGradient id="' + id + '-shade" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="' + color + '"/><stop offset=".45" stop-color="#fff" stop-opacity=".0"/><stop offset="1" stop-color="#000" stop-opacity=".25"/></linearGradient>' +
        '</defs>' +
        '<g class="pcd-lamp">' +
        '<g class="pcd-light">' +
        '<path d="M40 76 L-50 230 H170 L80 76Z" fill="url(#' + id + '-cone)"/>' +
        '<circle cx="60" cy="80" r="46" fill="url(#' + id + '-glow)"/>' +
        '</g>' +
        '<line x1="60" y1="-60" x2="60" y2="40" stroke="#3f3f46" stroke-width="2"/>' +
        '<rect x="54" y="36" width="12" height="10" rx="2" fill="#3f3f46"/>' +
        '<circle class="pcd-bulb" cx="60" cy="77" r="9" fill="#e4e4e7"/>' +
        '<path d="M60 44c-14 0-22 12-26 32h52c-4-20-12-32-26-32Z" fill="' + color + '"/>' +
        '<path d="M60 44c-14 0-22 12-26 32h52c-4-20-12-32-26-32Z" fill="url(#' + id + '-shade)"/>' +
        '<path d="M34 76h52" stroke="rgba(0,0,0,.25)" stroke-width="2" stroke-linecap="round"/>' +
        '<line class="pcd-cord" x1="78" y1="70" x2="78" y2="' + (70 + REST) + '" stroke="#71717a" stroke-width="1.6"/>' +
        '<rect class="pcd-bead" x="74" y="0" width="8" height="12" rx="4" fill="#a1a1aa" stroke="#52525b" stroke-width="1.2"/>' +
        '</g></svg>';
    }

    function setup(el) {
      var d = el.dataset;
      var id = 'pcd-' + (++uid);
      el.classList.add('pcd');
      el.setAttribute('role', 'switch');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Light');
      el.innerHTML = build(id, d.color || DEFAULTS.color);
      var lamp = el.querySelector('.pcd-lamp');
      var light = el.querySelector('.pcd-light');
      var bulb = el.querySelector('.pcd-bulb');
      var cord = el.querySelector('.pcd-cord');
      var bead = el.querySelector('.pcd-bead');
      var st = { pull: 0, sway: 0 };
      var on = !(d.off !== undefined && d.off !== 'false');

      function draw() {
        var y = 70 + REST + st.pull;
        cord.setAttribute('y2', y);
        bead.setAttribute('y', y - 1);
        lamp.setAttribute('transform', 'rotate(' + st.sway.toFixed(2) + ' 60 -60)');
      }

      function target() {
        var sel = d.target || DEFAULTS.target;
        try { return el.closest(sel) || document.querySelector(sel); } catch (e) { return null; }
      }

      function paint(animate) {
        el.setAttribute('aria-checked', on ? 'true' : 'false');
        var tg = target();
        if (tg) tg.classList.toggle(d['class'] || DEFAULTS.cls, !on);
        bulb.setAttribute('fill', on ? '#fffbeb' : '#e4e4e7');
        if (!animate || reduced()) { light.style.opacity = on ? 1 : 0; return; }
        if (on) anime.animate(light, { opacity: [0, 0.75, 0.15, 0.9, 0.5, 1], duration: 520, ease: 'linear' });
        else anime.animate(light, { opacity: 0, duration: 220, ease: 'out(2)' });
      }

      function toggle() {
        on = !on;
        paint(true);
        el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { on: on } }));
      }

      // Spring the cord back and set the lamp swinging a little.
      function release(strength) {
        anime.utils.remove(st);
        if (reduced()) { st.pull = 0; st.sway = 0; draw(); return; }
        anime.animate(st, { pull: 0, duration: 900, ease: 'outElastic(1, .35)', onUpdate: draw });
        anime.animate(st, { sway: [0, 4 * strength, -2.6 * strength, 1.2 * strength, 0], duration: 1600, ease: 'inOut(2)', onUpdate: draw });
      }

      // A whole pull: down, click, back up.
      function autoPull(silent) {
        if (reduced()) { if (silent) { on = !on; paint(false); } else toggle(); return; }
        anime.utils.remove(st);
        anime.animate(st, {
          pull: 26, duration: 260, ease: 'in(2)', onUpdate: draw,
          onComplete: function () {
            if (silent) { on = !on; paint(true); } else toggle();
            release(1);
          }
        });
      }

      paint(false);
      draw();

      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, scale: 200 / 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;
        // Rubber band: it gets stiffer the further it goes.
        var p = Math.max(0, dy);
        st.pull = MAX * (1 - Math.exp(-p / MAX));
        draw();
      });
      function end() {
        if (!drag) return;
        var moved = drag.moved;
        drag = null;
        if (!moved) { skipClick = false; return; }
        skipClick = true;
        var tripped = st.pull > TRIP;
        if (tripped) toggle();
        release(tripped ? 1 : 0.4);
      }
      el.addEventListener('pointerup', end);
      el.addEventListener('pointercancel', end);
      el.addEventListener('click', function (e) {
        if (skipClick) { skipClick = false; return; }
        if (e.isTrusted) userTouched = true;
        autoPull(false);
      });

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

    function init() {
      document.querySelectorAll('[data-pull-cord-light-switch]').forEach(function (el) {
        if (el._pcd) return;
        el._pcd = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<header style="position: relative; overflow: hidden">
  <button data-pull-cord-light-switch aria-label="Light"></button>
</header>

<style>
  html.dark body { background: #0f172a; color: #e2e8f0; }
</style>