Single Choice Poll with Animated Result Bars

Two poll cards in a bold, hard-edged style. The first is open: four options as large radio rows, a Cast vote button and a closing time. After voting, every row becomes a result. Its background fills from the left to that option's share, the percentage counts up beside it, and your own choice is marked in yellow with a tick. A Change my vote button takes you back. The second card shows a closed poll whose bars fill in when the page loads. On phones the cards stack and the rows stay full width.

Code

// Adapted from Cult UI (https://cult-ui.com/docs/components/choice-poll)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useState } from "react";
import { Check } from "lucide-react";

type Option = { id: string; label: string; votes: number };

const openPoll: { question: string; closes: string; options: Option[] } = {
  question: "Which talk should open the autumn meetup?",
  closes: "Voting closes Friday at noon",
  options: [
    { id: "type", label: "Variable fonts in production", votes: 64 },
    { id: "motion", label: "Motion that respects the reader", votes: 91 },
    { id: "grid", label: "Subgrid, two years on", votes: 38 },
    { id: "color", label: "Wide gamut colour without tears", votes: 47 },
  ],
};

const closedPoll: { question: string; options: Option[]; winner: string } = {
  question: "Where should the summer social be?",
  winner: "rooftop",
  options: [
    { id: "rooftop", label: "The rooftop at Calder Street", votes: 118 },
    { id: "canal", label: "Canal boat, two hours", votes: 74 },
    { id: "bowling", label: "Lawn bowling club", votes: 43 },
  ],
};

/** Counts from 0 up to `value` once `run` is true, easing out. */
function useCountUp(value: number, run: boolean) {
  const [shown, setShown] = useState(0);
  useEffect(() => {
    if (!run) {
      setShown(0);
      return;
    }
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setShown(value);
      return;
    }
    let frame = 0;
    const t0 = performance.now();
    const tick = (now: number) => {
      const p = Math.min(1, (now - t0) / 700);
      setShown(value * (1 - Math.pow(1 - p, 3)));
      if (p < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [value, run]);
  return shown;
}

function ResultRow({ option, total, highlight, run }: { option: Option; total: number; highlight: boolean; run: boolean }) {
  const pct = total ? (option.votes / total) * 100 : 0;
  const shown = useCountUp(pct, run);
  return (
    <li className="relative flex min-h-[52px] items-center gap-3 overflow-hidden border-2 border-black bg-white px-4 py-3">
      {/* The bar is the row's own background, filling from the left. */}
      <span aria-hidden="true" className={`absolute inset-y-0 left-0 ${highlight ? "bg-yellow-300" : "bg-neutral-200"}`} style={{ width: `${shown}%` }} />
      <span className="relative flex-1 text-sm font-semibold text-black">{option.label}</span>
      {highlight && (
        <span className="relative flex h-5 w-5 items-center justify-center rounded-full bg-black text-white">
          <Check aria-hidden="true" className="h-3 w-3" strokeWidth={3} />
        </span>
      )}
      <span className="relative w-10 text-right text-sm font-bold tabular-nums text-black">{Math.round(shown)}%</span>
    </li>
  );
}

export default function SingleChoicePollWithAnimatedResultBars() {
  const name = useId();
  const [choice, setChoice] = useState<string>("motion");
  const [voted, setVoted] = useState(false);
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    const t = window.setTimeout(() => setMounted(true), 350);
    return () => window.clearTimeout(t);
  }, []);

  const options = openPoll.options.map((o) => (voted && o.id === choice ? { ...o, votes: o.votes + 1 } : o));
  const total = options.reduce((n, o) => n + o.votes, 0);
  const closedTotal = closedPoll.options.reduce((n, o) => n + o.votes, 0);

  const card = "border-2 border-black bg-white p-5 shadow-[6px_6px_0_0_#000] sm:p-7";

  return (
    <section className="bg-orange-100 px-4 py-16 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-5xl">
        <h2 className="text-3xl font-black uppercase tracking-tight text-black sm:text-5xl">Have your say</h2>
        <p className="mt-3 max-w-lg text-base font-medium text-neutral-800">Members decide the programme. One vote each, and you can change it until the poll closes.</p>

        <div className="mt-10 grid items-start gap-8 lg:grid-cols-2">
          <div className={card}>
            <p className="text-xs font-bold uppercase tracking-widest text-neutral-700">Open now</p>
            {voted ? (
              <div>
                <h3 className="mt-2 text-xl font-bold text-black">{openPoll.question}</h3>
                <ul className="mt-5 space-y-2.5" aria-label="Results so far">
                  {options.map((o) => (
                    <ResultRow key={o.id} option={o} total={total} highlight={o.id === choice} run />
                  ))}
                </ul>
                <div className="mt-5 flex flex-wrap items-center justify-between gap-3">
                  <p className="text-sm font-medium text-neutral-800" role="status">Thanks. {total} votes so far.</p>
                  <button type="button" onClick={() => setVoted(false)} className="h-10 border-2 border-black px-4 text-sm font-bold text-black transition-colors hover:bg-black hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2">
                    Change my vote
                  </button>
                </div>
              </div>
            ) : (
              <form
                onSubmit={(e) => {
                  e.preventDefault();
                  setVoted(true);
                }}
              >
                <fieldset>
                  <legend className="mt-2 text-xl font-bold text-black">{openPoll.question}</legend>
                  <div className="mt-5 space-y-2.5">
                    {openPoll.options.map((o) => {
                      const selected = choice === o.id;
                      return (
                        <label key={o.id} htmlFor={`${name}-${o.id}`} className={`flex min-h-[52px] cursor-pointer items-center gap-3 border-2 border-black px-4 py-3 transition-colors focus-within:ring-2 focus-within:ring-black focus-within:ring-offset-2 ${selected ? "bg-yellow-300" : "bg-white hover:bg-neutral-100"}`}>
                          <input id={`${name}-${o.id}`} type="radio" name={name} value={o.id} checked={selected} onChange={() => setChoice(o.id)} className="sr-only" />
                          <span aria-hidden="true" className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-black transition-colors ${selected ? "bg-black text-white" : "bg-white text-transparent"}`}>
                            <Check className={`h-3 w-3 transition-transform duration-200 motion-reduce:transition-none ${selected ? "scale-100" : "scale-0"}`} strokeWidth={3} />
                          </span>
                          <span className="text-sm font-semibold text-black">{o.label}</span>
                        </label>
                      );
                    })}
                  </div>
                </fieldset>
                <div className="mt-5 flex flex-wrap items-center justify-between gap-3">
                  <p className="text-sm font-medium text-neutral-800">{openPoll.closes}</p>
                  <button type="submit" className="h-11 bg-black px-6 text-sm font-bold uppercase tracking-wide text-white transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 motion-reduce:transition-none">
                    Cast vote
                  </button>
                </div>
              </form>
            )}
          </div>

          <div className={card}>
            <p className="text-xs font-bold uppercase tracking-widest text-neutral-700">Closed ยท {closedTotal} votes</p>
            <h3 className="mt-2 text-xl font-bold text-black">{closedPoll.question}</h3>
            <ul className="mt-5 space-y-2.5" aria-label="Final results">
              {closedPoll.options.map((o) => (
                <ResultRow key={o.id} option={o} total={closedTotal} highlight={o.id === closedPoll.winner} run={mounted} />
              ))}
            </ul>
            <p className="mt-5 text-sm font-medium text-neutral-800">The rooftop it is. Thursday 17 July, from six.</p>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/single-choice-poll-with-animated-result-bars.json

When to use it

How to customize it

Accessibility

Questions

How do the bars and numbers animate together?

One hook, `useCountUp`, eases a number from zero to the option's percentage over 700 milliseconds. That single number is used for both the width of the bar and the rounded figure shown, so they can never drift apart.

Why is the bar the background of the row?

It keeps the label, the bar and the figure on one line, which is easier to scan than a separate thin bar under each option, and it leaves room for long labels.

How do I stop people voting twice?

That has to be done on your server, against an account or a signed cookie. The component only records the choice in state. Send it to your endpoint in the form's `onSubmit` and render the totals that come back.

Can people pick more than one option?

Change the inputs to `type="checkbox"`, keep the choices in an array, and work out each percentage from the number of voters instead of the number of votes.

Do the percentages always add up to 100?

Each is rounded separately, so the total can be 99 or 101. If that matters, round all but the largest and give the largest whatever is left.

Adapted from Choice Poll in Cult UI, used under its open-source licence. Licences and credits

Related: Contact Form with Topic Select and Details Panel, Feature Request Voting Board with Live Reordering, Settings Panel with Toggle Switches and Save Bar.

More forms or all free UI components. Start for free to have a full site built around it.

More in Forms

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap