DEV3 Technologies home

Upvote button with live count

A toggle upvote button that updates the count instantly, confirms with the server and rolls back on failure, styled by aria-pressed.

Tailwind CSSFeedback and empty statesupvotebuttonoptimistic-uiaccessibility

Live preview

Code

<!-- Upvote button: optimistic UI, rolls back if the request fails -->
<button type="button" data-vote-url="/api/ideas/42/vote" aria-pressed="false"
  class="group inline-flex flex-col items-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-600 transition
         hover:border-indigo-400 aria-pressed:border-indigo-500 aria-pressed:bg-indigo-50 aria-pressed:text-indigo-700">
  <svg viewBox="0 0 20 20" class="size-4 fill-current" aria-hidden="true"><path d="M10 4 3 13h14z"/></svg>
  <span data-count class="text-sm font-semibold tabular-nums">24</span>
  <span class="sr-only">upvotes</span>
</button>

<script>
document.querySelectorAll('[data-vote-url]').forEach((btn) => {
  const countEl = btn.querySelector('[data-count]');
  btn.addEventListener('click', async () => {
    const wasOn = btn.getAttribute('aria-pressed') === 'true';
    const before = Number(countEl.textContent);
    // Optimistic update first, so the UI feels instant
    btn.setAttribute('aria-pressed', String(!wasOn));
    countEl.textContent = before + (wasOn ? -1 : 1);
    btn.disabled = true;
    try {
      const res = await fetch(btn.dataset.voteUrl, { method: wasOn ? 'DELETE' : 'POST' });
      if (!res.ok) throw new Error(res.status);
      const data = await res.json();          // server is the source of truth
      countEl.textContent = data.count;
    } catch {
      btn.setAttribute('aria-pressed', String(wasOn)); // roll back
      countEl.textContent = before;
    } finally {
      btn.disabled = false;
    }
  });
});
</script>

How it works

What it solves

Voting feels broken when the number only changes after a network round trip. This button changes immediately, then reconciles with the server so the displayed count can never drift for long.

How it works

State lives in one attribute: aria-pressed. The same attribute that tells assistive technology the toggle is on also drives the look, because Tailwind's aria-pressed: variants restyle the border, background and colour. No extra class juggling is needed.

On click the handler stores the previous state, flips the attribute and adjusts the number, then disables the button while a POST (to vote) or DELETE (to withdraw) runs. A successful response replaces the optimistic number with the server's count, which accounts for other people voting in the meantime. If the request throws or returns an error status, the handler restores both the attribute and the old count. The finally block always re-enables the button, which also blocks double clicks mid-request.

Adapting it

  • Point data-vote-url at your own route and return JSON containing count.
  • Render the initial pressed state and count from the server for each idea.
  • Add a CSRF header if your framework requires one on write requests.

Pitfalls

  • Without authentication, ensure the server also de-duplicates votes; the client is not a gate.
  • The tabular-nums class keeps the button from changing width as digits change.
  • Use a real button element, never a styled div, to get keyboard support for free.

How to use it

  1. Add the button markup to each idea row, with its own vote URL and starting count.
  2. Include the script once per page; it binds every element with data-vote-url.
  3. Implement the endpoint to accept POST and DELETE and return the new count as JSON.
  4. Render aria-pressed="true" on load for ideas the current user has already voted on.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets