Sports

Seat Map Picker

A venue seat grid with available, taken, and selected states; seats are real toggle buttons with a selection cap.

Install

npx shadcn@latest add @paragon/seat-map-picker

seat-map-picker.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type SeatStatus = "available" | "taken";

export interface Seat {
  id: string;
  row: string;
  number: number;
  status: SeatStatus;
}

export interface SeatMapPickerProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** Rows of seats, top (stage-facing) first. */
  rows: Seat[][];
  /** Max seats selectable at once. */
  max?: number;
  /** Controlled set of selected seat ids. */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (ids: string[]) => void;
}

/**
 * A venue seat grid with available / taken / selected states. Seats are real
 * toggle buttons (keyboard + focus-visible), so selection is zoom-agnostic and
 * works by tap or key. Selecting past `max` drops the oldest pick so the count
 * never exceeds the cap. Taken seats are disabled. The press feedback is the
 * house pressable scale; color is the only channel under reduced motion.
 */
export function SeatMapPicker({
  rows,
  max = 4,
  value,
  defaultValue = [],
  onValueChange,
  className,
  ...props
}: SeatMapPickerProps) {
  const [internal, setInternal] = React.useState<string[]>(defaultValue);
  const selected = value ?? internal;

  const toggle = (id: string) => {
    const next = selected.includes(id)
      ? selected.filter((s) => s !== id)
      : [...selected, id].slice(-max);
    if (value === undefined) setInternal(next);
    onValueChange?.(next);
  };

  return (
    <div
      data-slot="seat-map-picker"
      className={cn(
        "flex w-full max-w-sm flex-col items-center gap-4 rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="w-full rounded-md bg-muted py-1.5 text-center text-xs font-medium tracking-wide text-muted-foreground">
        STAGE
      </div>

      <div
        role="group"
        aria-label="Seat selection"
        className="flex flex-col gap-1.5"
      >
        {rows.map((row) => (
          <div key={row[0]?.row} className="flex items-center gap-1.5">
            <span
              aria-hidden
              className="w-4 text-right text-xs tabular-nums text-muted-foreground"
            >
              {row[0]?.row}
            </span>
            {row.map((seat) => {
              const isSelected = selected.includes(seat.id);
              const taken = seat.status === "taken";
              return (
                <button
                  key={seat.id}
                  type="button"
                  aria-pressed={isSelected}
                  aria-disabled={taken}
                  disabled={taken}
                  aria-label={`Row ${seat.row}, seat ${seat.number}${
                    taken ? ", taken" : isSelected ? ", selected" : ", available"
                  }`}
                  onClick={() => !taken && toggle(seat.id)}
                  className={cn(
                    "pressable size-6 rounded-md text-[10px] font-medium tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
                    taken &&
                      "cursor-not-allowed bg-muted text-muted-foreground/40",
                    !taken &&
                      !isSelected &&
                      "bg-secondary text-secondary-foreground hover:bg-accent",
                    isSelected &&
                      "bg-primary text-primary-foreground",
                  )}
                >
                  {seat.number}
                </button>
              );
            })}
          </div>
        ))}
      </div>

      <div className="flex items-center gap-4 text-xs text-muted-foreground">
        <Legend swatch="bg-secondary" label="Available" />
        <Legend swatch="bg-primary" label="Selected" />
        <Legend swatch="bg-muted" label="Taken" />
      </div>

      <p className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
        {selected.length} of {max} seats selected
      </p>
    </div>
  );
}

function Legend({ swatch, label }: { swatch: string; label: string }) {
  return (
    <span className="inline-flex items-center gap-1.5">
      <span aria-hidden className={cn("size-3 rounded-sm", swatch)} />
      {label}
    </span>
  );
}