Sports

Bet Slip

A sportsbook parlay bet slip with removable selections, a stake input, quick-stake chips, and live combined-odds and payout calculation.

Install

npx shadcn@latest add @paragon/bet-slip

bet-slip.tsx

"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>
  );
}