A sportsbook parlay bet slip with removable selections, a stake input, quick-stake chips, and live combined-odds and payout calculation.
npx shadcn@latest add @paragon/bet-slip"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface BetSelection {
id: string;
/** Market label, e.g. "Celtics -4.5". */
pick: string;
/** Event label, e.g. "BOS vs LAL". */
event: string;
/** American odds, e.g. -110 or +150. */
odds: number;
}
export interface BetSlipProps extends React.ComponentProps<"div"> {
selections?: BetSelection[];
/** Initial stake in dollars (parlay mode). */
stake?: number;
/** Currency prefix. */
currency?: string;
}
const DEFAULT_SELECTIONS: BetSelection[] = [
{ id: "1", pick: "Celtics -4.5", event: "BOS vs LAL", odds: -110 },
{ id: "2", pick: "Over 224.5", event: "BOS vs LAL", odds: -105 },
{ id: "3", pick: "Nuggets ML", event: "DEN vs GSW", odds: 135 },
];
/** American odds → decimal multiplier. */
function decimal(odds: number) {
return odds > 0 ? odds / 100 + 1 : 100 / -odds + 1;
}
function fmtOdds(odds: number) {
return odds > 0 ? `+${odds}` : `${odds}`;
}
function money(n: number, currency: string) {
return `${currency}${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* A sportsbook parlay bet slip: removable selections, a stake input, and a
* live-calculated combined-odds and potential-payout summary. Removing a leg
* animates out with the house exit language (flattened under reduced motion)
* and instantly recomputes the parlay. Quick-stake chips fill common amounts.
*/
export function BetSlip({
selections = DEFAULT_SELECTIONS,
stake = 25,
currency = "$",
className,
...props
}: BetSlipProps) {
const [legs, setLegs] = React.useState(selections);
const [amount, setAmount] = React.useState(stake);
const reduced = useReducedMotion();
const combined = legs.reduce((acc, l) => acc * decimal(l.odds), 1);
const payout = amount * combined;
const profit = payout - amount;
const combinedAmerican =
combined >= 2
? `+${Math.round((combined - 1) * 100)}`
: `-${Math.round(100 / (combined - 1))}`;
return (
<div
data-slot="bet-slip"
className={cn(
"flex w-full max-w-xs flex-col 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">
<h3 className="text-sm font-semibold">
{legs.length > 1 ? `${legs.length}-Leg Parlay` : "Bet Slip"}
</h3>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{legs.length}
</span>
</div>
<ul className="flex flex-col">
<AnimatePresence initial={false}>
{legs.map((leg) => (
<motion.li
key={leg.id}
layout={reduced ? false : "position"}
initial={false}
exit={
reduced
? { opacity: 0 }
: { opacity: 0, y: -12, filter: "blur(4px)" }
}
transition={{ duration: 0.15, ease: [0.4, 0, 1, 1] }}
className="flex items-start gap-2 border-b border-border/60 px-4 py-2.5"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">
{leg.pick}
</span>
<span className="ml-auto shrink-0 font-mono text-sm font-semibold tabular-nums">
{fmtOdds(leg.odds)}
</span>
</div>
<p className="text-xs text-muted-foreground">{leg.event}</p>
</div>
<button
type="button"
aria-label={`Remove ${leg.pick}`}
onClick={() => setLegs((prev) => prev.filter((l) => l.id !== leg.id))}
className="pressable -mr-1 grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<X className="size-4" />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
{legs.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Your bet slip is empty.
</p>
) : (
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Combined odds</span>
<span className="font-mono font-semibold tabular-nums">
{combinedAmerican}
</span>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
Stake
</span>
<div className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-2 focus-within:ring-2 focus-within:ring-ring">
<span className="text-sm font-medium text-muted-foreground">
{currency}
</span>
<input
type="number"
min={0}
step={1}
value={amount}
onChange={(e) =>
setAmount(Math.max(0, Number(e.target.value) || 0))
}
aria-label="Stake amount"
className="w-full bg-transparent text-sm font-semibold tabular-nums outline-none"
/>
</div>
</label>
<div className="flex gap-1.5">
{[10, 25, 50, 100].map((v) => (
<button
key={v}
type="button"
onClick={() => setAmount(v)}
className={cn(
"pressable flex-1 rounded-md py-1 text-xs font-medium tabular-nums transition-colors duration-150",
amount === v
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
>
{currency}
{v}
</button>
))}
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<div>
<div className="text-xs text-muted-foreground">To win</div>
<div className="text-lg font-bold tabular-nums text-success">
{money(profit, currency)}
</div>
</div>
<button
type="button"
className="pressable inline-flex h-10 items-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition-colors duration-150 hover:bg-primary/90"
>
Place · {money(amount, currency)}
</button>
</div>
<p className="text-center text-[11px] tabular-nums text-muted-foreground">
Total payout {money(payout, currency)}
</p>
</div>
)}
</div>
);
}