Marketing & Social

Free Poll Widget Widget

Ask visitors a question and show animated result bars instantly.

A lightweight inline poll that asks one question with a list of clickable options, then reveals animated percentage bars. Each visitor votes once — their choice is remembered in their own browser via localStorage. There is no backend, so results show realistic sample counts plus each visitor's own vote; it's perfect for engagement and fun questions, not serious data collection. Install with a single script tag.

Live demo

Embed this widget

Paste either snippet into your page — a Custom HTML block on WordPress, Shopify, or Squarespace works too. Both snippets update live as you customize, so what you copy is exactly what you configured.

Hosted (recommended)html
<script src="https://hayneslab.dev/widgets/poll-widget.js" data-hl-config='{"question":"What's the best part of a Friday?","options":["Logging off early","Takeout night","Weekend plans kicking in","Absolutely all of it"]}' async></script>
<!-- Free Poll Widget widget by haynes lab — https://hayneslab.dev/tools/widgets/poll-widget -->
Inline (self-contained)html
<!-- Free Poll Widget widget by haynes lab — https://hayneslab.dev/tools/widgets/poll-widget -->
<script data-hl-config='{"question":"What's the best part of a Friday?","options":["Logging off early","Takeout night","Weekend plans kicking in","Absolutely all of it"]}'>
/*!
 * haynes lab — Poll Widget widget (free)
 * Docs & embed code: https://hayneslab.dev/tools/widgets/poll-widget
 */
(function () {
  'use strict';

  var PREFIX = 'hlw-poll-widget';
  var STYLE_ID = 'hlw-poll-widget-styles';

  function findScript() {
    return document.currentScript ||
      document.querySelector('script[src*="widgets/poll-widget.js"]');
  }

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    var css =
      '.' + PREFIX + ', .' + PREFIX + ' * { box-sizing: border-box; }' +
      '.' + PREFIX + ' { position: relative; max-width: 480px; margin: 0 auto; padding: 24px 24px 38px;' +
      ' font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;' +
      ' font-size: 16px; line-height: 1.5; color: #1f2937; background: #ffffff;' +
      ' border: 1px solid #e5e7eb; border-radius: 12px; }' +
      '.' + PREFIX + '__question { margin: 0 0 16px; font-size: 18px; font-weight: 600; color: #111827; }' +
      '.' + PREFIX + '__options { display: flex; flex-direction: column; gap: 8px; }' +
      '.' + PREFIX + '__option { display: block; width: 100%; padding: 12px 16px; font: inherit; text-align: left;' +
      ' color: #1f2937; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer;' +
      ' transition: border-color 0.15s ease, background-color 0.15s ease; }' +
      '.' + PREFIX + '__option:hover { border-color: var(--hlw-accent); background: #fafafa; }' +
      '.' + PREFIX + '__option:focus-visible { outline: 2px solid var(--hlw-accent); outline-offset: 2px; }' +
      '.' + PREFIX + '__row { display: block; width: 100%; padding: 4px 2px; }' +
      '.' + PREFIX + '__rowhead { display: flex; justify-content: space-between; align-items: baseline; gap: 12px;' +
      ' font-size: 15px; margin-bottom: 6px; }' +
      '.' + PREFIX + '__label { color: #1f2937; }' +
      '.' + PREFIX + '__label--picked { font-weight: 600; color: var(--hlw-accent); }' +
      '.' + PREFIX + '__pct { font-variant-numeric: tabular-nums; color: #6b7280; }' +
      '.' + PREFIX + '__pct--picked { font-weight: 600; color: var(--hlw-accent); }' +
      '.' + PREFIX + '__track { height: 8px; border-radius: 999px; background: #f3f4f6; overflow: hidden; }' +
      '.' + PREFIX + '__fill { height: 100%; width: 0; border-radius: 999px; background: #d1d5db;' +
      ' transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1); }' +
      '.' + PREFIX + '__fill--picked { background: var(--hlw-accent); }' +
      '.' + PREFIX + '__meta { margin: 14px 0 0; font-size: 12px; color: #9ca3af; }' +
      '.' + PREFIX + '__credit { position: absolute; right: 10px; bottom: 6px; font-size: 11px;' +
      ' display: inline-flex; align-items: center; gap: 4px; color: #9ca3af; text-decoration: none; }' +
      '.' + PREFIX + '__credit svg { display: block; }' +
      '.' + PREFIX + '__credit:hover { color: #6b7280; text-decoration: underline; }';
    var style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;
    document.head.appendChild(style);
  }

  function parseConfig(script) {
    var raw = script.getAttribute('data-hl-config');
    if (!raw) {
      console.warn('[haynes lab poll-widget] Missing required data-hl-config attribute (JSON {"question", "options": [...]}). Nothing rendered.');
      return null;
    }
    var cfg;
    try {
      cfg = JSON.parse(raw);
    } catch (e) {
      console.warn('[haynes lab poll-widget] data-hl-config is not valid JSON. Nothing rendered.', e);
      return null;
    }
    if (!cfg || typeof cfg.question !== 'string' || !cfg.question ||
        !Array.isArray(cfg.options)) {
      console.warn('[haynes lab poll-widget] data-hl-config needs a "question" string and an "options" array. Nothing rendered.');
      return null;
    }
    var options = cfg.options.filter(function (o) {
      return typeof o === 'string' && o;
    });
    if (options.length < 2) {
      console.warn('[haynes lab poll-widget] data-hl-config needs at least 2 non-empty option strings. Nothing rendered.');
      return null;
    }
    return { question: cfg.question, options: options };
  }

  function el(tag, className, text) {
    var node = document.createElement(tag);
    if (className) node.className = className;
    if (text != null) node.textContent = text;
    return node;
  }

  // Small deterministic string hash (djb2) — used for the storage key and
  // stable sample vote counts so bars look alive without a backend.
  function hash(str) {
    var h = 5381;
    for (var i = 0; i < str.length; i++) {
      h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
    }
    return h;
  }

  function logoSvg() {
    var svgNS = 'http://www.w3.org/2000/svg';
    var svg = document.createElementNS(svgNS, 'svg');
    svg.setAttribute('viewBox', '0 0 32 32');
    svg.setAttribute('width', '13');
    svg.setAttribute('height', '13');
    svg.setAttribute('aria-hidden', 'true');
    svg.setAttribute('focusable', 'false');
    function circle(cx, cy, r, attrs) {
      var c = document.createElementNS(svgNS, 'circle');
      c.setAttribute('cx', cx);
      c.setAttribute('cy', cy);
      c.setAttribute('r', r);
      for (var key in attrs) c.setAttribute(key, attrs[key]);
      svg.appendChild(c);
    }
    circle(16, 16, 16, { fill: '#0f0a0a' });
    circle(16, 16, 12, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.3 });
    circle(16, 16, 9, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.5 });
    circle(16, 16, 6, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1.2, opacity: 0.9 });
    circle(16, 16, 3, { fill: '#06b6d4' });
    circle(24.5, 7.5, 2, { fill: '#f97316' });
    circle(7, 16, 1.8, { fill: '#f97316' });
    circle(20, 20, 1.5, { fill: '#f97316' });
    return svg;
  }

  function build(script) {
    var cfg = parseConfig(script);
    if (!cfg) return;

    var accent = script.getAttribute('data-hl-accent') || '#f97316';

    injectStyles();

    var key = PREFIX + ':' + hash(cfg.question + '|' + cfg.options.join('|'));
    var picked = null;
    try {
      var stored = window.localStorage.getItem(key);
      if (stored !== null) {
        var n = parseInt(stored, 10);
        if (!isNaN(n) && n >= 0 && n < cfg.options.length) picked = n;
      }
    } catch (e) { /* private mode etc. — vote simply won't persist */ }

    // Baseline counts are deterministic sample numbers derived from the
    // question/options — there is no backend, so only the visitor's own
    // vote (stored in their browser) is ever added on top.
    var counts = cfg.options.map(function (option, i) {
      return 8 + (hash(cfg.question + '|' + option + '|' + i) % 45);
    });
    if (picked !== null) counts[picked] += 1;

    var root = el('div', PREFIX);
    root.setAttribute('role', 'group');
    root.setAttribute('aria-label', 'Poll');
    root.style.setProperty('--hlw-accent', accent);

    root.appendChild(el('p', PREFIX + '__question', cfg.question));

    var optionsBox = el('div', PREFIX + '__options');
    root.appendChild(optionsBox);

    function renderResults() {
      var total = counts.reduce(function (sum, c) { return sum + c; }, 0);
      optionsBox.setAttribute('aria-live', 'polite');
      counts.forEach(function (count, i) {
        var isPicked = i === picked;
        var pct = total ? Math.round((count / total) * 100) : 0;
        var row = el('div', PREFIX + '__row');
        var head = el('div', PREFIX + '__rowhead');
        head.appendChild(el('span',
          PREFIX + '__label' + (isPicked ? ' ' + PREFIX + '__label--picked' : ''),
          cfg.options[i] + (isPicked ? ' ✓' : '')));
        head.appendChild(el('span',
          PREFIX + '__pct' + (isPicked ? ' ' + PREFIX + '__pct--picked' : ''),
          pct + '%'));
        row.appendChild(head);
        var track = el('div', PREFIX + '__track');
        var fill = el('div', PREFIX + '__fill' + (isPicked ? ' ' + PREFIX + '__fill--picked' : ''));
        track.appendChild(fill);
        row.appendChild(track);
        optionsBox.appendChild(row);
        // Trigger the width transition on the next frame so bars animate in.
        window.requestAnimationFrame(function () {
          window.requestAnimationFrame(function () {
            fill.style.width = pct + '%';
          });
        });
      });
      root.appendChild(el('p', PREFIX + '__meta',
        total + ' votes · results are stored on this device only'));
    }

    if (picked !== null) {
      renderResults();
    } else {
      cfg.options.forEach(function (option, i) {
        var btn = el('button', PREFIX + '__option', option);
        btn.type = 'button';
        btn.addEventListener('click', function () {
          picked = i;
          counts[i] += 1;
          try { window.localStorage.setItem(key, String(i)); } catch (e) { /* non-persistent */ }
          while (optionsBox.firstChild) optionsBox.removeChild(optionsBox.firstChild);
          renderResults();
        });
        optionsBox.appendChild(btn);
      });
    }

    var credit = el('a', PREFIX + '__credit');
    credit.appendChild(logoSvg());
    credit.appendChild(document.createTextNode('Powered by haynes lab'));
    credit.href = 'https://hayneslab.dev/tools/widgets/poll-widget';
    credit.target = '_blank';
    credit.rel = 'noopener';
    root.appendChild(credit);

    if (script.parentNode && script.parentNode.nodeName !== 'HEAD') {
      script.insertAdjacentElement('afterend', root);
    } else {
      document.body.appendChild(root);
    }
  }

  function init() {
    var script = findScript();
    if (!script) {
      console.warn('[haynes lab poll-widget] Could not locate its own script tag. Nothing rendered.');
      return;
    }
    build(script);
  }

  if (document.body) {
    init();
  } else {
    document.addEventListener('DOMContentLoaded', init);
  }
})();

</script>

Configuration

Every option is an attribute on the script tag — the same attributes the customizer above exposes. All attributes are optional unless marked required — defaults apply when omitted.

AttributeDefaultDescription
data-hl-config(required)JSON object with a "question" string and an "options" array of at least 2 non-empty strings, e.g. {"question":"...","options":["...","..."]}.
data-hl-accent"#f97316"Any CSS color for the winning-vote highlights: the visitor's chosen option bar, its percentage, and hover/focus states.

Frequently asked questions

How do I install the poll widget?

Paste the embed snippet exactly where you want the poll to appear — it renders inline at that spot. Put your question and options in the data-hl-config attribute as JSON and you're done.

Where are votes stored? Are the results real?

Honestly: there is no backend. Each visitor's vote is stored only in their own browser (localStorage), so they can vote once per device. The percentage bars combine realistic built-in sample counts with the visitor's own vote — great for engagement, but not a substitute for a real survey tool if you need collected, auditable responses.

How do I customize the question, options, and colors?

Everything lives in data-hl-* attributes on the script tag. Edit data-hl-config for the question and the options array, and set data-hl-accent to any CSS color to match your brand. No CSS editing required.

Does it work on WordPress, Shopify, or Squarespace?

Yes. The widget is a single dependency-free vanilla JS file, so it runs anywhere you can paste a script tag — a Custom HTML block in WordPress, Shopify, Squarespace, Webflow, or plain HTML.

More free widgets

Need a custom widget?

We build custom widgets, integrations, and whole products. Tell us what your site needs and we'll scope it.

Get in Touch