StealThis .dev

Remotion — Poll Results Animation

A 4-second Remotion poll results animation featuring a clockwise-revealing donut chart alongside four staggered option rows, each with a colored dot, animated vote count, percentage read-out, and a spring-driven progress bar — winner option highlighted with a subtle glow and badge.

Open Remotion
remotion react typescript
Targets: TS React

Preview

Code

import React from "react";
import {
  AbsoluteFill,
  Composition,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
  Easing,
} from "remotion";

// ── Palette & Config ──────────────────────────────────────────────────────────
const BG_COLOR = "#0a0a0f";

const POLL_QUESTION = "Which feature do you want most?";
const TOTAL_VOTES = 1842;

interface PollOption {
  label: string;
  votes: number;
  pct: number;   // 0–100
  color: string;
  isWinner: boolean;
}

// ── Dataset ───────────────────────────────────────────────────────────────────
const OPTIONS: PollOption[] = [
  { label: "Dark Mode",       votes: 774,  pct: 42, color: "#6366f1", isWinner: true  },
  { label: "API Access",      votes: 516,  pct: 28, color: "#06b6d4", isWinner: false },
  { label: "Mobile App",      votes: 331,  pct: 18, color: "#10b981", isWinner: false },
  { label: "More Templates",  votes: 221,  pct: 12, color: "#f59e0b", isWinner: false },
];

// ── Timing constants ──────────────────────────────────────────────────────────
// Donut reveals from frame 0 to 60
const DONUT_START  = 0;
const DONUT_END    = 60;
// Options stagger in from frame 60, 15-frame gap each
const OPTIONS_START = 60;
const OPTION_STAGGER = 15;

// ── Helper: spring convenience ────────────────────────────────────────────────
function springVal(frame: number, fps: number, delay = 0, cfg?: object): number {
  return spring({
    frame: Math.max(0, frame - delay),
    fps,
    from: 0,
    to: 1,
    config: { damping: 18, stiffness: 100, mass: 0.7, ...cfg },
  });
}

// ── Donut Chart ───────────────────────────────────────────────────────────────

interface DonutProps {
  frame: number;
  fps: number;
}

const Donut: React.FC<DonutProps> = ({ frame, fps }) => {
  const SIZE = 260;
  const cx   = SIZE / 2;
  const cy   = SIZE / 2;
  const R    = 108;
  const stroke = 32;

  // 0→1 progress for full donut reveal
  const revealProgress = spring({
    frame: Math.max(0, frame - DONUT_START),
    fps,
    from: 0,
    to: 1,
    config: { damping: 22, stiffness: 70, mass: 1.1 },
  });

  // Clamp so we never draw more than the full circle
  const clampedProgress = Math.min(1, revealProgress);

  // Build cumulative arcs for each segment
  const circumference = 2 * Math.PI * R;

  // Each segment runs from its cumulative start to end, clipped by revealProgress
  let cumulative = 0;

  // SVG arc segments
  const segments = OPTIONS.map((opt) => {
    const segFraction = opt.pct / 100;
    const segStart    = cumulative;
    const segEnd      = cumulative + segFraction;
    cumulative        = segEnd;

    // How much of this segment is visible
    const visibleEnd   = Math.min(segEnd, clampedProgress);
    const visibleStart = Math.min(segStart, clampedProgress);
    const visibleFrac  = Math.max(0, visibleEnd - visibleStart);

    // strokeDasharray trick for partial arc
    const dashLength = visibleFrac * circumference;
    const dashOffset = -(segStart * circumference);

    return { ...opt, dashLength, dashOffset };
  });

  // Fade in the center text
  const centerOpacity = interpolate(frame, [DONUT_START + 40, DONUT_END + 5], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Total votes count-up
  const displayVotes = Math.round(
    interpolate(frame, [DONUT_START + 40, DONUT_END + 15], [0, TOTAL_VOTES], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.out(Easing.quad),
    })
  );

  // Glow pulse keyed to reveal progress
  const glowOpacity = interpolate(clampedProgress, [0, 0.5, 1], [0, 0.35, 0.2], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        position: "relative",
        width: SIZE,
        height: SIZE,
        flexShrink: 0,
      }}
    >
      {/* Ambient glow behind donut */}
      <div
        style={{
          position: "absolute",
          inset: -30,
          borderRadius: "50%",
          background: "radial-gradient(circle, rgba(99,102,241,0.35) 0%, transparent 70%)",
          opacity: glowOpacity,
          pointerEvents: "none",
        }}
      />

      <svg width={SIZE} height={SIZE} style={{ position: "absolute", top: 0, left: 0 }}>
        {/* Background track */}
        <circle
          cx={cx}
          cy={cy}
          r={R}
          fill="none"
          stroke="rgba(255,255,255,0.06)"
          strokeWidth={stroke}
        />

        {/* Colored segments — drawn clockwise from 12 o'clock (-90°) */}
        {segments.map((seg) => (
          <circle
            key={seg.label}
            cx={cx}
            cy={cy}
            r={R}
            fill="none"
            stroke={seg.color}
            strokeWidth={stroke - 2}
            strokeLinecap="butt"
            strokeDasharray={`${seg.dashLength} ${circumference - seg.dashLength}`}
            strokeDashoffset={-(seg.dashOffset)}
            transform={`rotate(-90 ${cx} ${cy})`}
            style={{ filter: `drop-shadow(0 0 8px ${seg.color}88)` }}
          />
        ))}
      </svg>

      {/* Center text */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          opacity: centerOpacity,
        }}
      >
        <div
          style={{
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 700,
            fontSize: 32,
            color: "#ffffff",
            letterSpacing: "-1px",
            lineHeight: 1,
          }}
        >
          {displayVotes.toLocaleString()}
        </div>
        <div
          style={{
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 500,
            fontSize: 13,
            color: "rgba(255,255,255,0.45)",
            marginTop: 5,
            letterSpacing: "0.5px",
            textTransform: "uppercase",
          }}
        >
          votes
        </div>
      </div>
    </div>
  );
};

// ── Option Row ────────────────────────────────────────────────────────────────

interface OptionRowProps {
  opt: PollOption;
  frame: number;
  fps: number;
  delay: number;
  index: number;
}

const OptionRow: React.FC<OptionRowProps> = ({ opt, frame, fps, delay, index }) => {
  const f = Math.max(0, frame - delay);

  // Slide + fade entrance
  const entranceT = spring({
    frame: f,
    fps,
    from: 0,
    to: 1,
    config: { damping: 20, stiffness: 90, mass: 0.8 },
  });
  const opacity = Math.min(1, entranceT * 1.5);
  const translateX = interpolate(entranceT, [0, 1], [40, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Bar fill width spring (0 → pct%)
  const barFill = spring({
    frame: Math.max(0, f - 6),
    fps,
    from: 0,
    to: opt.pct / 100,
    config: { damping: 18, stiffness: 80, mass: 0.9 },
  });

  // Vote count-up
  const displayVotes = Math.round(
    interpolate(f, [6, 35], [0, opt.votes], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.out(Easing.quad),
    })
  );

  const pctDisplay = Math.round(
    interpolate(f, [6, 35], [0, opt.pct], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: Easing.out(Easing.quad),
    })
  );

  const BAR_HEIGHT = 10;
  const BAR_WIDTH  = 380;

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        gap: 7,
        opacity,
        transform: `translateX(${translateX}px)`,
        padding: "14px 18px",
        borderRadius: 12,
        background: opt.isWinner
          ? `linear-gradient(135deg, ${opt.color}14 0%, transparent 100%)`
          : "transparent",
        border: opt.isWinner
          ? `1px solid ${opt.color}30`
          : "1px solid transparent",
      }}
    >
      {/* Top row: dot + label + winner badge + votes + pct */}
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
        }}
      >
        {/* Colored dot */}
        <div
          style={{
            width: 10,
            height: 10,
            borderRadius: "50%",
            backgroundColor: opt.color,
            boxShadow: `0 0 8px ${opt.color}`,
            flexShrink: 0,
          }}
        />

        {/* Label */}
        <div
          style={{
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: opt.isWinner ? 700 : 600,
            fontSize: 18,
            color: opt.isWinner ? "#ffffff" : "rgba(255,255,255,0.75)",
            flex: 1,
            letterSpacing: "-0.3px",
          }}
        >
          {opt.label}
        </div>

        {/* Winner crown badge */}
        {opt.isWinner && (
          <div
            style={{
              background: `linear-gradient(135deg, ${opt.color} 0%, ${opt.color}aa 100%)`,
              borderRadius: 6,
              padding: "2px 8px",
              fontFamily: "system-ui, -apple-system, sans-serif",
              fontWeight: 700,
              fontSize: 10,
              color: "#fff",
              letterSpacing: "0.8px",
              textTransform: "uppercase",
            }}
          >
            Top
          </div>
        )}

        {/* Vote count */}
        <div
          style={{
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 600,
            fontSize: 15,
            color: "rgba(255,255,255,0.45)",
            minWidth: 60,
            textAlign: "right",
          }}
        >
          {displayVotes.toLocaleString()}
        </div>

        {/* Percentage */}
        <div
          style={{
            fontFamily: "system-ui, -apple-system, sans-serif",
            fontWeight: 700,
            fontSize: 18,
            color: opt.color,
            minWidth: 46,
            textAlign: "right",
            letterSpacing: "-0.5px",
          }}
        >
          {pctDisplay}%
        </div>
      </div>

      {/* Progress bar */}
      <div
        style={{
          width: BAR_WIDTH,
          height: BAR_HEIGHT,
          borderRadius: 6,
          backgroundColor: "rgba(255,255,255,0.07)",
          overflow: "hidden",
          marginLeft: 20,
        }}
      >
        <div
          style={{
            width: `${barFill * 100}%`,
            height: "100%",
            borderRadius: 6,
            background: `linear-gradient(90deg, ${opt.color}cc 0%, ${opt.color} 100%)`,
            boxShadow: `0 0 10px ${opt.color}66`,
            position: "relative",
            overflow: "hidden",
          }}
        >
          {/* Shine */}
          <div
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              right: 0,
              height: "50%",
              background: "linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)",
              borderRadius: "6px 6px 0 0",
            }}
          />
        </div>
      </div>
    </div>
  );
};

// ── Main Composition ──────────────────────────────────────────────────────────

export const PollResults: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Title / question fade + slide in
  const titleOpacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const titleY = interpolate(frame, [0, 20], [-20, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Overall bg glow opacity
  const bgGlow = interpolate(frame, [0, 40], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: BG_COLOR,
        overflow: "hidden",
        fontFamily: "system-ui, -apple-system, sans-serif",
      }}
    >
      {/* Radial background glow */}
      <div
        style={{
          position: "absolute",
          top: "35%",
          left: "20%",
          width: 700,
          height: 500,
          transform: "translate(-50%, -50%)",
          background:
            "radial-gradient(ellipse at center, rgba(99,102,241,0.12) 0%, rgba(6,182,212,0.05) 45%, transparent 70%)",
          opacity: bgGlow,
          pointerEvents: "none",
        }}
      />

      {/* Secondary glow top-right */}
      <div
        style={{
          position: "absolute",
          top: -80,
          right: -80,
          width: 500,
          height: 500,
          background:
            "radial-gradient(circle, rgba(139,92,246,0.08) 0%, transparent 70%)",
          opacity: bgGlow,
          pointerEvents: "none",
        }}
      />

      {/* ── Poll question header ── */}
      <div
        style={{
          position: "absolute",
          top: 48,
          left: 72,
          right: 72,
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
        }}
      >
        <div
          style={{
            fontSize: 13,
            fontWeight: 600,
            color: "rgba(255,255,255,0.35)",
            letterSpacing: "1.5px",
            textTransform: "uppercase",
            marginBottom: 8,
          }}
        >
          Community Poll
        </div>
        <div
          style={{
            fontSize: 30,
            fontWeight: 700,
            color: "#ffffff",
            letterSpacing: "-0.5px",
            lineHeight: 1.1,
          }}
        >
          {POLL_QUESTION}
        </div>
      </div>

      {/* ── Main content row ── */}
      <div
        style={{
          position: "absolute",
          top: 150,
          left: 72,
          right: 72,
          bottom: 60,
          display: "flex",
          alignItems: "center",
          gap: 64,
        }}
      >
        {/* Left: Donut chart */}
        <Donut frame={frame} fps={fps} />

        {/* Vertical divider */}
        <div
          style={{
            width: 1,
            alignSelf: "stretch",
            backgroundColor: "rgba(255,255,255,0.08)",
            flexShrink: 0,
            opacity: bgGlow,
          }}
        />

        {/* Right: Option list */}
        <div
          style={{
            flex: 1,
            display: "flex",
            flexDirection: "column",
            gap: 4,
          }}
        >
          {OPTIONS.map((opt, i) => (
            <OptionRow
              key={opt.label}
              opt={opt}
              frame={frame}
              fps={fps}
              delay={OPTIONS_START + i * OPTION_STAGGER}
              index={i}
            />
          ))}
        </div>
      </div>

      {/* Watermark */}
      <div
        style={{
          position: "absolute",
          bottom: 22,
          right: 72,
          fontSize: 11,
          fontWeight: 400,
          color: "rgba(255,255,255,0.18)",
          letterSpacing: "0.8px",
          opacity: bgGlow,
          textTransform: "uppercase",
        }}
      >
        Fictional data · Stealthis
      </div>
    </AbsoluteFill>
  );
};

// ── Remotion Root ─────────────────────────────────────────────────────────────
export const RemotionRoot: React.FC = () => (
  <Composition
    id="PollResults"
    component={PollResults}
    durationInFrames={120}
    fps={30}
    width={1280}
    height={720}
  />
);

Poll Results Animation

Four fictional feature requests — Dark Mode (42%), API Access (28%), Mobile App (18%), and More Templates (12%) — are presented against a deep cinema-dark background. A donut chart in the left half reveals clockwise over the first 60 frames using a spring()-driven strokeDasharray animation on stacked SVG circles, each segment colored with a distinct vibrant hue (indigo, cyan, emerald, amber). A running vote counter in the donut’s center counts up from zero to 1,842 as the arcs complete, giving the viewer an immediate sense of scale before the breakdown appears.

Starting at frame 60, the four option rows slide in from the right one by one with a 15-frame stagger. Each row contains a glowing colored dot, the option label, an animated vote count that counts up on entrance, a percentage that ticks from 0 to its final value, and a spring()-powered horizontal progress bar with a shine overlay. The winner row (Dark Mode) is distinguished by a faint indigo gradient background, a colored border, and a compact “Top” badge — making the result legible at a glance without being heavy-handed. All entrance animations use spring() for the slide and bar-fill so motion feels physical rather than mechanical.

The layout places the donut and the option list side-by-side separated by a subtle divider line, keeping the composition balanced at 1280 × 720. A soft radial glow behind the donut and a secondary violet glow in the top-right corner add depth to the dark background without distracting from the data.

Composition specs

PropertyValue
Resolution1280 × 720
FPS30
Duration4 s (120 frames)

Data format

All data is defined in the OPTIONS constant near the top of the file. Each entry is a PollOption object:

interface PollOption {
  label: string;   // display name
  votes: number;   // raw vote count
  pct: number;     // 0–100 percentage (should sum to 100)
  color: string;   // hex accent color for dot, bar, and percentage
  isWinner: boolean; // highlights the row with a glow + badge
}

To customise the poll, replace the four entries in OPTIONS, update POLL_QUESTION, and set TOTAL_VOTES to match the sum of all vote counts. Each option’s color should be chosen from the palette constants at the top (indigo #6366f1, cyan #06b6d4, emerald #10b981, amber #f59e0b, violet #8b5cf6, etc.) or any vibrant hex that stands out on the dark background. The OPTION_STAGGER constant controls the per-row delay in frames (default 15), and OPTIONS_START sets the frame at which the first row begins to appear (default 60).