Building Sports Broadcast Graphics in Remotion: Stats, Highlights, and Rankings
Building Sports Broadcast Graphics in Remotion: Stats, Highlights, and Rankings
Sports broadcast graphics have a specific visual grammar that fans recognize instantly — the bold stat card that slides in from the lower left, the season rankings bar chart that animates in sequence, the glowing MVP spotlight that appears over a player’s face. This grammar signals credibility. When your graphics look like what viewers see on ESPN or Sky Sports, the content feels authoritative.
The problem for independent leagues, regional sports networks, esports organizations, and sports content creators has always been access. Professional broadcast graphics are produced using proprietary systems that cost tens of thousands of dollars and require dedicated operators. The output is extraordinary but the barrier to entry is extreme.
Remotion changes the access equation. Because Remotion renders React components to video, you can build broadcast-quality sports graphics as code — data-driven, reusable, composable. Feed in your stats JSON, pass in team hex colors as props, and render a full suite of lower thirds, stat cards, and ranking animations without touching a single broadcast graphics workstation.
The Data Architecture: Stats as Props
Sports data is inherently structured. Almost every major sport tracks a consistent set of per-player and per-team statistics that aggregate into season rankings. For Remotion, the mental model is: your stats database is your video script.
A comprehensive player card prop structure looks like this:
type PlayerStatCardProps = {
player: {
id: string;
name: string;
number: number;
position: string;
photoUrl: string;
team: string;
};
stats: {
primary: { label: string; value: string | number };
secondary: Array<{ label: string; value: string | number }>;
};
team: {
primaryColor: string;
secondaryColor: string;
logoUrl: string;
name: string;
};
animationStyle?: 'slide-in' | 'rise-up' | 'fade-scale';
};
For score overlays and match graphics, extend this with match context:
type MatchDataProps = {
homeTeam: {
name: string;
shortName: string;
score: number;
logoUrl: string;
primaryColor: string;
};
awayTeam: {
name: string;
shortName: string;
score: number;
logoUrl: string;
primaryColor: string;
};
matchTime: string;
period: string;
matchPhase: 'preview' | 'live' | 'halftime' | 'final';
};
Player Stat Card: Broadcast Lower Third Style
The lower-third player stat card is the workhorse of sports broadcast graphics. It appears briefly during game action to contextualize a player performance, then animates out cleanly without obscuring the action.
import { useCurrentFrame, interpolate, spring, useVideoConfig, Img } from 'remotion';
export const PlayerStatCard: React.FC<PlayerStatCardProps> = ({
player,
stats,
team,
animationStyle = 'slide-in',
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Entrance: frames 0–20
const entranceProgress = spring({
frame,
fps,
config: { damping: 14, stiffness: 100, mass: 0.7 },
durationInFrames: 20,
});
// Exit: last 20 frames
const exitOpacity = interpolate(
frame,
[durationInFrames - 20, durationInFrames],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
const translateX = animationStyle === 'slide-in'
? interpolate(entranceProgress, [0, 1], [-400, 0])
: 0;
const translateY = animationStyle === 'rise-up'
? interpolate(entranceProgress, [0, 1], [60, 0])
: 0;
const scale = animationStyle === 'fade-scale'
? interpolate(entranceProgress, [0, 1], [0.85, 1])
: 1;
const opacity = interpolate(entranceProgress, [0, 0.3], [0, 1]) * exitOpacity;
return (
<div style={{
position: 'absolute',
bottom: 80,
left: 60,
transform: `translateX(${translateX}px) translateY(${translateY}px) scale(${scale})`,
opacity,
display: 'flex',
alignItems: 'stretch',
borderRadius: 8,
overflow: 'hidden',
boxShadow: '0 4px 32px rgba(0,0,0,0.5)',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}>
{/* Accent stripe in team color */}
<div style={{
width: 6,
backgroundColor: team.primaryColor,
flexShrink: 0,
}} />
{/* Player photo */}
<div style={{
width: 100,
backgroundColor: team.primaryColor + '22',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
}}>
<Img
src={player.photoUrl}
style={{ width: 84, height: 84, borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
{/* Main content */}
<div style={{
backgroundColor: 'rgba(10,10,10,0.92)',
padding: '14px 24px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 4,
minWidth: 280,
}}>
<div style={{
display: 'flex',
alignItems: 'baseline',
gap: 8,
}}>
<span style={{
fontSize: 28,
fontWeight: 700,
color: '#FFFFFF',
letterSpacing: '-0.01em',
}}>
{player.name}
</span>
<span style={{
fontSize: 16,
color: team.primaryColor,
fontWeight: 600,
}}>
#{player.number} · {player.position}
</span>
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
<div>
<div style={{ fontSize: 36, fontWeight: 800, color: '#FFFFFF' }}>
{stats.primary.value}
</div>
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{stats.primary.label}
</div>
</div>
{stats.secondary.slice(0, 3).map((s) => (
<div key={s.label}>
<div style={{ fontSize: 22, fontWeight: 700, color: 'rgba(255,255,255,0.85)' }}>
{s.value}
</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{s.label}
</div>
</div>
))}
</div>
</div>
</div>
);
};
Live Score Overlay
Score overlays need to feel permanent yet unobtrusive. The broadcast standard is a compact bug in the top-left or top-right corner with team logos, scores, and match time displayed at all times during game action.
type ScoreOverlayProps = {
homeTeam: MatchDataProps['homeTeam'];
awayTeam: MatchDataProps['awayTeam'];
matchTime: string;
period: string;
};
export const ScoreOverlay: React.FC<ScoreOverlayProps> = ({
homeTeam,
awayTeam,
matchTime,
period,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entranceOpacity = spring({
frame,
fps,
config: { damping: 20, stiffness: 80 },
durationInFrames: 18,
});
const TeamBlock = ({
team,
score,
isLeading,
}: {
team: typeof homeTeam;
score: number;
isLeading: boolean;
}) => (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 14px',
backgroundColor: isLeading ? team.primaryColor : 'rgba(0,0,0,0.75)',
transition: 'background-color 0.3s',
}}>
<Img src={team.logoUrl} style={{ width: 28, height: 28, objectFit: 'contain' }} />
<span style={{
fontSize: 16,
fontWeight: 700,
color: '#FFFFFF',
letterSpacing: '0.04em',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}>
{team.shortName}
</span>
<span style={{
fontSize: 20,
fontWeight: 800,
color: '#FFFFFF',
minWidth: 24,
textAlign: 'right',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}>
{score}
</span>
</div>
);
const homeLeading = homeTeam.score > awayTeam.score;
const awayLeading = awayTeam.score > homeTeam.score;
return (
<div style={{
position: 'absolute',
top: 24,
left: 24,
opacity: entranceOpacity,
display: 'flex',
flexDirection: 'column',
borderRadius: 6,
overflow: 'hidden',
boxShadow: '0 2px 16px rgba(0,0,0,0.6)',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}>
<TeamBlock team={homeTeam} score={homeTeam.score} isLeading={homeLeading} />
<div style={{ height: 1, backgroundColor: 'rgba(255,255,255,0.15)' }} />
<TeamBlock team={awayTeam} score={awayTeam.score} isLeading={awayLeading} />
<div style={{
backgroundColor: 'rgba(0,0,0,0.85)',
padding: '4px 8px',
textAlign: 'center',
fontSize: 11,
color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
}}>
{period} · {matchTime}
</div>
</div>
);
};
Season Rankings Bar Chart
Animated rankings are among the most visually compelling elements in sports content. A horizontal bar chart where bars grow from left to right, each labeled with team/player name and stat value, tells a competitive story at a glance.
type RankingsBarChartProps = {
title: string;
statLabel: string;
entries: Array<{
name: string;
value: number;
teamColor: string;
logoUrl?: string;
}>;
startFrame: number;
};
export const RankingsBarChart: React.FC<RankingsBarChartProps> = ({
title,
statLabel,
entries,
startFrame,
}) => {
const frame = useCurrentFrame();
const sortedEntries = [...entries].sort((a, b) => b.value - a.value).slice(0, 8);
const maxValue = sortedEntries[0]?.value ?? 1;
return (
<div style={{
width: '100%',
padding: '40px 48px',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}>
<div style={{
fontSize: 14,
fontWeight: 600,
color: 'rgba(255,255,255,0.5)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: 8,
}}>
{statLabel}
</div>
<div style={{
fontSize: 36,
fontWeight: 800,
color: '#FFFFFF',
marginBottom: 32,
letterSpacing: '-0.02em',
}}>
{title}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{sortedEntries.map((entry, i) => {
const delay = startFrame + i * 6;
const barProgress = spring({
frame: frame - delay,
fps: 30,
config: { damping: 18, stiffness: 60, mass: 1 },
});
const barWidth = `${(entry.value / maxValue) * 100 * barProgress}%`;
const rowOpacity = interpolate(frame - delay, [0, 12], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div key={entry.name} style={{ opacity: rowOpacity }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 4,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
fontSize: 14,
color: 'rgba(255,255,255,0.45)',
fontWeight: 700,
minWidth: 24,
}}>
{i + 1}
</span>
<span style={{ fontSize: 16, color: '#FFFFFF', fontWeight: 600 }}>
{entry.name}
</span>
</div>
<span style={{ fontSize: 16, color: '#FFFFFF', fontWeight: 700 }}>
{entry.value}
</span>
</div>
<div style={{
height: 8,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 4,
overflow: 'hidden',
}}>
<div style={{
width: barWidth,
height: '100%',
backgroundColor: entry.teamColor,
borderRadius: 4,
}} />
</div>
</div>
);
})}
</div>
</div>
);
};
The stagger delay of 6 frames per entry means the bars cascade from top to bottom, giving the viewer time to read each entry as it grows into view. Increase the delay for more dramatic pacing; reduce it when displaying many entries.
MVP Spotlight Animation
The MVP spotlight combines a dramatic entrance with particle-effect glows and bold typography. The key is constrast — the player photo dominates the frame, with stats appearing in a controlled sequence around it.
export const MVPSpotlight: React.FC<{
player: PlayerStatCardProps['player'];
award: string;
statHighlight: string;
teamColor: string;
}> = ({ player, award, statHighlight, teamColor }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const photoScale = spring({
frame,
fps,
config: { damping: 12, stiffness: 50, mass: 1.2 },
durationInFrames: 40,
});
const glowOpacity = interpolate(frame, [20, 50], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const awardTextOpacity = interpolate(frame, [35, 55], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const awardTextY = interpolate(frame, [35, 55], [20, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}>
{/* Glow backdrop */}
<div style={{
position: 'absolute',
width: 400,
height: 400,
borderRadius: '50%',
backgroundColor: teamColor,
opacity: glowOpacity * 0.25,
filter: 'blur(80px)',
}} />
{/* Player photo */}
<Img
src={player.photoUrl}
style={{
width: 280,
height: 280,
borderRadius: '50%',
objectFit: 'cover',
border: `4px solid ${teamColor}`,
transform: `scale(${photoScale})`,
boxShadow: `0 0 60px ${teamColor}66`,
}}
/>
{/* Award label */}
<div style={{
opacity: awardTextOpacity,
transform: `translateY(${awardTextY}px)`,
marginTop: 32,
textAlign: 'center',
}}>
<div style={{
fontSize: 14,
fontWeight: 700,
color: teamColor,
letterSpacing: '0.15em',
textTransform: 'uppercase',
marginBottom: 8,
}}>
{award}
</div>
<div style={{
fontSize: 44,
fontWeight: 800,
color: '#FFFFFF',
letterSpacing: '-0.02em',
}}>
{player.name}
</div>
<div style={{
fontSize: 20,
color: 'rgba(255,255,255,0.7)',
marginTop: 6,
}}>
{statHighlight}
</div>
</div>
</div>
);
};
Sports API Integration Pattern
Connecting to live or regularly updated sports data sources follows a consistent pattern in Remotion batch pipelines. The key is fetching data once before the render loop and passing it as static props — Remotion compositions are not reactive to live data during rendering.
import fetch from 'node-fetch';
import { renderMedia, selectComposition } from '@remotion/renderer';
// Example: fetch league standings from a REST sports API
async function fetchStandings(leagueId: string, season: string) {
const response = await fetch(
`https://api.sportsdata.example.com/v1/leagues/${leagueId}/seasons/${season}/standings`,
{ headers: { 'X-API-Key': process.env.SPORTS_API_KEY! } }
);
return response.json();
}
async function renderStandingsGraphic(leagueId: string, season: string) {
const standings = await fetchStandings(leagueId, season);
const entries = standings.teams.map((team: any) => ({
name: team.abbreviation,
value: team.wins,
teamColor: team.primaryColorHex,
logoUrl: team.logoUrl,
}));
await renderMedia({
composition: await selectComposition({
serveUrl: BUNDLE_URL,
id: 'RankingsBarChart',
inputProps: {
title: `${season} Season Wins`,
statLabel: 'Wins',
entries,
startFrame: 0,
},
}),
outputLocation: `./out/standings-${season}.mp4`,
codec: 'h264',
});
}
For near-real-time workflows (e.g., generating post-match highlight graphics within minutes of the final whistle), this pipeline can be triggered via webhook from your stats provider, run in a serverless function, and upload directly to your social media publishing queue.
Team Color Theming
One of Remotion’s strengths for sports content is how naturally it handles team color theming via props. Instead of maintaining separate templates for each team, a single composition accepts primaryColor, secondaryColor, and accentColor as props and applies them throughout.
// Gradient background that respects team colors
const backgroundStyle = {
background: `linear-gradient(135deg, ${team.primaryColor}ee 0%, ${team.secondaryColor}aa 50%, #0a0a0a 100%)`,
};
// Dynamic glow on stat numbers
const statGlowStyle = {
textShadow: `0 0 20px ${team.primaryColor}88, 0 0 40px ${team.primaryColor}44`,
};
For teams with very light primary colors (like white or yellow), add a luminance check to determine whether to use a dark or light text treatment:
function isLightColor(hexColor: string): boolean {
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
// Perceived luminance formula
return 0.299 * r + 0.587 * g + 0.114 * b > 128;
}
const textColor = isLightColor(team.primaryColor) ? '#000000' : '#FFFFFF';
Social Format Optimization: Square and Vertical
For Instagram and Twitter/X post highlights, 1:1 and 9:16 formats outperform 16:9 in feed placement. Stat cards work well at 1:1 (1080×1080) — the square format gives ample room for a single large stat alongside player context without the cramped feeling of a lower-third strip.
For TikTok and Instagram Reels, 9:16 (1080×1920) allows full-bleed player photography with stats as a floating overlay at the bottom third — the same visual grammar as broadcast lower thirds but adapted for vertical viewing.
Building separate compositions for each aspect ratio is the cleanest approach. Share animation logic in utility functions; differ only in how elements are positioned within the frame.
Why Remotion Over Motion Graphics Templates
After Effects templates for sports graphics exist in abundance — MOGRT files for Premiere, templates for Envato Market, etc. They look fine. The limitation is that they are static designs with text placeholders, not data pipelines.
With After Effects templates, updating a ranking graphic for a new match week means manually editing text layers, re-exporting, and handling each graphic individually. With Remotion, you update the JSON, run the script, and every graphic in your library regenerates automatically.
For media organizations covering multiple sports, multiple leagues, or multiple seasons simultaneously, the operational leverage of code-driven graphics is not marginal — it is transformative.
RenderComp Sports Template Collection
RenderComp offers a sports broadcast graphics bundle for Remotion that includes production-ready implementations of every component described in this guide: player stat cards, score overlays, rankings charts, MVP spotlights, match preview cards, and post-match recap sequences — all wired for team color theming via props and ready to connect to your stats data source.
FAQ
Q: Can Remotion graphics be used for live broadcast overlays, or is it only for pre-rendered video? A: Remotion renders to file rather than outputting a live video stream, so it is not suitable for true real-time broadcast overlays (which require systems like Ross Xpression or Chyron). However, Remotion is excellent for producing graphics around live broadcasts — pre-match graphics, half-time stat packages, post-match recaps, and social media highlight packages rendered minutes after the event concludes.
Q: How do I handle team logos with varying aspect ratios and transparent backgrounds?
A: Store logos as PNG files with alpha channels in a consistent size (e.g., 200×200). Use objectFit: 'contain' in your Img styles so the logo scales proportionally within its container without distortion. For logos on colored backgrounds, a white SVG silhouette version is often cleaner than a full-color PNG.
Q: Can I animate score changes mid-video — e.g., showing the score before and after a goal?
A: Yes. Use Remotion’s <Sequence> component to swap between a pre-goal score display and a post-goal display at a specific frame. Combine with a flash animation (a brief white overlay that fades) to simulate the dramatic moment of the score changing. Store multiple score states as an array in your props and step through them at defined frames.
Q: How do I keep the animation timing consistent across different frame rates?
A: Use Remotion’s fps value from useVideoConfig() to calculate frame counts dynamically rather than hardcoding frame numbers. For example, const ONE_SECOND = fps; const twoSeconds = fps * 2;. This ensures your template works correctly at 24fps, 30fps, or 60fps without changing any animation logic.
Q: My league tracks non-standard statistics. How do I display custom stat labels?
A: The stats.secondary array in the PlayerStatCardProps type accepts any { label: string; value: string | number } pair, so custom stats like “Clearances per 90” or “Zone Coverage %” work exactly the same as standard stats. The label appears as the small text below the value — just make it short enough to read at the rendered font size.
Q: Is there a recommended approach for match preview graphics that include head-to-head historical records?
A: Structure the historical record as a headToHead object in your match props — { homeWins: number; draws: number; awayWins: number; lastFiveMeetings: Array<...> }. Display it as a compact three-column layout (home wins | draws | away wins) with a small bar showing the win percentage split. Animate each column count-up separately with a 6-frame stagger between them.
Q: What is the most performant way to render a full round of match graphics — say, 12 match cards — quickly?
A: For 12 match graphics in parallel, use Remotion Lambda with concurrencyPerLambda set to 2 and up to 6 Lambda invocations running simultaneously. This delivers all 12 renders in roughly 2–4 minutes depending on composition complexity. For local rendering, a batch script processing 4 at a time with concurrency: 4 in renderMedia is typically the sweet spot before hitting memory constraints on a standard laptop.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →