More Cards Snippets
Poll Widget — Free HTML CSS JS Voting Snippet
Poll Widget · Cards · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Poll Widget — Click to Vote, Animated Progress Bars, Winner Highlight & Vote Count

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:
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
- 1Click 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.
- 2Click 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.
- 3Update 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.
- 4Seed 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.
- 5Connect 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().
- 6Export 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
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).