About this animation
A live match stack with rolling scores and goal celebrations, with configurable matches, copy, colors, and fonts.
Why this one
A goal couples the falling score with a crest jump, expanding wave, three-cycle shake, diagonal light, and a temporary goal ribbon. Selecting another match exchanges the actual cards through a continuous position, size, color, and notch morph. It preserves each match's score and minute instead of replacing the list.
How it works
- Match IDs keep each card mounted. A single shared array of slot positions drives the entire exchange; a separate destination snapshot makes rapid taps interrupt safely.
- Skia draws the card outline, including its convex featured tab and concave compact join. Lucide paths are copied from the local kit; the generic basketball is an original drawing.
- Each digit has its own clipped window and fades according to distance. New digits and animation start together in a layout effect; separators have independent widths.
goal()updates one score, the audience, and a celebration event. UI worklets derive the wave, crest jump, shake, light, and ribbon from shared values.- Gesture Handler taps supply immediate pressure feedback independently of the morph. Screen reader activation invokes the same selection action.
- The demo orchestrates two ticks, a home goal at 2.3 seconds, a second-match exchange, and an away goal. Normal mode ticks every four seconds.
Install
npx expo install @shopify/react-native-skia expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-workletsUse a GestureHandlerRootView at your app root. For web, initialize CanvasKit using Skia's documented web setup before importing this component. The demo requires the catalog's existing Skia loader. No remote images, added packages, or haptics are required.
Usage
import { useRef } from 'react';
import LiveScoreMatchCard, { type LiveScoreMatchCardRef, type Match } from './live-score-match-card';
const matches: Match[] = [{
id: 'match-a', league: 'Regional Cup', status: 'Live', minute: 67,
score: [2, 1],
home: { name: 'Harbor FC', glyph: 'shield', colors: ['#3B8BCE', '#143B62'] },
away: { name: 'Northside', glyph: 'crown', colors: ['#9D68CC', '#432754'] },
}];
function LiveMatches() {
const ref = useRef<LiveScoreMatchCardRef>(null);
return <LiveScoreMatchCard ref={ref} matches={matches} featuredId="match-a"
onMatchPress={match => ref.current?.goal(match.id, 'home')} />;
}Personality and a complete light palette:
<LiveScoreMatchCard matches={matches} fans={3500} tickInterval={0}
springStiffness={300} greeting="Your match center"
theme={{ background: '#F4F6ED', surface: '#FFFFFF', ink: '#172011',
muted: '#57624D', border: '#17201118', accent: '#B5E226',
onAccent: '#172011', light: '#FFFFFF', live: '#D92D26' }}
minuteLabel={minute => `${minute}'`} />Call ref.current?.goal(id, 'home' | 'away') or feature(id). tick() advances all supplied minutes, capped at 90. reset() restores supplied data, audience, and initial order. Props updates replace the snapshot; keep matches referentially stable between live data updates. The timer is a demo clock, not a sports data feed. Sports chips select a visual category; hosts supply the appropriate matches through props.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
| matches | readonly Match[] | Required | First three matches, including team names, kit glyphs, two hex crest colors, score, minute, league, and status |
| featuredId | string | First match | Selected match ID |
| fans | number | 2100 | Audience; each goal adds 120 |
| onMatchPress | (match) => void | — | Host selection action |
| brand | ReactNode | — | Host brand tile and wordmark |
| greeting | string | Welcome back | Small greeting |
| title / titleEmphasis | string | Pick your / match | Editorial headline |
| sectionLabel | string | Live matches | Section heading |
| sports | readonly { label, glyph }[] | Football, Basketball, Tennis | Sports chips using kit paths and the original basketball glyph |
| fansLabel / fanInitials | string / readonly string[] | fans watching / AL, JR, MK, TS | Audience copy and four initials |
| goalLabel | (match, side) => string | GOAL · minute' | Ribbon and spoken announcement |
| matchLabel | (match) => string | Team scores, status, minute, action | Accessible selection label |
| minuteLabel | (minute) => string | minute' | Rolling minute copy |
| theme | Partial<MatchTheme> | Warm black and lime | Complete palette; use hex colors for alpha variants |
| fontFamily / titleFontFamily / monoFontFamily | string | System | Body, editorial, and score typefaces |
| tickInterval | number | 4000 | Clock interval; zero disables it |
| springStiffness | number | 280 | Morph stiffness; damping adapts to it |
| style / testID | StyleProp<ViewStyle> / string | — / live-score-match-card | Root overrides and stable control IDs |
Compatibility
Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Skia 2.14.0. Uses modules available in this project's Expo Go runtime. Supports iOS, Android, and web. Icons: Lucide (ISC), except the original generic basketball; see icons.ts for provenance.
The root fills its host and measures via onLayout. Content is centered at a maximum card width of 440 points. At 320×694, 390×844, landscape, tablet, and a 300×400 embed, the stack remains vertically scrollable instead of squeezing touch targets. The host owns safe area insets. Header content reserves the upper-right viewer control area. Large accessibility text and native touch need a device pass.
Accessibility
Match cards and 48-point sport chips expose button roles, labels, selected states, and accessible activation. Goals and feature changes are announced. Reduced motion replaces rolling movement with 200 ms fades and removes crest flights, wave rings, shakes, pulsing, and light/ribbon sweeps. There is no pan-only action. All visible copy, crest art choices, fonts, and palette values are supplied through props.




