Live Scoreboard Widget with Flip Digit Scores

A live match card styled like an arena scoreboard. Each team has a coloured crest, a three-letter name and a two-digit score shown on dark split-flap tiles with a seam through the middle. When a score changes, only the digits that changed tip away and the new ones swing up. A yellow dot marks which side has the ball. The footer shows the quarter, a row of period dots and the series score; hovering or pressing it slides a green strip into view with the next fixture. The demo plays a short run of baskets on a loop. The card is 340 pixels wide at most and fits small phones.

Code

// Adapted from Animata (https://animata.design/docs/widget/live-score)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

const home = { name: "Harriers", short: "HAR", crest: "bg-orange-500" };
const away = { name: "Kestrels", short: "KES", crest: "bg-sky-500" };
const QUARTERS = 4;
const TICK_MS = 1500; // demo only: how often a basket goes in
const nextFixture = "Next: Harriers at Otters, Saturday 7:30 pm";

type Game = { home: number; away: number; quarter: number; possession: "home" | "away"; plays: number };
const opening: Game = { home: 47, away: 44, quarter: 3, possession: "home", plays: 0 };

/** One split-flap digit: the old number tips away over the top edge and the new one swings up from below. */
function FlipDigit({ value, still }: { value: string; still: boolean }) {
  return (
    <span className="relative flex h-24 w-14 items-center justify-center overflow-hidden rounded-xl border-4 border-black bg-zinc-800 sm:w-16" style={{ perspective: 300 }}>
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={value}
          initial={still ? false : { rotateX: -90, opacity: 0 }}
          animate={{ rotateX: 0, opacity: 1 }}
          exit={still ? { opacity: 0 } : { rotateX: 90, opacity: 0 }}
          transition={{ duration: 0.32, ease: [0.3, 0.7, 0.2, 1] }}
          className="block text-6xl font-semibold tabular-nums text-white"
        >
          {value}
        </motion.span>
      </AnimatePresence>
      <span className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 bg-black" />
    </span>
  );
}

function Score({ value, label, still }: { value: number; label: string; still: boolean }) {
  const text = String(value % 100).padStart(2, "0");
  return (
    <div className="flex gap-1" role="img" aria-label={`${label} ${value}`}>
      <FlipDigit value={text[0]} still={still} />
      <FlipDigit value={text[1]} still={still} />
    </div>
  );
}

export default function LiveScoreboardWidgetWithFlipDigitScores() {
  const reduce = useReducedMotion();
  const [game, setGame] = useState(opening);
  const [showNext, setShowNext] = useState(false);
  const ordinal = ["1st", "2nd", "3rd", "4th"][game.quarter - 1];

  // Stand-in for a live feed: whoever has the ball scores two or three, then the other side takes over.
  useEffect(() => {
    if (reduce) return;
    const timer = window.setInterval(() => {
      setGame((current) => {
        const plays = current.plays + 1;
        if (plays > 14) return opening;
        const points = plays % 3 === 0 ? 3 : 2;
        const scorer = current.possession;
        return {
          home: current.home + (scorer === "home" ? points : 0),
          away: current.away + (scorer === "away" ? points : 0),
          quarter: plays === 8 ? Math.min(QUARTERS, current.quarter + 1) : current.quarter,
          possession: plays % 4 === 3 ? scorer : scorer === "home" ? "away" : "home",
          plays,
        };
      });
    }, TICK_MS);
    return () => window.clearInterval(timer);
  }, [reduce]);

  const teamRow = (team: typeof home, side: "home" | "away") => (
    <div className={`flex items-center gap-2 ${side === "away" ? "flex-row-reverse" : ""}`}>
      <span className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white ${team.crest}`} aria-hidden="true">
        {team.short[0]}
      </span>
      <span className="text-base font-bold tracking-wide">{team.short}</span>
      <span className={`h-2 w-2 rounded-full bg-yellow-400 transition-opacity duration-300 ${game.possession === side ? "opacity-100" : "opacity-0"}`} aria-hidden="true" />
    </div>
  );

  return (
    <div className="bg-emerald-950 px-4 py-14 sm:py-20">
      <div className="mx-auto w-full max-w-[340px] overflow-hidden rounded-[2rem] bg-zinc-800 text-white shadow-2xl shadow-black/50 ring-1 ring-zinc-700">
        <div className="flex items-center justify-between px-5 pb-1 pt-4">
          <p className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-zinc-300">
            <span className="h-2 w-2 animate-pulse rounded-full bg-red-500 motion-reduce:animate-none" aria-hidden="true" />
            Live
          </p>
          <p className="text-xs text-zinc-300">City League, round 9</p>
        </div>

        <div className="flex items-center justify-between px-5 py-3">
          {teamRow(home, "home")}
          {teamRow(away, "away")}
        </div>

        <div className="flex items-center justify-center gap-3 px-4 pb-5">
          <Score value={game.home} label={home.name} still={Boolean(reduce)} />
          <span className="flex flex-col gap-2" aria-hidden="true">
            <span className="h-1.5 w-1.5 rounded-full bg-zinc-500" />
            <span className="h-1.5 w-1.5 rounded-full bg-zinc-500" />
          </span>
          <Score value={game.away} label={away.name} still={Boolean(reduce)} />
        </div>

        {/* The footer is a two-line window. The button slides the second line into view. */}
        <button
          type="button"
          aria-pressed={showNext}
          onClick={() => setShowNext((current) => !current)}
          className="group relative block h-14 w-full overflow-hidden bg-zinc-950 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-yellow-400"
        >
          <span className="sr-only">Match details and next fixture. </span>
          <span className={`block transition-transform duration-500 ease-[cubic-bezier(0.76,0,0.24,1)] motion-reduce:transition-none ${showNext ? "-translate-y-1/2" : "group-hover:-translate-y-1/2"}`}>
            <span className="flex h-14 items-center justify-between px-5 text-sm font-medium">
              <span className="tabular-nums">{ordinal} quarter</span>
              <span className="flex items-center gap-1.5" aria-hidden="true">
                {Array.from({ length: QUARTERS }, (_, index) => (
                  <span key={index} className={`h-2 w-2 rounded-full ${index < game.quarter ? "bg-white" : "bg-zinc-600"}`} />
                ))}
              </span>
              <span className="text-zinc-300">Series 2 to 1</span>
            </span>
            <span className="flex h-14 items-center justify-center bg-emerald-500 px-5 text-center text-sm font-semibold text-emerald-950">{nextFixture}</span>
          </span>
        </button>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/live-scoreboard-widget-with-flip-digit-scores.json

When to use it

How to customize it

Accessibility

Questions

How does a digit flip?

Each tile renders its character inside `AnimatePresence` with the character as the key. When it changes, the old span rotates away around the horizontal axis and fades, and the new one rotates in from the opposite angle. The tile has `perspective`, which gives the turn its depth.

Why do only some digits flip?

The score is split into a tens character and a units character, and each has its own tile. A tile only animates when its own character changes, so going from 47 to 49 flips one tile and going from 49 to 51 flips both.

Can it show scores above 99?

The component shows the last two digits. For three-digit scores, pad to three characters and render a third `FlipDigit`. Narrow the tiles to `w-12` so three fit in the card.

How do I connect it to live data?

Remove the interval and update `game` from your source, whether that is polling, server-sent events or a socket handled elsewhere in your app. The tiles react to whatever numbers they are given.

How does the footer slide?

The footer is a window one line tall with two lines stacked inside. Moving the inner block up by half its height swaps which line is visible. Hover does it with a CSS class and the button pins it with state.

Adapted from Live Score in Animata, used under its open-source licence. Licences and credits

Related: Rolling Letter Departure Board Headline, Count Up Stats Row with Rolling Digits, Dynamic Island with Morphing Live Activities.

More app and game ui or all free UI components. Start for free to have a full site built around it.

More in App UI

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