A venue seat grid with available, taken, and selected states; seats are real toggle buttons with a selection cap.
npx shadcn@latest add @paragon/seat-map-picker"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>
);
}