Subframe
Components & interactionsAnimated poll results

The results are in

A choice turns into a small chart, with result fills and percentages moving together.

Choose a destination

Source: Interior

Add to your project

Component source

Standalone React component. Styles are included.

npm install [email protected]

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * Adapted from https://github.com/ddoemonn/interior/blob/314800833bf01e23e6ae74c2a17fbdaac3688c84/components/interior/poll-results.tsx
 * Retains the poll result derivation, clipped spring fills, MotionValue percentage readouts and winner check.
 * Standalone styles and original demo content; reduced-motion and preview controls.
 *
 * MIT License
 *
 * Copyright (c) 2026 ozzy
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { animate, AnimatePresence, motion, motionValue, useMotionValueEvent, useTransform } from "motion/react"
import { useCallback, useEffect, useRef, useState } from "react"

// Subscribe directly so a changed system preference also stops an active preview.
function useReducedMotion() {
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const preference = window.matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(preference.matches)
    update()
    preference.addEventListener("change", update)
    return () => preference.removeEventListener("change", update)
  }, [])
  return reduced
}

const FILL = { type: "spring", stiffness: 210, damping: 34, mass: 0.9 } as const

const POP = { type: "spring", stiffness: 640, damping: 22, mass: 0.7 } as const
const EASE = [0.23, 1, 0.32, 1] as const
const ENTER = { duration: 0.2, ease: EASE } as const
const STILL = { duration: 0 } as const

export type PollOption = {
  id: string
  label: string
  votes: number
}

export type UsePollResultsOptions = {
  options: PollOption[]

  value?: string | null
  defaultValue?: string | null
  onVote?: (id: string) => void
}

export function usePollResults({ options, value, defaultValue = null, onVote }: UsePollResultsOptions) {
  const [internal, setInternal] = useState<string | null>(defaultValue)
  const controlled = value !== undefined
  const chosen = controlled ? value : internal

  const emit = useRef(onVote)
  emit.current = onVote

  const vote = useCallback(
    (id: string) => {
      if (chosen !== null) return
      if (!controlled) setInternal(id)
      emit.current?.(id)
    },
    [chosen, controlled],
  )

  const total = options.reduce((sum, o) => sum + Math.max(0, o.votes), 0)
  const top = options.reduce((best, o) => (o.votes > best ? o.votes : best), 0)

  const rows = options.map((option) => ({
    ...option,
    share: total > 0 ? Math.max(0, option.votes) / total : 0,
    winner: total > 0 && option.votes === top,
    mine: option.id === chosen,
  }))

  return {
    rows,
    total,
    chosen,
    revealed: chosen !== null,
    vote,
  }
}

const Tick = (
  <svg viewBox="0 0 256 256" width="11" height="11" fill="none" aria-hidden>
    <polyline
      points="216 72 104 184 48 128"
      stroke="currentColor"
      strokeWidth="26"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  </svg>
)

type RowProps = {
  label: string
  share: number
  winner: boolean
  mine: boolean
  revealed: boolean
  reduced: boolean
  onPick: () => void
}

function Row({ label, share, winner, mine, revealed, reduced, onPick }: RowProps) {
  const progress = useRef(motionValue(0)).current
  const clipPath = useTransform(progress, (p) => `inset(0 ${((1 - p) * 100).toFixed(2)}% 0 0 round 5px)`)

  const [landed, setLanded] = useState(false)
  const readout = useRef<HTMLSpanElement | null>(null)

  useMotionValueEvent(progress, "change", (p) => {
    const node = readout.current
    if (!node) return
    const next = `${Math.round(p * 100)}%`
    if (node.textContent !== next) node.textContent = next
  })

  useEffect(() => {
    if (!revealed) return
    if (reduced) {
      progress.jump(share)
      setLanded(true)
      return
    }
    const controls = animate(progress, share, FILL)
    let live = true
    void controls.then(() => {
      if (live) setLanded(true)
    })
    return () => {
      live = false
      controls.stop()
    }
  }, [revealed, share, reduced, progress])

  return (
    <button
      type="button"
      onClick={onPick}
      aria-disabled={revealed}
      aria-pressed={revealed ? mine : undefined}
      aria-label={`${label}${revealed ? `, ${Math.round(share * 100)} percent` : ""}`}
      style={{
        ...button,
        display: "block",
        width: "100%",
        minHeight: 44,
        padding: "0 12px",
        overflow: "hidden",
        textAlign: "left",
        background: revealed ? "#f3f1eb" : "#fff",
        cursor: revealed ? "default" : "pointer",
        boxShadow: "none",
      }}
    >
      <motion.span
        aria-hidden
        style={{ position: "absolute", inset: 3, borderRadius: 6, background: mine ? "#d5e3c5" : "#e2e2d7", clipPath }}
      />
      <span aria-hidden style={{ position: "relative", display: "flex", alignItems: "center", gap: 8 }}>
        <span style={{ flex: 1, fontSize: 12 }}>{label}</span>
        <span style={{ width: 14, display: "grid", placeItems: "center" }}>
          <AnimatePresence initial={false}>
            {revealed && winner && landed && (
              <motion.span
                initial={{ opacity: 0, scale: reduced ? 1 : 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0 }}
                transition={reduced ? STILL : POP}
              >
                {Tick}
              </motion.span>
            )}
          </AnimatePresence>
        </span>
        <motion.span
          ref={readout}
          initial={false}
          animate={{ opacity: revealed ? 1 : 0 }}
          transition={reduced ? STILL : ENTER}
          style={{
            minWidth: 32,
            textAlign: "right",
            fontSize: 11,
            fontVariantNumeric: "tabular-nums",
            fontFamily: "monospace",
          }}
        >
          0%
        </motion.span>
      </span>
    </button>
  )
}

const stage: React.CSSProperties = {
  width: "100%",
  height: "100%",
  minHeight: 260,
  display: "grid",
  placeItems: "center",
  padding: 24,
  boxSizing: "border-box",
  background: "#eeede8",
  color: "#343832",
  fontFamily: "inherit",
}
const button: React.CSSProperties = {
  position: "relative",
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  gap: 10,
  minHeight: 46,
  padding: "0 20px",
  border: "1px solid #cfcec7",
  borderRadius: 10,
  background: "#fff",
  color: "inherit",
  font: "inherit",
  fontSize: 13,
  fontWeight: 500,
  cursor: "pointer",
  outlineOffset: 4,
  touchAction: "manipulation",
  boxShadow: "0 2px 3px #252a2010",
}

const OPTIONS = [
  { id: "forest", label: "A cabin in the woods", votes: 42 },
  { id: "coast", label: "Somewhere by the sea", votes: 35 },
  { id: "city", label: "A city to get lost in", votes: 23 },
]

function PollCard({ active }: { active: boolean }) {
  const [chosen, setChosen] = useState<string | null>(null)
  const options = OPTIONS.map((option) => ({ ...option, votes: option.votes + (chosen === option.id ? 1 : 0) }))
  const poll = usePollResults({ options, value: chosen, onVote: setChosen })
  const reduced = useReducedMotion() !== false || !active
  return (
    <>
      <p style={{ margin: "0 0 16px", fontSize: 13, fontWeight: 500 }}>Where would you rather be?</p>
      <div role="group" aria-label="Choose your ideal getaway" style={{ display: "grid", gap: 7 }}>
        {poll.rows.map((row) => (
          <Row key={row.id} {...row} revealed={poll.revealed} reduced={reduced} onPick={() => poll.vote(row.id)} />
        ))}
      </div>
      <p role="status" style={{ fontSize: 10, color: "#606e4e", margin: "12px 0 0", minHeight: 14 }}>
        {poll.revealed
          ? `${poll.total} votes · ${poll.rows.find((row) => row.winner)?.label} leads`
          : "Choose one to see the results"}
      </p>
    </>
  )
}

export default function PollResultsExample({ active = true }: { active?: boolean }) {
  const [version, setVersion] = useState(0)
  return (
    <div style={{ ...stage, background: "#eeeede" }}>
      <div style={{ width: "100%", maxWidth: 284 }}>
        <PollCard key={version} active={active} />
        <button
          type="button"
          onClick={() => setVersion((value) => value + 1)}
          style={{
            ...button,
            minHeight: 44,
            marginTop: 6,
            padding: 0,
            background: "transparent",
            border: 0,
            boxShadow: "none",
            fontSize: 10,
            color: "#606e4e",
          }}
        >
          Reset poll
        </button>
      </div>
    </div>
  )
}

Where it works well

Reveal poll results after a vote while keeping the chosen option visible.

Reduced motion

Bars and percentages jump to their final values; the winner check appears without scaling.

Things to know

Votes are local illustrative data. Replace them with server results and preserve the one-vote guard.

More examples