Poll Widget — Free HTML CSS JS Voting Snippet

Poll Widget · Cards · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

CSS custom property --pct on each bar: transition:width 0.5s ease animates on update
Seeded starting data: SEED array gives realistic proportions before voting
Winner highlight: .winner class (green) applied to option(s) with max votes
One-vote enforcement: myVote !== null check + disabled on all buttons after vote
Reset: restores SEED counts and clears myVote — re-enables voting
Vote count footer: shows total votes with toLocaleString formatting
Grid layout per option: label / bar / percentage in 3 columns

About this UI Snippet

Poll Widget — Click to Vote, Animated Progress Bars, Winner Highlight & Vote Count

Screenshot of the Poll Widget snippet rendered live

A poll that opens at "0 votes, 0%, 0%, 0%, 0%" feels like a room nobody else has walked into yet — and that emptiness is itself a reason not to vote. The polls that *do* drive engagement, on community sites and product pages alike, almost always show a confident, populated result the instant they load, with bars that animate into place the moment you add your voice to the count. This snippet recreates that — a question card with four options, realistic seeded vote counts, click-to-vote with smoothly animating progress bars, a green "winner" highlight, a running total, and a reset button for demoing the before-and-after.

Animating a CSS custom property instead of `width` directly

Each result bar is a <span> with width: var(--pct) and transition: width 0.5s ease in its CSS, while the JavaScript only ever calls element.style.setProperty('--pct', pct + '%'). That indirection is the whole trick: writing to a custom property updates a value that CSS itself is watching, so the browser's transition engine smoothly interpolates from the old percentage to the new one without a single line of animation code on the JavaScript side. The same property-driven approach shows up in the Sparkline Chart and Donut Chart snippets — hand the *value* to CSS and let CSS own the *motion*.

Why the poll opens with votes already in it

The SEED array — [142, 67, 58, 43] — exists purely to solve the cold-start problem: a poll showing a believable 142-to-43 spread looks like something people actually care about, while one showing four flat zero-bars looks abandoned. Casting your own vote simply increments the seeded count rather than starting a new one, and reset() restores the original SEED array — handy for replaying the demo, and a pattern worth borrowing any time you're building a component that needs to look "alive" before a single real user has interacted with it.

Highlighting a winner only once there's something to win

Before anyone votes, every option renders identically — there's no winner yet, so nothing should look like one. The moment vote() runs, render() finds Math.max(...votes) and applies the .winner class (green border, green bar, green percentage) to whichever option(s) match it — plural, deliberately, since a tie means every tied option gets to be "the winner" rather than arbitrarily picking one. That small bit of correctness is easy to skip and immediately noticeable when it's missing.

One vote, then the door closes

A single module-level variable, myVote, remembers which option — if any — the current visitor chose. The vote() function's very first line is a guard: if (myVote !== null) return, and every button gets disabled once a vote is cast. It's the simplest possible implementation of "one vote per person," and the FAQ below covers the natural next step — persisting that choice to localStorage so a page refresh can't be used to vote again.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to trace the CSS custom property trick by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the bar animates smoothly when JavaScript only calls setProperty on a --pct custom property rather than writing to style.width directly, and why the SEED array of realistic starting vote counts matters for engagement compared to starting every option at zero. The same assistant can help optimize it, for example checking whether the myVote guard correctly blocks a double-vote in every code path, or whether Math.max(...votes) for finding the winner handles a tie between two or more options correctly. It's also useful for extending the effect: ask it to persist the vote to localStorage so a page refresh cannot be used to vote twice, connect it to a real backend endpoint and WebSocket for live updates, or add a countdown showing when the poll closes. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a click-to-vote poll widget in plain HTML, CSS, and vanilla JavaScript with animated result bars, using only a CSS custom property to drive the animation — no JavaScript animation loop.

Requirements:
- A poll card with a question and at least four selectable options, each rendered as a button containing a label, a progress bar track with an inner fill element, and a percentage label.
- Seed the vote counts with realistic non-zero starting numbers (not all zeros) so the poll looks already-populated and credible before anyone votes.
- Each bar fill element's width must be driven entirely by a CSS custom property (for example --pct) referenced in the CSS as width: var(--pct), with a CSS transition on width — JavaScript must only ever call element.style.setProperty to update that custom property, never touch style.width directly, so the smooth animation comes from CSS alone.
- Clicking any option must: increment that option's vote count, recompute every option's percentage of the new total, update every bar's custom property and percentage label, disable all the vote buttons so a second vote cannot be cast in the same session, and visually highlight whichever option (or options, in the event of a tie) currently has the maximum vote count as the "winner" with a distinct color.
- A running total vote count displayed below the options that updates every time a vote is cast, with correct singular/plural wording (1 vote vs 2 votes).
- A reset control that restores the original seeded vote counts, clears whichever option the user had selected, and re-enables voting.

Step by step

How to Use

  1. 1
    Click any option to cast your voteClicking an option increments its count, animates all progress bars to their new percentages, and highlights the winning option in green. Voting disables all buttons so only one vote per session is possible.
  2. 2
    Click Reset to undo your voteThe Reset button restores the seeded starting counts and re-enables voting. Use this to demonstrate the before/after state or to let multiple users test the snippet.
  3. 3
    Update the poll question and optionsEdit .poll-q text for the question. Add or remove .poll-opt buttons — increment data-id for each new option. Update the SEED array to match your starting vote counts.
  4. 4
    Seed with realistic dataUpdate const SEED = [N,N,N,N] with real vote counts from your database. This shows users a credible result distribution before they vote, increasing engagement compared to starting from zero.
  5. 5
    Connect to a real voting APIIn vote(), replace the local votes[id]++ with fetch("/api/poll/vote", { method:"POST", body: JSON.stringify({ optionId: id }) }). On page load, fetch current counts and set votes = apiCounts before calling render().
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component with useState for votes and myVote, or "Tailwind" for a React + Tailwind CSS version.

Real-world uses

Common Use Cases

Community and forum opinion polls
Embed polls in forum threads, blog posts, and community pages to collect quick audience opinions. The seeded data prevents the "empty poll" effect where bars show 0% before the first votes come in.
Product feedback and feature preference surveys
Ask users which features to build next, which design direction to take, or which pricing tier makes sense. The instant visual result encourages voting by showing users they are contributing to a real distribution.
Real-time poll with WebSocket vote updates
Connect to a WebSocket that broadcasts new votes. On each message, update the votes array and call render(). All connected users see bars animate in real time as votes come in — no page reload needed.
Newsletter and email campaign vote widget
Embed the poll as a standalone page linked from an email campaign. Track which option link is clicked as the vote. Show the poll result page with all votes visible when the user arrives.
Study CSS custom property animation technique
The progress bar uses style.setProperty("--pct", pct+"%") to update a CSS variable, which drives the bar width via transition. This pattern avoids direct style manipulation and lets CSS handle the animation cleanly.
Live event audience interaction and Q&A polls
Show a poll during a presentation or live stream. Audience members vote on their phones while the presenter screen shows the results updating in real time. The winner highlight announces the result visually.

Got questions?

Frequently Asked Questions

Each .opt-bar has width: var(--pct) in CSS and transition: width 0.5s ease. When JavaScript calls element.style.setProperty("--pct", "42%"), the CSS variable updates, which changes the computed width from the old value to "42%". The CSS transition animates between the old and new computed width values over 0.5 seconds. This approach avoids JavaScript animation loops and keeps all animation logic in CSS.

Store the voted state in localStorage: localStorage.setItem("poll_voted_" + pollId, optionId). On page load: const saved = localStorage.getItem("poll_voted_" + pollId); if (saved !== null) { myVote = +saved; render(); }. This persists the vote across refreshes. For stronger enforcement, use a server-side vote record with the user's IP or account ID.

Remove the onclick handler from .poll-opt buttons and add pointer-events: none to the CSS. Or add a "Show results" button that calls render() with myVote set to -1 (a sentinel value that shows bars without highlighting a winner). This lets users see the distribution without casting a vote.

Click "JSX" to download. Manage votes (array of numbers) and myVote (number|null) with useState. The vote function: setVotes(prev => { const next=[...prev]; next[id]++; return next; }); setMyVote(id). Derive total, percentages, and maxVotes from the votes array with useMemo. Pass a resetPoll prop that calls setVotes(SEED) and setMyVote(null).