All patterns

Poll Vote

Casting a vote fills every bar to its share and the percentages arrive behind them.

socialenergeticfriendlyinteraction · finite · starter · ~0.7s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

272 lines · react + motion only
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Poll Vote
 *
 * Before the vote a poll is a list of choices; after it, a chart. The
 * option you picked fills first and the rest follow a beat later — the
 * order is the acknowledgement, and it lands before any number does.
 *
 * Bars grow with scaleX from the leading edge, so the whole reveal
 * stays on the compositor, and every label sits outside its bar: no
 * element containing text is ever scaled.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `question`, `options`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PollOption = {
  id: string;
  label: string;
  /** Votes already cast for this option. */
  votes: number;
};

export type PollVoteFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  question?: string;
  options?: PollOption[];
  /** Shown under the results. */
  closesIn?: string;
  /** Fires with the id of the chosen option. */
  onVote?: (id: string) => void;
};

type VariantConfig = {
  /** How long a bar takes to reach its share. */
  fillSeconds: number;
  /** Delay before the options you did not pick begin. */
  followDelay: number;
  /** px the percentage travels as it arrives. */
  numberTravel: number;
};

// A tween, never a spring: a bar that overshoots its share is a bar
// showing a number that is not true, even for 120ms. Variants change
// pace and the size of the beat between the chosen bar and the rest.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and level. For a poll embedded in a dense feed.
  subtle: { fillSeconds: 0.4, followDelay: 0.05, numberTravel: 3 },
  // A readable beat between your choice and the rest. All-purpose.
  default: { fillSeconds: 0.55, followDelay: 0.1, numberTravel: 5 },
  // A longer fill and a wider beat, for a poll that is the whole card.
  playful: { fillSeconds: 0.7, followDelay: 0.16, numberTravel: 7 },
};

const ACCENT = "#7C7CF0";
const EASE = [0.22, 0.68, 0, 1] as const;

const OPTIONS: PollOption[] = [
  { id: "tue", label: "Tuesday morning", votes: 412 },
  { id: "wed", label: "Wednesday afternoon", votes: 268 },
  { id: "thu", label: "Thursday, any time", votes: 521 },
];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function PollVoteFill({
  variant = "default",
  question = "When should we run the team sync?",
  options = OPTIONS,
  closesIn = "2 days left",
  onVote,
}: PollVoteFillProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [choice, setChoice] = useState<string | null>(null);

  const totals = options.map(
    (option) => option.votes + (choice === option.id ? 1 : 0)
  );
  const sum = totals.reduce((running, votes) => running + votes, 0);
  const voted = choice !== null;

  const cast = (id: string) => {
    if (voted) return;
    setChoice(id);
    onVote?.(id);
  };

  return (
    <div
      style={{
        width: 310,
        padding: 18,
        borderRadius: 16,
        border: `1px solid ${tone(11)}`,
        background: tone(4),
        fontSize: 13.5,
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
        <span
          aria-hidden
          style={{
            display: "grid",
            placeItems: "center",
            width: 28,
            height: 28,
            borderRadius: "50%",
            background: "#4AA3B8",
            color: "#ffffff",
            fontSize: 11,
            fontWeight: 650,
          }}
        >
          ID
        </span>
        <div style={{ fontSize: 12, opacity: 0.55 }}>Ines Duarte asked</div>
      </div>

      <div style={{ margin: "10px 0 14px", fontSize: 14.5, fontWeight: 600, lineHeight: 1.4 }}>
        {question}
      </div>

      <div
        role="group"
        aria-label={question}
        style={{ display: "flex", flexDirection: "column", gap: 8 }}
      >
        {options.map((option, index) => {
          const share = voted ? totals[index] / sum : 0;
          const chosen = choice === option.id;
          const delay = !voted || reduceMotion ? 0 : chosen ? 0 : cfg.followDelay;
          const percent = Math.round(share * 100);

          return (
            <button
              key={option.id}
              type="button"
              onClick={() => cast(option.id)}
              disabled={voted}
              aria-pressed={chosen}
              style={{
                position: "relative",
                display: "flex",
                alignItems: "center",
                gap: 10,
                width: "100%",
                padding: "11px 13px",
                borderRadius: 11,
                border: `1px solid ${chosen ? ACCENT : tone(13)}`,
                background: "none",
                color: "inherit",
                fontFamily: "inherit",
                fontSize: 13.5,
                textAlign: "left",
                cursor: voted ? "default" : "pointer",
                overflow: "hidden",
              }}
            >
              {/* The bar lives behind the label rather than around it,
                  so scaling it never touches a glyph. */}
              <motion.span
                aria-hidden
                initial={{ scaleX: 0 }}
                animate={{ scaleX: share }}
                transition={{
                  duration: reduceMotion ? 0 : cfg.fillSeconds,
                  ease: EASE,
                  delay,
                }}
                style={{
                  position: "absolute",
                  inset: 0,
                  transformOrigin: "left center",
                  background: chosen
                    ? `color-mix(in srgb, ${ACCENT} 32%, transparent)`
                    : tone(9),
                }}
              />

              <span style={{ position: "relative", flex: 1, fontWeight: chosen ? 600 : 400 }}>
                {option.label}
              </span>

              {/* Both slots are reserved from the start, so revealing
                  them never reflows the label beside them. */}
              <span
                style={{
                  position: "relative",
                  display: "flex",
                  alignItems: "center",
                  gap: 6,
                }}
              >
                <motion.span
                  aria-hidden
                  initial={false}
                  animate={{ opacity: chosen ? 1 : 0, scale: chosen ? 1 : 0.7 }}
                  transition={{
                    duration: reduceMotion ? 0.12 : 0.22,
                    ease: "easeOut",
                    delay: chosen && !reduceMotion ? cfg.fillSeconds * 0.3 : 0,
                  }}
                  style={{
                    display: "grid",
                    placeItems: "center",
                    width: 15,
                    height: 15,
                    color: ACCENT,
                  }}
                >
                  <svg width="15" height="15" viewBox="0 0 16 16" fill="none">
                    <path
                      d="M3.2 8.3 6.4 11.5 12.8 4.6"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </motion.span>

                {/* The percentage is text: it fades and travels a few
                    pixels, and it never scales. */}
                <motion.span
                  aria-hidden
                  initial={false}
                  animate={{
                    opacity: voted ? 0.85 : 0,
                    y: voted || reduceMotion ? 0 : cfg.numberTravel,
                  }}
                  transition={{
                    duration: reduceMotion ? 0.14 : 0.26,
                    ease: "easeOut",
                    delay: voted && !reduceMotion ? delay + cfg.fillSeconds * 0.45 : 0,
                  }}
                  style={{
                    minWidth: 30,
                    fontSize: 12.5,
                    fontWeight: 600,
                    fontVariantNumeric: "tabular-nums",
                    textAlign: "right",
                  }}
                >
                  {percent}%
                </motion.span>
              </span>
            </button>
          );
        })}
      </div>

      <div
        role="status"
        style={{ marginTop: 12, fontSize: 11.5, opacity: 0.5 }}
      >
        {voted
          ? `${sum.toLocaleString()} votes · ${closesIn}`
          : `Pick one · ${closesIn}`}
      </div>
    </div>
  );
}

About this pattern

Before the vote a poll is a list of choices; after it, it is a chart. The transition has to do both jobs at once, so the option you picked fills first and the others follow a beat later — the order is the acknowledgement, and it lands before any number does. Bars grow with scaleX from the leading edge, which keeps the whole reveal on the compositor, and the labels sit outside the bar so nothing containing text is ever scaled.

Voting in a pollSurvey results revealTeam preference checkReader sentiment on a post

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Dana Whitfield
    Priya Raman
    Ops standup
    Marcus Bell
    Design sync
    Nils Bergström
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    The chosen option is acknowledged before the totals are read out.

Related patterns