More Buttons Snippets
Voting Buttons — Free Upvote Downvote HTML CSS JS Snippet
Voting Buttons · Buttons · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Voting Buttons — Upvote / Downvote Score Widget

Voting buttons are the up/down arrows beside a post, comment, or feature request that let users rate it and surface the best content — the pattern Reddit, Stack Overflow, and Product Hunt are built on. This snippet implements the full interaction in plain HTML, CSS, and vanilla JavaScript: an upvote and downvote arrow, a live score between them, three-state toggle logic, and color feedback, with no framework or dependency.
Three-state toggle logic
Each row tracks one of three states — upvoted (+1), neutral (0), or downvoted (-1) — in a votes map keyed by item id. The key behaviour is that clicking the *already-active* arrow clears the vote rather than re-applying it: votes[id] = (votes[id] === dir) ? 0 : dir. That single expression handles every transition (up→neutral, up→down, neutral→down, and back) the way users expect, so there is no separate "remove vote" button.
Score derived from a base, never mutated
The displayed number is computed as base + vote, where base is the server-side tally and vote is only the current user's contribution. Keeping the base immutable and deriving the score on every render means the count is always correct after any sequence of clicks — you can't drift out of sync by toggling rapidly, because nothing is incremented in place.
Color and motion feedback
The score turns green when positive, red when negative, and neutral at zero, and the active arrow fills with a matching tint via the .on class. Every change triggers a short vbBump keyframe — the number lifts and scales briefly — restarted reliably with the void el.offsetWidth reflow trick so the animation replays even on consecutive clicks. The arrows also scale down on :active for a tactile press.
Accessible by default
Arrows are real <button> elements with aria-label and aria-pressed reflecting the current vote, so screen readers announce "Upvote, pressed" correctly and keyboard users can Tab and activate them with Enter or Space — no extra wiring needed.
Wiring to real data
Replace the DATA array with your posts and send a request in cast() (the single choke point for every vote) to POST the new direction; optimistic UI already updates instantly, so you only need to reconcile on error. The scoreFor() helper is pure, making it trivial to also sort the list by score for a "hot" or "top" ranking.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA list of items renders, each with an upvote arrow, score, and downvote arrow.
- 2Click upvoteThe arrow fills green and the score increases by one with a bump animation.
- 3Click the active arrow againYour vote clears and the score returns to its base value.
- 4Switch directionClicking downvote after an upvote moves the score by two in one step.
- 5Watch the color statesPositive scores turn green, negative turn red, zero stays neutral.
- 6Wire to your APISwap the DATA array and POST the new direction inside cast().
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Voting is three-state. The cast() function uses votes[id] = (votes[id] === dir) ? 0 : dir, so clicking the arrow you already selected sets the vote back to neutral and the score returns to its base. Clicking the opposite arrow switches direction in a single step, moving the displayed score by two.
The displayed number is base + vote, where base is the server tally and vote is just this user's -1, 0, or +1. Deriving it on every render means no sequence of rapid clicks can corrupt the count, which is a common bug when you mutate a counter directly on each press.
Every vote flows through cast(), so it is the only place you add a request. POST the item id and new direction, update the UI optimistically (it already does), and roll back only if the request fails. Because the base is separate from the user's vote, reconciling the server's true total is straightforward.
Yes. The arrows are native button elements with aria-label and aria-pressed set to the current state, so they are focusable, operable with Enter or Space, and announced as pressed or not pressed. No custom key handling or roles are required.
Hold the votes map in state (useState in React, a ref or reactive object in Vue, a component property in Angular) and render the score as base + vote. Move the cast() toggle into an event handler that also calls your API. In Tailwind, apply the green and red tints with conditional utility classes based on the vote value instead of the .on class.