← Feedback

Vote Cast

A ballot paper floats down over a ballot box, a tick marks one of its boxes, and it slides in through the slot. The box gives a little bump as the vote lands and a big tick pops up on its front, with “Vote cast” underneath.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-vote-cast></div> where the confirmation should show. Its font-size is the width of the box.
  3. It plays when it scrolls into view, so add it to the page once the vote is in, or call animacious.voteCast(el) to play it again.

Options

Add any of these attributes to the same element.

data-colorColour of the box. Default #6a0dad.
data-tick-colorColour of the tick. Default #16a34a.
data-labelCaption under the box. Default “Vote cast”; false for none.
data-loopCast another vote after a pause.
data-holdHow long the tick shows before looping. Default 2200 ms.

It’s an image labelled with the caption for screen readers; set your own aria-label to say more. With reduced motion the box shows its tick straight away.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Vote Cast: a ballot paper floats down over a ballot box, a tick marks
    // one of its boxes, and it slides in through the slot. The box gives a
    // little bump as it lands and a big tick pops up on its front.
    // Usage: <div data-vote-cast></div>   (size it with font-size: 1em = the width of the box)
    // Optional: data-color (box colour, default #6a0dad), data-tick-color
    // (default #16a34a), data-label (caption once the vote is in, default
    // "Vote cast"; "false" for none), data-loop (cast another vote after a
    // pause), data-hold (ms the tick shows before looping, default 2200).
    // Script use: animacious.voteCast(el) casts it again.
    var DEFAULTS = { color: '#6a0dad', tickColor: '#16a34a', label: 'Vote cast', hold: 2200 };
    var uid = 0;

    var css =
      '.vtc{display:inline-grid;justify-items:center;gap:.06em;line-height:1.2;vertical-align:middle}' +
      '.vtc svg{display:block;width:1em;height:1.05em;overflow:visible}' +
      '.vtc-box{transform-box:fill-box;transform-origin:50% 100%}' +
      '.vtc-paper{transform-box:fill-box;transform-origin:50% 100%}' +
      '.vtc-badge,.vtc-ring{transform-box:fill-box;transform-origin:50% 50%}' +
      '.vtc-label{font-size:.15em;font-weight:800;letter-spacing:-.01em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function opt(el, k) { var v = el.dataset[k]; return v === undefined ? DEFAULTS[k] : v; }

    function setup(el) {
      var id = 'vtc-clip-' + (++uid);
      var c = opt(el, 'color'), t = opt(el, 'tickColor'), label = opt(el, 'label');
      el.classList.add('vtc');
      el.setAttribute('role', 'img');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', label !== 'false' ? label : 'Vote cast');
      el.innerHTML =
        '<svg viewBox="0 0 120 126" aria-hidden="true">' +
        '<defs><clipPath id="' + id + '"><rect x="0" y="-80" width="120" height="133"/></clipPath></defs>' +
        '<ellipse cx="60" cy="118" rx="44" ry="4" fill="#000" opacity=".08"/>' +
        '<g class="vtc-box">' +
        '<path d="M20 58h80l-8-10H28z" fill="' + c + '"/><path d="M20 58h80l-8-10H28z" fill="#fff" opacity=".3"/>' +
        '<rect x="41" y="51" width="38" height="4" rx="2" fill="#1a1625" opacity=".75"/>' +
        '<g clip-path="url(#' + id + ')"><g class="vtc-paper">' +
        '<rect x="44" y="6" width="32" height="42" rx="2.5" fill="#fff" stroke="#d4d0dd" stroke-width="1.2"/>' +
        '<path d="M49 16h11M49 27h11M49 38h8" stroke="#d4d0dd" stroke-width="2.4" stroke-linecap="round"/>' +
        '<g fill="none" stroke="#a8a2b6" stroke-width="1.2"><rect x="64" y="13" width="6.5" height="6.5" rx="1.2"/><rect x="64" y="24" width="6.5" height="6.5" rx="1.2"/><rect x="64" y="35" width="6.5" height="6.5" rx="1.2"/></g>' +
        '<path class="vtc-mark" d="M64.6 26.6l2.6 2.8L72.6 22" fill="none" stroke="' + c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
        '</g></g>' +
        '<rect x="20" y="58" width="80" height="56" rx="5" fill="' + c + '"/>' +
        '<rect x="20" y="58" width="80" height="5" fill="#000" opacity=".12"/>' +
        '<text class="vtc-word" x="60" y="90" text-anchor="middle" font-size="12" font-weight="800" letter-spacing="1.5" fill="#fff" font-family="inherit">VOTE</text>' +
        '<circle class="vtc-ring" cx="60" cy="86" r="15" fill="none" stroke="#fff" stroke-width="2"/>' +
        '<g class="vtc-badge"><circle cx="60" cy="86" r="15" fill="#fff"/>' +
        '<path class="vtc-tick" d="M52.5 86.5l5 5l10-10.5" fill="none" stroke="' + t + '" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/></g>' +
        '</g></svg>' +
        (label !== 'false' ? '<span class="vtc-label" aria-hidden="true"></span>' : '');
      var s = el._vtc = {
        svg: el.querySelector('svg'), box: el.querySelector('.vtc-box'), paper: el.querySelector('.vtc-paper'),
        mark: anime.svg.createDrawable(el.querySelector('.vtc-mark')),
        word: el.querySelector('.vtc-word'), ring: el.querySelector('.vtc-ring'), badge: el.querySelector('.vtc-badge'),
        tick: anime.svg.createDrawable(el.querySelector('.vtc-tick')),
        label: el.querySelector('.vtc-label'), timer: null, visible: false, waiting: false, run: 0
      };
      if (s.label) s.label.textContent = label;
      if (reduced()) finished(s); else start(s);
    }

    // The end state: paper in, tick showing.
    function finished(s) {
      anime.utils.set(s.paper, { y: 60, opacity: 1, rotate: 0 });
      anime.utils.set(s.word, { opacity: 0 });
      anime.utils.set(s.ring, { opacity: 0 });
      anime.utils.set(s.badge, { scale: 1, opacity: 1 });
      anime.utils.set(s.tick, { draw: '0 1' });
      if (s.label) anime.utils.set(s.label, { opacity: 1, y: '0em' });
    }

    // The start state: nothing above the box yet.
    function start(s) {
      anime.utils.set(s.paper, { y: -40, opacity: 0, rotate: -10 });
      anime.utils.set(s.mark, { draw: '0 0' });
      anime.utils.set(s.word, { opacity: 1 });
      anime.utils.set(s.ring, { opacity: 0, scale: 1 });
      anime.utils.set(s.badge, { scale: 0, opacity: 1 });
      anime.utils.set(s.tick, { draw: '0 0' });
      if (s.label) anime.utils.set(s.label, { opacity: 0 });
    }

    function play(el) {
      var s = el._vtc, d = el.dataset;
      clearTimeout(s.timer);
      if (!s.visible) { s.waiting = true; return; }
      s.waiting = false;
      var run = ++s.run;
      start(s);
      var tl = anime.createTimeline();
      // Float down, mark a box, straighten up, then into the slot.
      tl.add(s.paper, { y: [-40, 0], opacity: [0, 1], rotate: [-10, 4], duration: 700, ease: 'out(3)' })
        .add(s.mark, { draw: ['0 0', '0 1'], duration: 320, ease: 'out(2)' }, '+=80')
        .add(s.paper, { rotate: 0, y: -4, duration: 300, ease: 'inOut(2)' }, '+=180')
        .add(s.paper, { y: 60, duration: 520, ease: 'in(3)' })
        .add(s.box, {
          scaleY: [{ to: 0.92, duration: 120, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'outElastic(1, .45)' }],
          scaleX: [{ to: 1.04, duration: 120, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'outElastic(1, .45)' }]
        }, '-=60')
        .add(s.word, { opacity: 0, duration: 160 }, '<<')
        .add(s.badge, { scale: [0, 1], duration: 520, ease: 'outBack(2.2)' }, '<<+=80')
        .add(s.tick, { draw: ['0 0', '0 1'], duration: 360, ease: 'out(3)' }, '<<+=220')
        .add(s.ring, { scale: [1, 1.7], opacity: [0.8, 0], duration: 700, ease: 'out(3)' }, '<<');
      if (s.label) tl.add(s.label, { opacity: [0, 1], y: ['.5em', '0em'], duration: 420, ease: 'out(3)' }, '<<+=100');
      if (d.loop === undefined || d.loop === 'false') return;
      tl.then(function () {
        if (run !== s.run) return;
        s.timer = setTimeout(function () {
          var fade = [s.badge];
          if (s.label) fade.push(s.label);
          anime.animate(fade, { opacity: 0, duration: 350, ease: 'in(2)', onComplete: function () { if (run === s.run) s.timer = setTimeout(function () { play(el); }, 300); } });
        }, +opt(el, 'hold'));
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._vtc;
          s.visible = e.isIntersecting;
          if (!s.visible) return;
          if (!s.started) { s.started = true; play(e.target); }
          else if (s.waiting) play(e.target);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-vote-cast]').forEach(function (el) {
        if (el._vtc) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.voteCast = function (el) {
      var s = el._vtc;
      if (!s || reduced()) return;
      s.visible = true;
      play(el);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-vote-cast style="font-size: 120px"></div>

<!-- in your own colours, with your own words: -->
<div data-vote-cast data-color="#0f766e" data-label="Response recorded"></div>