A fantasy football roster with position slots, matchups, projected points, a live lineup total, and swap actions.
npx shadcn@latest add @paragon/fantasy-lineup"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowLeftRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface LineupPlayer {
id: string;
/** Roster slot, e.g. "QB", "RB", "FLEX". */
slot: string;
name: string;
/** Team + opponent, e.g. "KC vs DEN". */
matchup: string;
/** Projected fantasy points. */
projected: number;
color?: string;
/** Status: "active" plays, "bye" or "out" is flagged. */
status?: "active" | "bye" | "out";
}
export interface FantasyLineupProps extends React.ComponentProps<"div"> {
team?: string;
players?: LineupPlayer[];
}
const DEFAULT_PLAYERS: LineupPlayer[] = [
{ id: "1", slot: "QB", name: "P. Mahomes", matchup: "KC vs DEN", projected: 22.4, color: "#e31837" },
{ id: "2", slot: "RB", name: "B. Robinson", matchup: "ATL @ NO", projected: 16.8, color: "#a71930" },
{ id: "3", slot: "RB", name: "D. Achane", matchup: "MIA vs NYJ", projected: 14.1, color: "#008e97" },
{ id: "4", slot: "WR", name: "J. Chase", matchup: "CIN @ BAL", projected: 18.9, color: "#fb4f14" },
{ id: "5", slot: "WR", name: "A. St. Brown", matchup: "DET vs GB", projected: 15.3, color: "#0076b6" },
{ id: "6", slot: "TE", name: "T. McBride", matchup: "ARI vs SF", projected: 11.2, color: "#97233f" },
{ id: "7", slot: "FLEX", name: "J. Jefferson", matchup: "MIN — BYE", projected: 0, color: "#4f2683", status: "bye" },
];
/**
* A fantasy football roster: position slots, player name and matchup, and
* projected points per player with a live-summed lineup total. Each row has a
* swap affordance (opens a bench, here a no-op button) and flags bye/out
* players. The total ticks with a spring when the roster changes, flattened
* under reduced motion.
*/
export function FantasyLineup({
team = "Paragon Playmakers",
players = DEFAULT_PLAYERS,
className,
...props
}: FantasyLineupProps) {
const reduced = useReducedMotion();
const total = players
.filter((p) => p.status !== "bye" && p.status !== "out")
.reduce((a, p) => a + p.projected, 0);
return (
<div
data-slot="fantasy-lineup"
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div>
<h3 className="text-sm font-semibold">{team}</h3>
<p className="text-xs text-muted-foreground">Week 14 · Starters</p>
</div>
<div className="text-right">
<div className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
Projected
</div>
<motion.div
key={total}
initial={reduced ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="text-lg font-bold tabular-nums leading-none"
>
{total.toFixed(1)}
</motion.div>
</div>
</div>
<ul className="flex flex-col">
{players.map((p) => {
const inactive = p.status === "bye" || p.status === "out";
return (
<li
key={p.id}
className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-b-0"
>
<span className="grid w-11 shrink-0 place-items-center rounded-md bg-muted py-1 text-[11px] font-bold tracking-wide text-muted-foreground">
{p.slot}
</span>
<span
aria-hidden
className="size-2 shrink-0 rounded-full"
style={{ background: p.color ?? "var(--muted-foreground)" }}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{p.name}</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate">{p.matchup}</span>
{p.status === "bye" && (
<span className="rounded bg-warning/15 px-1 py-0.5 text-[9px] font-bold text-warning">
BYE
</span>
)}
{p.status === "out" && (
<span className="rounded bg-destructive/15 px-1 py-0.5 text-[9px] font-bold text-destructive">
OUT
</span>
)}
</div>
</div>
<span
className={cn(
"w-12 text-right text-sm font-bold tabular-nums",
inactive ? "text-muted-foreground/50" : "text-foreground",
)}
>
{inactive ? "—" : p.projected.toFixed(1)}
</span>
<button
type="button"
aria-label={`Swap ${p.name}`}
className="pressable grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ArrowLeftRight className="size-4" />
</button>
</li>
);
})}
</ul>
</div>
);
}