Live scoreboard + event feed
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useReducedMotion } from "@lumioui/core";
import { Avatar, Button, Glass, Icon, Text, type IconName } from "@lumioui/ui";
import { MONO, Mount, Press, World } from "../shared";
export interface MatchEvent {
id: string;
minute: string;
text: string;
team: "home" | "away";
/** Drives the timeline glyph — goals get a star, cards a card, subs a swap. */
kind?: "goal" | "card" | "sub" | "whistle";
}
export interface MatchStat {
label: string;
home: number;
away: number;
}
export interface MatchDetailScreenProps {
home?: string;
away?: string;
homeScore?: string;
awayScore?: string;
status?: string;
league?: string;
homeAvatarUrl?: string;
awayAvatarUrl?: string;
events?: MatchEvent[];
stats?: MatchStat[];
lineups?: { home: string[]; away: string[] };
onBack?: () => void;
}
const DEFAULT_EVENTS: MatchEvent[] = [
{ id: "e0", minute: "1'", text: "Kick-off — we're underway at the Emirates", team: "home", kind: "whistle" },
{ id: "e1", minute: "12'", text: "Goal — De Bruyne assist, Haaland finish", team: "away", kind: "goal" },
{ id: "e2", minute: "34'", text: "Yellow card — Rice", team: "home", kind: "card" },
{ id: "e6", minute: "41'", text: "Big save — Raya palms away Doku's curler", team: "home", kind: "whistle" },
{ id: "e7", minute: "45'", text: "Half-time — City lead by one", team: "away", kind: "whistle" },
{ id: "e3", minute: "58'", text: "Goal — Saka equalises", team: "home", kind: "goal" },
{ id: "e8", minute: "66'", text: "Yellow card — Dias for a cynical block", team: "away", kind: "card" },
{ id: "e4", minute: "74'", text: "Substitution — Jesus on for Trossard", team: "home", kind: "sub" },
{ id: "e9", minute: "76'", text: "Substitution — Kovacic on for Silva", team: "away", kind: "sub" },
];
const DEFAULT_STATS: MatchStat[] = [
{ label: "Possession", home: 46, away: 54 },
{ label: "Shots", home: 9, away: 14 },
{ label: "xG", home: 1.1, away: 1.9 },
{ label: "Shots on target", home: 4, away: 6 },
{ label: "Corners", home: 3, away: 7 },
{ label: "Fouls", home: 11, away: 8 },
];
const DEFAULT_LINEUPS = {
home: ["Raya", "White", "Saliba", "Gabriel", "Timber", "Ødegaard", "Rice", "Saka", "Havertz", "Martinelli", "Jesus"],
away: ["Ederson", "Walker", "Dias", "Gvardiol", "Akanji", "Rodri", "De Bruyne", "Foden", "Silva", "Doku", "Haaland"],
};
const KIND_ICON: Record<NonNullable<MatchEvent["kind"]>, IconName> = {
goal: "star",
card: "credit-card",
sub: "repeat",
whistle: "clock",
};
const TABS = ["events", "stats", "lineups"] as const;
type Tab = (typeof TABS)[number];
/** Pulsing dot for the LIVE badge. */
function LiveDot() {
const reduce = useReducedMotion();
const t = useSharedValue(1);
React.useEffect(() => {
if (reduce) return;
t.value = withRepeat(withTiming(0.3, { duration: 900 }), -1, true);
}, [reduce, t]);
const style = useAnimatedStyle(() => ({ opacity: t.value }));
return <Animated.View style={style} className="h-1.5 w-1.5 rounded-full bg-success" />;
}
/** Match detail — scoreboard + Events/Stats/Lineups tabs. */
export function MatchDetailScreen({
home = "Arsenal",
away = "Man City",
homeScore = "1",
awayScore = "1",
status = "LIVE · 78'",
league = "Premier League",
homeAvatarUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/5/53/Arsenal_FC.svg/120px-Arsenal_FC.svg.png",
awayAvatarUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/e/eb/Manchester_City_FC_badge.svg/120px-Manchester_City_FC_badge.svg.png",
events = DEFAULT_EVENTS,
stats = DEFAULT_STATS,
lineups = DEFAULT_LINEUPS,
onBack,
}: MatchDetailScreenProps) {
const insets = useSafeAreaInsets();
const [tab, setTab] = React.useState<Tab>("events");
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</View>
</View>
{/* Scoreboard — the only thing that matters at a glance. */}
<View className="mx-4 mt-14 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
<View className="flex-row items-center justify-center gap-1.5">
<LiveDot />
<Text className="text-center text-[10px] font-semibold uppercase tracking-wide text-success">
{status}
</Text>
</View>
<View className="mt-2 flex-row items-center justify-between">
<View className="w-20 items-center gap-1">
<Avatar size="lg" source={{ uri: homeAvatarUrl }} fallback={home.slice(0, 2).toUpperCase()} alt={home} />
<Text className="text-xs font-medium" numberOfLines={1}>
{home}
</Text>
</View>
<View className="items-center">
<Text className="text-5xl font-bold tracking-tight" style={{ fontFamily: MONO, fontVariant: ["tabular-nums"] }}>
{homeScore} – {awayScore}
</Text>
<Text className="text-xs text-muted-foreground">{league}</Text>
{/* Venue — scoreboards situate the game. */}
<Text className="pt-0.5 text-[10px] text-muted-foreground/70">
Emirates Stadium · 60,704
</Text>
</View>
<View className="w-20 items-center gap-1">
<Avatar size="lg" source={{ uri: awayAvatarUrl }} fallback={away.slice(0, 2).toUpperCase()} alt={away} />
<Text className="text-xs font-medium" numberOfLines={1}>
{away}
</Text>
</View>
</View>
{/* Tabs — Events / Stats / Lineups fill the detail half. */}
<Glass interactive effect="regular" className="mt-4 flex-row self-center rounded-full p-1">
{TABS.map((t) => (
<Press
key={t}
haptic="light"
pressedScale={0.96}
onPress={() => setTab(t)}
accessibilityRole="button"
accessibilityLabel={`${t} tab`}
accessibilityState={{ selected: tab === t }}
className={`rounded-full px-4 py-1.5 ${tab === t ? "bg-card" : ""}`}
>
<Text
className={`text-xs font-medium capitalize ${
tab === t ? "text-foreground" : "text-muted-foreground"
}`}
>
{t}
</Text>
</Press>
))}
</Glass>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
{tab === "events" && (
/* Timeline — spine connects icon discs; minute column left. */
<View className="relative mx-4 mt-4">
<View className="absolute bottom-4 left-[39px] top-4 w-[2px] rounded-full bg-border" />
{events.map((e) => (
<View key={e.id} className="flex-row items-start gap-3 py-2.5">
<Text className="w-9 pt-1 text-right text-sm font-semibold text-primary">
{e.minute}
</Text>
<View
className={`z-10 h-6 w-6 items-center justify-center rounded-full ${
e.team === "home" ? "bg-primary" : "bg-muted-foreground/40"
}`}
>
<Icon
name={KIND_ICON[e.kind ?? "whistle"]}
size="sm"
color="primaryForeground"
/>
</View>
<Text className="flex-1 pt-0.5 text-sm leading-5">{e.text}</Text>
</View>
))}
</View>
)}
{tab === "stats" && (
<Mount>
<View className="mx-4 mt-4 gap-4 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
{stats.map((s) => {
const total = s.home + s.away || 1;
return (
<View key={s.label} className="gap-1.5">
<View className="flex-row items-baseline justify-between">
<Text className="w-12 text-sm font-semibold">{s.home}</Text>
<Text className="text-xs text-muted-foreground">{s.label}</Text>
<Text className="w-12 text-right text-sm font-semibold">{s.away}</Text>
</View>
<View className="flex-row gap-1">
<View className="h-1.5 flex-1 flex-row justify-end rounded-full bg-muted">
<View
className="h-1.5 rounded-full bg-primary"
style={{ width: `${(s.home / total) * 100}%` }}
/>
</View>
<View className="h-1.5 flex-1 rounded-full bg-muted">
<View
className="h-1.5 rounded-full bg-muted-foreground"
style={{ width: `${(s.away / total) * 100}%` }}
/>
</View>
</View>
</View>
);
})}
</View>
</Mount>
)}
{tab === "lineups" && (
<Mount>
<View className="mx-4 mt-4 flex-row gap-3">
{([["home", lineups.home], ["away", lineups.away]] as const).map(([side, names]) => (
<View key={side} className="flex-1 overflow-hidden rounded-3xl bg-white p-4 dark:bg-[#1A1A20]">
<Text className="pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{side === "home" ? home : away}
</Text>
{names.map((n, i) => (
<View key={n} className="flex-row items-center gap-2 py-1">
<Text className="w-4 text-right text-[11px] text-muted-foreground">
{i + 1}
</Text>
<Text className="flex-1 text-sm" numberOfLines={1}>
{n}
</Text>
</View>
))}
</View>
))}
</View>
</Mount>
)}
</ScrollView>
</SafeAreaView>
</World>
);
}
MatchDetailScreen.displayName = "MatchDetailScreen";
Recreate the "Match Detail Screen" screen in React Native using LumioUI.
Goal: Live scoreboard + event feed Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add misc-match-detail
```
Or pull just the components: `npx lumioui add avatar button glass icon list text`
Native packages:
- @lumioui/core@latest
- @lumioui/ui@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×2
### `button` — Tactile pill button with press spring, ink wash and async loading.
- imported but not rendered directly — likely used through a sibling or as a utility
### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×2
### `list` — Settings-style rows with separators and press feedback.
- imported but not rendered directly — likely used through a sibling or as a utility
### `text` — Themed type scale from caption to display.
- `<Text>` ×15
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/misc/match-detail.tsx` exporting MatchDetailScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useReducedMotion } from "@lumioui/core";
import { Avatar, Button, Glass, Icon, Text, type IconName } from "@lumioui/ui";
import { MONO, Mount, Press, World } from "../shared";
export interface MatchEvent {
id: string;
minute: string;
text: string;
team: "home" | "away";
/** Drives the timeline glyph — goals get a star, cards a card, subs a swap. */
kind?: "goal" | "card" | "sub" | "whistle";
}
export interface MatchStat {
label: string;
home: number;
away: number;
}
export interface MatchDetailScreenProps {
home?: string;
away?: string;
homeScore?: string;
awayScore?: string;
status?: string;
league?: string;
homeAvatarUrl?: string;
awayAvatarUrl?: string;
events?: MatchEvent[];
stats?: MatchStat[];
lineups?: { home: string[]; away: string[] };
onBack?: () => void;
}
const DEFAULT_EVENTS: MatchEvent[] = [
{ id: "e0", minute: "1'", text: "Kick-off — we're underway at the Emirates", team: "home", kind: "whistle" },
{ id: "e1", minute: "12'", text: "Goal — De Bruyne assist, Haaland finish", team: "away", kind: "goal" },
{ id: "e2", minute: "34'", text: "Yellow card — Rice", team: "home", kind: "card" },
{ id: "e6", minute: "41'", text: "Big save — Raya palms away Doku's curler", team: "home", kind: "whistle" },
{ id: "e7", minute: "45'", text: "Half-time — City lead by one", team: "away", kind: "whistle" },
{ id: "e3", minute: "58'", text: "Goal — Saka equalises", team: "home", kind: "goal" },
{ id: "e8", minute: "66'", text: "Yellow card — Dias for a cynical block", team: "away", kind: "card" },
{ id: "e4", minute: "74'", text: "Substitution — Jesus on for Trossard", team: "home", kind: "sub" },
{ id: "e9", minute: "76'", text: "Substitution — Kovacic on for Silva", team: "away", kind: "sub" },
];
const DEFAULT_STATS: MatchStat[] = [
{ label: "Possession", home: 46, away: 54 },
{ label: "Shots", home: 9, away: 14 },
{ label: "xG", home: 1.1, away: 1.9 },
{ label: "Shots on target", home: 4, away: 6 },
{ label: "Corners", home: 3, away: 7 },
{ label: "Fouls", home: 11, away: 8 },
];
const DEFAULT_LINEUPS = {
home: ["Raya", "White", "Saliba", "Gabriel", "Timber", "Ødegaard", "Rice", "Saka", "Havertz", "Martinelli", "Jesus"],
away: ["Ederson", "Walker", "Dias", "Gvardiol", "Akanji", "Rodri", "De Bruyne", "Foden", "Silva", "Doku", "Haaland"],
};
const KIND_ICON: Record<NonNullable<MatchEvent["kind"]>, IconName> = {
goal: "star",
card: "credit-card",
sub: "repeat",
whistle: "clock",
};
const TABS = ["events", "stats", "lineups"] as const;
type Tab = (typeof TABS)[number];
/** Pulsing dot for the LIVE badge. */
function LiveDot() {
const reduce = useReducedMotion();
const t = useSharedValue(1);
React.useEffect(() => {
if (reduce) return;
t.value = withRepeat(withTiming(0.3, { duration: 900 }), -1, true);
}, [reduce, t]);
const style = useAnimatedStyle(() => ({ opacity: t.value }));
return <Animated.View style={style} className="h-1.5 w-1.5 rounded-full bg-success" />;
}
/** Match detail — scoreboard + Events/Stats/Lineups tabs. */
export function MatchDetailScreen({
home = "Arsenal",
away = "Man City",
homeScore = "1",
awayScore = "1",
status = "LIVE · 78'",
league = "Premier League",
homeAvatarUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/5/53/Arsenal_FC.svg/120px-Arsenal_FC.svg.png",
awayAvatarUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/e/eb/Manchester_City_FC_badge.svg/120px-Manchester_City_FC_badge.svg.png",
events = DEFAULT_EVENTS,
stats = DEFAULT_STATS,
lineups = DEFAULT_LINEUPS,
onBack,
}: MatchDetailScreenProps) {
const insets = useSafeAreaInsets();
const [tab, setTab] = React.useState<Tab>("events");
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View pointerEvents="box-none" className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }}>
<View className="flex-row items-center justify-between">
<Glass interactive effect="regular" className="h-11 w-11 items-center justify-center rounded-full">
<Press
haptic="light"
onPress={() => onBack?.()}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
<View className="h-11 w-11" />
</View>
</View>
{/* Scoreboard — the only thing that matters at a glance. */}
<View className="mx-4 mt-14 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
<View className="flex-row items-center justify-center gap-1.5">
<LiveDot />
<Text className="text-center text-[10px] font-semibold uppercase tracking-wide text-success">
{status}
</Text>
</View>
<View className="mt-2 flex-row items-center justify-between">
<View className="w-20 items-center gap-1">
<Avatar size="lg" source={{ uri: homeAvatarUrl }} fallback={home.slice(0, 2).toUpperCase()} alt={home} />
<Text className="text-xs font-medium" numberOfLines={1}>
{home}
</Text>
</View>
<View className="items-center">
<Text className="text-5xl font-bold tracking-tight" style={{ fontFamily: MONO, fontVariant: ["tabular-nums"] }}>
{homeScore} – {awayScore}
</Text>
<Text className="text-xs text-muted-foreground">{league}</Text>
{/* Venue — scoreboards situate the game. */}
<Text className="pt-0.5 text-[10px] text-muted-foreground/70">
Emirates Stadium · 60,704
</Text>
</View>
<View className="w-20 items-center gap-1">
<Avatar size="lg" source={{ uri: awayAvatarUrl }} fallback={away.slice(0, 2).toUpperCase()} alt={away} />
<Text className="text-xs font-medium" numberOfLines={1}>
{away}
</Text>
</View>
</View>
{/* Tabs — Events / Stats / Lineups fill the detail half. */}
<Glass interactive effect="regular" className="mt-4 flex-row self-center rounded-full p-1">
{TABS.map((t) => (
<Press
key={t}
haptic="light"
pressedScale={0.96}
onPress={() => setTab(t)}
accessibilityRole="button"
accessibilityLabel={`${t} tab`}
accessibilityState={{ selected: tab === t }}
className={`rounded-full px-4 py-1.5 ${tab === t ? "bg-card" : ""}`}
>
<Text
className={`text-xs font-medium capitalize ${
tab === t ? "text-foreground" : "text-muted-foreground"
}`}
>
{t}
</Text>
</Press>
))}
</Glass>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
{tab === "events" && (
/* Timeline — spine connects icon discs; minute column left. */
<View className="relative mx-4 mt-4">
<View className="absolute bottom-4 left-[39px] top-4 w-[2px] rounded-full bg-border" />
{events.map((e) => (
<View key={e.id} className="flex-row items-start gap-3 py-2.5">
<Text className="w-9 pt-1 text-right text-sm font-semibold text-primary">
{e.minute}
</Text>
<View
className={`z-10 h-6 w-6 items-center justify-center rounded-full ${
e.team === "home" ? "bg-primary" : "bg-muted-foreground/40"
}`}
>
<Icon
name={KIND_ICON[e.kind ?? "whistle"]}
size="sm"
color="primaryForeground"
/>
</View>
<Text className="flex-1 pt-0.5 text-sm leading-5">{e.text}</Text>
</View>
))}
</View>
)}
{tab === "stats" && (
<Mount>
<View className="mx-4 mt-4 gap-4 overflow-hidden rounded-3xl bg-white p-5 dark:bg-[#1A1A20]">
{stats.map((s) => {
const total = s.home + s.away || 1;
return (
<View key={s.label} className="gap-1.5">
<View className="flex-row items-baseline justify-between">
<Text className="w-12 text-sm font-semibold">{s.home}</Text>
<Text className="text-xs text-muted-foreground">{s.label}</Text>
<Text className="w-12 text-right text-sm font-semibold">{s.away}</Text>
</View>
<View className="flex-row gap-1">
<View className="h-1.5 flex-1 flex-row justify-end rounded-full bg-muted">
<View
className="h-1.5 rounded-full bg-primary"
style={{ width: `${(s.home / total) * 100}%` }}
/>
</View>
<View className="h-1.5 flex-1 rounded-full bg-muted">
<View
className="h-1.5 rounded-full bg-muted-foreground"
style={{ width: `${(s.away / total) * 100}%` }}
/>
</View>
</View>
</View>
);
})}
</View>
</Mount>
)}
{tab === "lineups" && (
<Mount>
<View className="mx-4 mt-4 flex-row gap-3">
{([["home", lineups.home], ["away", lineups.away]] as const).map(([side, names]) => (
<View key={side} className="flex-1 overflow-hidden rounded-3xl bg-white p-4 dark:bg-[#1A1A20]">
<Text className="pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{side === "home" ? home : away}
</Text>
{names.map((n, i) => (
<View key={n} className="flex-row items-center gap-2 py-1">
<Text className="w-4 text-right text-[11px] text-muted-foreground">
{i + 1}
</Text>
<Text className="flex-1 text-sm" numberOfLines={1}>
{n}
</Text>
</View>
))}
</View>
))}
</View>
</Mount>
)}
</ScrollView>
</SafeAreaView>
</World>
);
}
MatchDetailScreen.displayName = "MatchDetailScreen";
```
Screen: https://lumioui.dev/screens/misc-match-detail