Voting Buttons — Free Upvote Downvote HTML CSS JS Snippet

Voting Buttons · Buttons · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Three-state voting
Up, neutral, and down with click-to-undo on the active arrow.
Immutable base score
Score is base + vote, so rapid toggling never drifts.
Color feedback
Green for positive, red for negative, neutral at zero.
Bump animation
The score lifts and scales on every change via reflow restart.
Accessible buttons
Real buttons with aria-pressed and aria-label for screen readers.
Single vote choke point
All logic flows through cast() — one place to add API calls.
Tactile press
Arrows scale down on :active for a physical feel.
No dependency
Pure HTML/CSS/JS — no framework, no icon library.

About this UI Snippet

Voting Buttons — Upvote / Downvote Score Widget

Screenshot of the Voting Buttons snippet rendered live

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Instead of tracing the toggle logic by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why votes[id] = (votes[id] === dir) ? 0 : dir alone is enough to correctly implement all four transitions (neutral-to-up, up-to-neutral, up-to-down, and back), and why scoreFor() recomputes base + vote on every render instead of mutating a running counter in place. It's also worth asking about the void scoreEl.offsetWidth line — have it explain precisely why that forced reflow is necessary to replay the bump animation on rapid consecutive clicks. For extending it, have it add a "hot"/"top" sort toggle that reorders the list by the derived score, disable voting temporarily while a real API request for that item is in flight, or add a subtle floating "+1"/"-1" indicator that appears and fades near the score on each click. 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 Reddit-style upvote/downvote list widget in plain HTML, CSS, and vanilla JavaScript with no libraries.

Requirements:
- A list of items, each rendered with an upvote button, a numeric score display, and a downvote button, plus a title and metadata line.
- Track each item's current user vote as exactly one of three states (+1, 0, -1) in a single lookup object keyed by item id — do not store separate booleans for "is upvoted" and "is downvoted".
- Implement voting with a single toggle expression: clicking a direction button sets that item's vote to that direction unless it is already set to that same direction, in which case it clears back to neutral (0). Clicking the opposite direction while one is active must switch directly to the opposite value in one click, without requiring the user to first clear the existing vote.
- Compute the displayed score on every render as an immutable base value (representing everyone else's votes) plus only the current user's own vote value — never mutate a counter directly in place, so rapid or repeated toggling can never drift the displayed number away from the true total.
- Style the score text and the active vote button with distinct color states: a positive color when the score is above the base, a negative color when below, and neutral otherwise; the currently active direction button must be visually filled to match.
- Every score change must replay a short "bump" animation (a brief upward lift and scale) even on consecutive clicks to the same element, which requires forcing a synchronous reflow between removing and re-adding the animation class.
- Both direction buttons must be real button elements with an aria-label describing their action and an aria-pressed attribute reflecting whether that direction is the item's current vote.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA list of items renders, each with an upvote arrow, score, and downvote arrow.
  2. 2
    Click upvoteThe arrow fills green and the score increases by one with a bump animation.
  3. 3
    Click the active arrow againYour vote clears and the score returns to its base value.
  4. 4
    Switch directionClicking downvote after an upvote moves the score by two in one step.
  5. 5
    Watch the color statesPositive scores turn green, negative turn red, zero stays neutral.
  6. 6
    Wire to your APISwap the DATA array and POST the new direction inside cast().

Real-world uses

Common Use Cases

Feature request boards
Let users vote ideas up the list, paired with a poll widget.
Comment threads
Score replies the way Reddit does inside a comment thread.
Q&A and forums
Surface the best answers Stack Overflow-style above a rating breakdown.
Product feedback
Collect signal next to an NPS survey or helpful feedback widget.
Changelog reactions
Gauge interest on shipped items in a changelog feed.
Learning the pattern
A clean reference for three-state toggle voting logic.

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.