Live Score Widget That Expands When a Goal Is Scored

A match score card in the style of a phone's live activity. Two drawn club crests sit either side of the score and a running match clock. When a goal goes in, the frame around the card turns green and grows on a spring to reveal a banner naming the scorer; half a second later the changed digit drops into place, and after a few seconds the banner folds away. Each goal is also added to a short event list underneath. The demo scores a goal by itself shortly after load and two buttons let you score for either side. On phones the copy sits above the widget.

Code

// Adapted from Animata (https://github.com/codse/animata/blob/main/animata/card/score-card.tsx)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CircleDot, Radio } from "lucide-react";

const match = {
  competition: "Coastal League, matchday 12",
  startMinute: 63,
  home: { name: "Harbour City", code: "HC", color: "#f59e0b", accent: "#1c1917", score: 1, scorers: ["Lindqvist", "Abara", "Moreau"] },
  away: { name: "Northfield", code: "NF", color: "#38bdf8", accent: "#0c4a6e", score: 1, scorers: ["Okafor", "Tanaka", "Reyes"] },
};

type Side = "home" | "away";
type Goal = { id: number; side: Side; scorer: string; minute: number };

const BANNER_MS = 3200; // how long the goal banner stays open

function Crest({ code, color, accent }: { code: string; color: string; accent: string }) {
  return (
    <svg aria-hidden="true" viewBox="0 0 48 56" className="h-14 w-12 sm:h-16 sm:w-14">
      <path d="M24 2 44 9v20c0 12-8 20-20 25C12 49 4 41 4 29V9z" fill={color} />
      <path d="M24 2v52C12 49 4 41 4 29V9z" fill={accent} opacity="0.22" />
      <path d="M24 2 44 9v20c0 12-8 20-20 25C12 49 4 41 4 29V9z" fill="none" stroke="#ffffff" strokeOpacity="0.5" strokeWidth="1.5" />
      <text x="24" y="33" textAnchor="middle" fontSize="15" fontWeight="800" fill={accent} fontFamily="ui-sans-serif, system-ui, sans-serif">
        {code}
      </text>
    </svg>
  );
}

/** One score digit. The old number drops out as the new one drops in. */
function Digit({ value, reduce }: { value: number; reduce: boolean }) {
  return (
    <span className="relative inline-flex h-12 w-8 justify-center overflow-hidden sm:w-9">
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={value}
          initial={reduce ? false : { y: "-100%", opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={reduce ? { opacity: 0 } : { y: "100%", opacity: 0 }}
          transition={{ type: "spring", stiffness: 300, damping: 24 }}
          className="absolute inset-0 flex items-center justify-center"
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export default function LiveScoreWidget() {
  const reduce = useReducedMotion() ?? false;
  const [score, setScore] = useState({ home: match.home.score, away: match.away.score });
  const [goals, setGoals] = useState<Goal[]>([]);
  const [banner, setBanner] = useState<Goal | null>(null);
  const [seconds, setSeconds] = useState(match.startMinute * 60 + 12);
  const secondsRef = useRef(seconds);
  secondsRef.current = seconds;
  const nextId = useRef(1);
  const hideTimer = useRef<ReturnType<typeof setTimeout>>();
  const touched = useRef(false);

  const goal = useCallback((side: Side) => {
    const id = nextId.current++;
    const names = match[side].scorers;
    const entry: Goal = { id, side, scorer: names[id % names.length], minute: Math.floor(secondsRef.current / 60) + 1 };
    setBanner(entry);
    setGoals((list) => [entry, ...list].slice(0, 3));
    // The number changes a beat after the banner opens, like a real broadcast graphic.
    setTimeout(() => setScore((s) => ({ ...s, [side]: s[side] + 1 })), 450);
    clearTimeout(hideTimer.current);
    hideTimer.current = setTimeout(() => setBanner(null), BANNER_MS);
  }, []);

  // Match clock.
  useEffect(() => {
    const timer = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(timer);
  }, []);

  // Self-playing demo: a goal soon after load, then one now and then, until the visitor presses a button.
  useEffect(() => {
    if (reduce) return;
    let turn = 0;
    const first = setTimeout(() => !touched.current && goal("away"), 1100);
    const timer = setInterval(() => {
      if (touched.current) return;
      turn += 1;
      goal(turn % 2 ? "home" : "away");
    }, 8500);
    return () => {
      clearTimeout(first);
      clearInterval(timer);
      clearTimeout(hideTimer.current);
    };
  }, [goal, reduce]);

  const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
  const scoring = banner !== null;

  return (
    <section className="relative overflow-hidden bg-[#04130c] px-4 py-16 sm:px-6">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(ellipse 70% 60% at 70% 40%, rgba(16,185,129,0.18), transparent 70%)" }} />
      <div className="relative mx-auto grid max-w-5xl items-center gap-10 md:grid-cols-2">
        <div className="text-center md:text-left">
          <p className="inline-flex items-center gap-2 rounded-full bg-red-500/15 px-3 py-1.5 text-xs font-semibold uppercase tracking-widest text-red-300">
            <Radio aria-hidden="true" className="h-3.5 w-3.5" />
            Live now
          </p>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Never miss the moment the net moves.</h2>
          <p className="mt-4 text-base leading-relaxed text-emerald-100/80">
            The score sits quietly at the top of your screen. When someone scores, it opens up to tell you who, then tucks itself away again.
          </p>
          <div className="mt-6 flex flex-wrap justify-center gap-2 md:justify-start">
            {(["home", "away"] as Side[]).map((side) => (
              <button
                key={side}
                type="button"
                onClick={() => {
                  touched.current = true;
                  goal(side);
                }}
                className="h-11 rounded-full border border-white/25 px-4 text-sm font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
              >
                Goal for {match[side].name}
              </button>
            ))}
          </div>
        </div>

        <div className="mx-auto w-full max-w-md">
          <div className="min-h-[178px]">
            <div className={`rounded-[30px] p-1.5 transition-colors duration-500 motion-reduce:transition-none ${scoring ? "bg-emerald-700" : "bg-white/5"}`}>
              <div className="rounded-3xl bg-black px-4 py-4 text-white sm:px-6">
                <p className="text-center text-[11px] font-medium uppercase tracking-widest text-zinc-400">{match.competition}</p>
                <div className="mt-2 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
                  <div className="flex flex-col items-center gap-1.5">
                    <Crest {...match.home} />
                    <span className="text-center text-sm font-medium">{match.home.name}</span>
                  </div>
                  <div className="text-center">
                    <div aria-hidden="true" className="flex items-center text-4xl font-bold tabular-nums sm:text-5xl">
                      <Digit value={score.home} reduce={reduce} />
                      <span className="text-zinc-500">:</span>
                      <Digit value={score.away} reduce={reduce} />
                    </div>
                    <p className="sr-only">
                      {match.home.name} {score.home}, {match.away.name} {score.away}
                    </p>
                    <p className="mt-1 inline-flex items-center gap-1.5 text-sm tabular-nums text-emerald-300">
                      <span aria-hidden="true" className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400 motion-reduce:animate-none" />
                      {clock}
                    </p>
                  </div>
                  <div className="flex flex-col items-center gap-1.5">
                    <Crest {...match.away} />
                    <span className="text-center text-sm font-medium">{match.away.name}</span>
                  </div>
                </div>
              </div>
              <AnimatePresence initial={false}>
                {banner && (
                  <motion.div
                    key="banner"
                    initial={reduce ? false : { height: 0, opacity: 0 }}
                    animate={{ height: 48, opacity: 1 }}
                    exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                    transition={{ type: "spring", stiffness: 200, damping: 22 }}
                    className="overflow-hidden"
                  >
                    <div className="flex h-12 items-center justify-between px-4 text-white">
                      <AnimatePresence mode="wait" initial={false}>
                        <motion.p
                          key={banner.id}
                          initial={reduce ? false : { opacity: 0, x: -10 }}
                          animate={{ opacity: 1, x: 0 }}
                          exit={{ opacity: 0 }}
                          transition={{ duration: 0.25 }}
                          className="flex items-center gap-2 text-base font-semibold"
                        >
                          <CircleDot aria-hidden="true" className="h-5 w-5" />
                          {banner.scorer} scores for {match[banner.side].name}!
                        </motion.p>
                      </AnimatePresence>
                      <span className="text-sm text-emerald-100">now</span>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>

          <div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4">
            <h3 className="text-xs font-semibold uppercase tracking-widest text-emerald-200/80">Latest</h3>
            <ul aria-live="polite" className="mt-2 min-h-[5.5rem] space-y-1.5">
              <AnimatePresence initial={false}>
                {goals.map((entry) => (
                  <motion.li
                    key={entry.id}
                    layout={!reduce}
                    initial={reduce ? false : { opacity: 0, y: -10 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0 }}
                    transition={{ type: "spring", stiffness: 320, damping: 28 }}
                    className="flex items-center gap-3 text-sm text-white"
                  >
                    <span className="w-8 shrink-0 font-semibold tabular-nums text-emerald-300">{entry.minute}&apos;</span>
                    <span aria-hidden="true" className="h-2 w-2 shrink-0 rounded-full" style={{ background: match[entry.side].color }} />
                    <span>
                      Goal, {entry.scorer} <span className="text-emerald-100/70">({match[entry.side].name})</span>
                    </span>
                  </motion.li>
                ))}
              </AnimatePresence>
              {goals.length === 0 && <li className="text-sm text-emerald-100/70">Second half under way. No goals since the break.</li>}
            </ul>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/live-score-widget-that-expands-when-a-goal-is-scored.json

When to use it

How to customize it

Accessibility

Questions

How do I connect it to real match data?

Keep the layout and replace the demo effect. When your feed or socket reports a new score, call `goal` with the side that scored, or set the score state directly and set `banner` to the event you want announced.

How does the digit roll?

Each digit is rendered with its value as the React key inside `AnimatePresence`. When the value changes, the old digit exits downward while the new one enters from above, both clipped by a box with hidden overflow.

Why does the widget area have a minimum height?

The banner adds 48 pixels when it opens. Reserving that space with `min-h-[178px]` on the wrapper stops the event list and anything below from jumping each time a goal is scored.

Can it show other sports?

Yes. The layout is two sides, two numbers and a clock. Change the wording in the banner, swap the icon and, for sports with larger scores, widen the digit box or render the score as one number.

Are the team names and crests real?

No. Both clubs and the competition are invented and the crests are simple drawn shields. Use your own names and artwork that you have the right to show.

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

Related: Dynamic Island with Morphing Live Activities, Animated Notification Feed with Looping Arrivals, Count Up Stats Row with Rolling Digits, Live Scoreboard Widget with Flip Digit Scores.

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