← Buttons

Checkbox Tick Draw

Tick a box and it gives a little squeeze, fills with colour, and the tick draws itself in like a pen stroke. Add data-strike and a line crosses the item off too. The real checkboxes stay in the page, so forms and keyboards work as normal.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-checkbox-tick-draw to a <label> that wraps a checkbox, or to a list of them.

Options

Add any of these attributes to the same element.

data-strikeDraw a line through the label when ticked, like a to-do list.
data-colorBox colour when ticked. Default #6a0dad.
data-autoplayIt ticks the boxes one by one and clears them, over and over, while in view. Handy for demos.

Size it with font-size. Custom elements with role="checkbox" and aria-checked work too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Checkbox Tick Draw: tick a box and it gives a little squeeze, fills with
    // colour, and the tick draws itself in with a pen stroke. Untick it and the
    // tick wipes away. With data-strike, a line also draws through the label,
    // like crossing something off a to-do list. Real checkboxes stay in the
    // page (just out of sight), so forms, keyboards and screen readers work as
    // normal.
    // Usage: <label data-checkbox-tick-draw><input type="checkbox"> Buy milk</label>
    //   Or put it on a list: every checkbox inside gets one. Elements with
    //   role="checkbox" (and aria-checked) work too.
    // Optional: data-color (box colour when ticked, default #6a0dad), data-strike
    // (strike through the label when ticked), data-autoplay (it ticks the boxes
    // one by one and clears them again, over and over, while in view; handy for
    // demos). Size it with font-size.
    var DEFAULTS = { color: '#6a0dad', every: 800 };

    var css =
      '.ctd{display:flex;align-items:center;gap:0.6em;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.ctd-input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}' +
      '.ctd-box{position:relative;flex:none;display:grid;place-items:center;width:1.15em;height:1.15em;border-radius:0.3em;' +
      'box-sizing:border-box;border:0.11em solid #c9c3d6;background:#fff}' +
      '.ctd-fill{position:absolute;inset:-1px;border-radius:inherit;background:var(--ctd-color);transform:scale(0)}' +
      '.ctd-box svg{position:relative;width:78%;height:78%;overflow:visible}' +
      '.ctd-input:focus-visible + .ctd-box, .ctd[role=checkbox]:focus-visible .ctd-box{outline:2px solid var(--ctd-color);outline-offset:2px}' +
      '.ctd[role=checkbox]:focus-visible{outline:none}' +
      '.ctd-text{position:relative}' +
      '.ctd-line{position:absolute;left:-0.1em;right:-0.1em;top:55%;height:0.09em;border-radius:0.05em;background:currentColor;transform:scaleX(0);transform-origin:0 50%;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function build(root, input, el) {
      var color = el.dataset.color || DEFAULTS.color;
      root.classList.add('ctd');
      root.style.setProperty('--ctd-color', color);
      var box = document.createElement('span');
      box.className = 'ctd-box';
      box.setAttribute('aria-hidden', 'true');
      box.innerHTML = '<span class="ctd-fill"></span><svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
      if (input) {
        input.classList.add('ctd-input');
        input.after(box);
      } else root.insertBefore(box, root.firstChild);
      // Gather the label text into one box so a line can strike through it.
      var text = null, line = null;
      if (el.dataset.strike !== undefined && el.dataset.strike !== 'false') {
        text = document.createElement('span');
        text.className = 'ctd-text';
        while (box.nextSibling) text.appendChild(box.nextSibling);
        line = document.createElement('span');
        line.className = 'ctd-line';
        line.setAttribute('aria-hidden', 'true');
        text.appendChild(line);
        root.appendChild(text);
      }
      var item = {
        root: root, input: input, box: box, fill: box.firstChild, text: text, line: line, color: color,
        tick: anime.svg.createDrawable(box.querySelector('path')),
        checked: input ? input.checked : root.getAttribute('aria-checked') === 'true'
      };
      show(item, item.checked, true);
      return item;
    }

    function show(item, on, instant) {
      item.checked = on;
      if (item.input) item.input.checked = on;
      else item.root.setAttribute('aria-checked', on);
      var box = { borderColor: on ? item.color : '#c9c3d6' };
      if (instant || reduced()) {
        anime.utils.set(item.box, box);
        anime.utils.set(item.fill, { scale: on ? 1 : 0 });
        anime.utils.set(item.tick, { draw: on ? '0 1' : '0 0' });
        if (item.line) {
          anime.utils.set(item.line, { scaleX: on ? 1 : 0 });
          anime.utils.set(item.text, { opacity: on ? 0.55 : 1 });
        }
        return;
      }
      if (on) {
        anime.animate(item.box, { scale: [{ to: 0.8, duration: 100, ease: 'out(2)' }, { to: 1, duration: 600, ease: 'outElastic(1, .5)' }] });
        anime.animate(item.box, Object.assign({ duration: 150 }, box));
        anime.animate(item.fill, { scale: [0, 1], duration: 300, ease: 'out(3)' });
        anime.animate(item.tick, { draw: ['0 0', '0 1'], duration: 380, delay: 130, ease: 'out(3)' });
        if (item.line) {
          anime.utils.set(item.line, { transformOrigin: '0% 50%' });
          anime.animate(item.line, { scaleX: [0, 1], duration: 450, delay: 220, ease: 'inOut(2)' });
          anime.animate(item.text, { opacity: 0.55, duration: 400, delay: 300 });
        }
      } else {
        // The tick wipes away in the direction it was drawn.
        anime.animate(item.tick, { draw: '1 1', duration: 220, ease: 'in(2)' });
        anime.animate(item.fill, { scale: 0, duration: 260, delay: 100, ease: 'in(2)' });
        anime.animate(item.box, Object.assign({ duration: 200, delay: 200 }, box));
        if (item.line) {
          anime.utils.set(item.line, { transformOrigin: '100% 50%' });
          anime.animate(item.line, { scaleX: 0, duration: 350, ease: 'inOut(2)' });
          anime.animate(item.text, { opacity: 1, duration: 300 });
        }
      }
    }

    function setup(el) {
      var items = [];
      var inputs = el.matches('input[type=checkbox]') ? [el] : Array.from(el.querySelectorAll('input[type=checkbox]'));
      if (inputs.length) {
        inputs.forEach(function (input) {
          var item = build(input.closest('label') || input.parentNode, input, el);
          input.addEventListener('change', function () { show(item, input.checked); });
          items.push(item);
        });
      } else {
        var boxes = el.matches('[role=checkbox]') ? [el] : Array.from(el.querySelectorAll('[role=checkbox]'));
        boxes.forEach(function (root) {
          var item = build(root, null, el);
          // Keyboard-reachable, unless it's a decorative copy hidden from everyone.
          if (!root.closest('[aria-hidden="true"]') && !root.hasAttribute('tabindex')) root.tabIndex = 0;
          root.addEventListener('click', function () { show(item, !item.checked); });
          root.addEventListener('keydown', function (e) {
            if (e.key !== ' ') return;
            e.preventDefault();
            show(item, !item.checked);
          });
          items.push(item);
        });
      }

      if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced() && items.length) {
        // Tick each box in turn, pause, clear them all, pause, repeat.
        var step = 0;
        var timer = null;
        var next = function () {
          if (!el.matches(':hover') && !el.contains(document.activeElement)) {
            var phase = step % (items.length + 2);
            if (phase < items.length) show(items[phase], true);
            else if (phase === items.length + 1) {
              items.forEach(function (item, i) { setTimeout(function () { show(item, false); }, i * 90); });
            }
            step++;
          }
          timer = setTimeout(next, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(next, 500);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-checkbox-tick-draw]').forEach(function (el) {
        if (el._ctd) return;
        el._ctd = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<label data-checkbox-tick-draw><input type="checkbox"> Remember me</label>

<!-- a to-do list: -->
<div data-checkbox-tick-draw data-strike>
  <label><input type="checkbox"> Buy milk</label>
  <label><input type="checkbox"> Walk the dog</label>
</div>