Componentticket-stub5.46 KB gzipZero dependencies
Seat Map
Interactive arena seating chart matrix grid with row letters, seat numbers, tier types, and price calculator.
Preview
Live Preview
INTERACTIVE SEAT MATRIX[MAX 8 SEATS]
BIGBULL AMPHITHEATER
SYMPHONY IN BRASS · MAIN STAGE · SAT, OCT 24 · 20:00
★ MAIN STAGE / ACOUSTIC PODIUM ★
◄ WEST AISLEACOUSTIC CENTERLINEEAST AISLE ►
A
A
B
B
C
C
D
D
E
E
F
F
G
G
H
H
HOVER OVER ANY SEAT FOR ROW, TIER, AND ADMISSION RATE
SELECTED STUBS (2/8)
B-5(vip)$135B-6(vip)$135
STUBS SUBTOTAL:$270.00
FACILITY / STAMP FEE:$9.00
TOTAL ADMISSION:$279.00
TICKET CHARTER #SEC-8820 · VALID UPON TURNSTILE SCANALL SEATS ARE SUBJECT TO VENUE CONDITIONS
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add seat-mapOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { SeatMap } from "@/components/ui/seat-map";
<SeatMap venueName="BIGBULL AMPHITHEATER" maxSeats={6} onSelectionChange={(seats) => console.log(seats)} />Usage guidance
Use Seat Map when a ticket stub surface needs Interactive arena seating chart matrix grid with row letters, seat numbers, tier types, and price calculator. Keep props minimal and prefer composition with sibling stubs.
Avoid Seat Map for long form flows or dense data grids where a dedicated layout block fits better.
Source code
seat-map.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export type SeatTierType = "vip" | "orchestra" | "balcony" | "sold" | "accessible";
export interface SeatTier {
id: SeatTierType | string;
name: string;
price: number;
badgeClass?: string;
description?: string;
}
export interface SeatData {
id: string; // e.g. "A-1"
row: string; // e.g. "A"
number: number; // e.g. 1
tier: SeatTierType | string;
price?: number;
accessible?: boolean;
}
export interface SeatMapProps extends React.HTMLAttributes<HTMLDivElement> {
venueName?: string;
eventName?: string;
eventDate?: string;
stageLabel?: string;
rows?: string[];
seatsPerRow?: number;
aisleAfterSeat?: number;
tiers?: Record<string, SeatTier>;
initialSoldSeats?: string[];
initialAccessibleSeats?: string[];
selectedSeatIds?: string[];
defaultSelectedSeatIds?: string[];
maxSeats?: number;
currency?: string;
serviceFeePerTicket?: number;
onSelectionChange?: (selectedSeats: SeatData[]) => void;
onSeatClick?: (seat: SeatData) => void;
onCheckout?: (selectedSeats: SeatData[], totalPrice: number) => void;
className?: string;
}
const DEFAULT_TIERS: Record<string, SeatTier> = {
vip: {
id: "vip",
name: "VIP Prime",
price: 135,
badgeClass: "border-accent text-accent bg-accent/10",
description: "Front rows with priority acoustic center",
},
orchestra: {
id: "orchestra",
name: "Orchestra",
price: 85,
badgeClass: "border-foreground text-foreground bg-secondary",
description: "Main floor prime viewing sightlines",
},
balcony: {
id: "balcony",
name: "Balcony Loge",
price: 45,
badgeClass: "border-border text-muted-foreground bg-card",
description: "Elevated tier overview seating",
},
accessible: {
id: "accessible",
name: "Accessible",
price: 60,
badgeClass: "border-success text-success bg-success/10",
description: "Step-free wheelchair companion access",
},
sold: {
id: "sold",
name: "Sold Out",
price: 0,
badgeClass: "border-border/50 text-muted-foreground/50 bg-muted/40 line-through",
description: "Seat unavailable for purchase",
},
};
const DEFAULT_ROWS = ["A", "B", "C", "D", "E", "F", "G", "H"];
const DEFAULT_SEATS_PER_ROW = 12;
const DEFAULT_AISLE_AFTER = 6;
const DEFAULT_SOLD_SEATS = [
"A-3",
"A-4",
"B-7",
"B-8",
"C-1",
"C-12",
"D-5",
"D-6",
"E-2",
"E-9",
"F-6",
"F-7",
"G-3",
"G-10",
"H-4",
];
const DEFAULT_ACCESSIBLE_SEATS = ["H-1", "H-12"];
function getTierForRow(row: string): "vip" | "orchestra" | "balcony" {
if (row === "A" || row === "B") return "vip";
if (row === "C" || row === "D" || row === "E") return "orchestra";
return "balcony";
}
/**
* SeatMap: Interactive arena seating chart matrix grid with row letters,
* seat numbers, tier types, selection toggle, price calculator readout, and ticket punch border.
*/
export function SeatMap({
venueName = "GRAND AUDITORIUM",
eventName = "BIGBULL SYMPHONY · OPENING NIGHT",
eventDate = "SAT, OCT 24 · 20:00",
stageLabel = "★ MAIN STAGE / ACOUSTIC PODIUM ★",
rows = DEFAULT_ROWS,
seatsPerRow = DEFAULT_SEATS_PER_ROW,
aisleAfterSeat = DEFAULT_AISLE_AFTER,
tiers = DEFAULT_TIERS,
initialSoldSeats = DEFAULT_SOLD_SEATS,
initialAccessibleSeats = DEFAULT_ACCESSIBLE_SEATS,
selectedSeatIds,
defaultSelectedSeatIds = ["A-6"],
maxSeats = 8,
currency = "$",
serviceFeePerTicket = 4.5,
onSelectionChange,
onSeatClick,
onCheckout,
className,
...props
}: SeatMapProps) {
const isControlled = selectedSeatIds !== undefined;
const [internalSelectedIds, setInternalSelectedIds] = React.useState<string[]>(
defaultSelectedSeatIds
);
const activeSelectedIds = isControlled ? selectedSeatIds : internalSelectedIds;
const [hoveredSeat, setHoveredSeat] = React.useState<SeatData | null>(null);
const [activeTierHighlight, setActiveTierHighlight] = React.useState<string | null>(null);
const [limitNotice, setLimitNotice] = React.useState(false);
// Generate seat data map
const allSeatsMap = React.useMemo(() => {
const map = new Map<string, SeatData>();
rows.forEach((row) => {
for (let num = 1; num <= seatsPerRow; num++) {
const id = `${row}-${num}`;
const isSold = initialSoldSeats.includes(id);
const isAccessible = initialAccessibleSeats.includes(id);
let tier: string = getTierForRow(row);
if (isAccessible) tier = "accessible";
if (isSold) tier = "sold";
const price = tiers[tier]?.price ?? 50;
map.set(id, {
id,
row,
number: num,
tier,
price,
accessible: isAccessible,
});
}
});
return map;
}, [rows, seatsPerRow, tiers, initialSoldSeats, initialAccessibleSeats]);
const selectedSeatsList = React.useMemo(() => {
return activeSelectedIds
.map((id) => allSeatsMap.get(id))
.filter((s): s is SeatData => s !== undefined);
}, [activeSelectedIds, allSeatsMap]);
// Price calculations
const subtotal = React.useMemo(() => {
return selectedSeatsList.reduce((acc, s) => acc + (s.price || 0), 0);
}, [selectedSeatsList]);
const totalFee = selectedSeatsList.length * serviceFeePerTicket;
const grandTotal = subtotal > 0 ? subtotal + totalFee : 0;
const handleToggleSeat = (seat: SeatData) => {
if (seat.tier === "sold") return;
if (onSeatClick) {
onSeatClick(seat);
}
const isAlreadySelected = activeSelectedIds.includes(seat.id);
let nextIds: string[];
if (isAlreadySelected) {
nextIds = activeSelectedIds.filter((id) => id !== seat.id);
setLimitNotice(false);
} else {
if (activeSelectedIds.length >= maxSeats) {
setLimitNotice(true);
setTimeout(() => setLimitNotice(false), 2400);
return;
}
nextIds = [...activeSelectedIds, seat.id];
}
if (!isControlled) {
setInternalSelectedIds(nextIds);
}
if (onSelectionChange) {
const nextSeats = nextIds
.map((id) => allSeatsMap.get(id))
.filter((s): s is SeatData => s !== undefined);
onSelectionChange(nextSeats);
}
};
const handleClear = () => {
if (!isControlled) {
setInternalSelectedIds([]);
}
if (onSelectionChange) {
onSelectionChange([]);
}
};
const handleCheckoutClick = () => {
if (onCheckout && selectedSeatsList.length > 0) {
onCheckout(selectedSeatsList, grandTotal);
}
};
return (
<div
className={cn(
"relative w-full rounded-xl border-2 border-foreground bg-card text-card-foreground shadow-[6px_6px_0_0_var(--color-border)] outline-1 outline-dashed outline-offset-[-6px] overflow-hidden select-none",
className
)}
{...props}
>
{/* Side Punch Notches along Ticket Frame */}
<div
aria-hidden="true"
className="absolute -left-3 top-1/4 -translate-y-1/2 size-5 rounded-full border-2 border-foreground bg-background z-30"
/>
<div
aria-hidden="true"
className="absolute -right-3 top-1/4 -translate-y-1/2 size-5 rounded-full border-2 border-foreground bg-background z-30"
/>
<div
aria-hidden="true"
className="absolute -left-3 top-3/4 -translate-y-1/2 size-5 rounded-full border-2 border-foreground bg-background z-30"
/>
<div
aria-hidden="true"
className="absolute -right-3 top-3/4 -translate-y-1/2 size-5 rounded-full border-2 border-foreground bg-background z-30"
/>
{/* Arena Header Stub */}
<div className="border-b-2 border-dashed border-border bg-secondary/60 p-4 sm:p-5">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-bold tracking-[0.25em] text-accent uppercase">
INTERACTIVE SEAT MATRIX
</span>
<span className="font-mono text-[10px] text-muted-foreground">
[MAX {maxSeats} SEATS]
</span>
</div>
<h2 className="mt-1 text-lg sm:text-2xl font-black tracking-tight uppercase font-mono text-foreground">
{venueName}
</h2>
<p className="font-mono text-xs text-muted-foreground uppercase tracking-wider">
{eventName} · {eventDate}
</p>
</div>
{/* Interactive Tier Badges */}
<div className="flex flex-wrap items-center gap-1.5 self-start sm:self-auto">
{Object.values(tiers).map((t) => {
const isActive = activeTierHighlight === t.id;
return (
<button
key={t.id}
type="button"
onMouseEnter={() => setActiveTierHighlight(t.id)}
onMouseLeave={() => setActiveTierHighlight(null)}
className={cn(
"font-mono text-[10px] uppercase font-bold px-2 py-1 rounded border transition-all flex items-center gap-1.5",
t.badgeClass,
isActive && "ring-2 ring-foreground scale-105"
)}
title={t.description}
>
<span className="size-1.5 rounded-full bg-current" />
<span>{t.name}</span>
{t.price > 0 && <span>{currency}{t.price}</span>}
</button>
);
})}
</div>
</div>
{limitNotice && (
<div className="mt-3 rounded border border-accent bg-accent/10 px-3 py-1.5 text-center font-mono text-xs font-bold uppercase tracking-wider text-accent animate-pulse">
★ MAXIMUM LIMIT REACHED ({maxSeats} TICKETS PER ADMISSION) ★
</div>
)}
</div>
{/* Seating Arena Chart */}
<div className="p-4 sm:p-6 overflow-x-auto">
<div className="min-w-[540px] flex flex-col items-center">
{/* Stage Graphic */}
<div className="w-4/5 max-w-md mb-8 text-center">
<div className="relative py-2.5 px-4 rounded-t-2xl border-2 border-foreground bg-secondary/80 shadow-inner">
<span className="font-mono text-[11px] font-bold uppercase tracking-[0.25em] text-foreground block">
{stageLabel}
</span>
<div className="mt-1 h-[2px] w-full border-b border-dashed border-border" />
</div>
<div className="mt-1.5 flex items-center justify-between text-[9px] font-mono text-muted-foreground tracking-widest uppercase px-2">
<span>◄ WEST AISLE</span>
<span>ACOUSTIC CENTERLINE</span>
<span>EAST AISLE ►</span>
</div>
</div>
{/* Matrix Grid */}
<div className="space-y-2.5 w-full max-w-xl">
{rows.map((row) => {
return (
<div key={row} className="flex items-center justify-center gap-2 sm:gap-3">
{/* Left Row Indicator */}
<span className="w-5 text-center font-mono text-xs font-bold text-muted-foreground uppercase">
{row}
</span>
{/* Seat Buttons */}
<div className="flex items-center gap-1 sm:gap-1.5">
{Array.from({ length: seatsPerRow }).map((_, i) => {
const seatNum = i + 1;
const seatId = `${row}-${seatNum}`;
const seat = allSeatsMap.get(seatId);
if (!seat) return null;
const isSold = seat.tier === "sold";
const isSelected = activeSelectedIds.includes(seatId);
const isHighlighted =
activeTierHighlight !== null && seat.tier === activeTierHighlight;
const isAisle = seatNum === aisleAfterSeat;
return (
<React.Fragment key={seatId}>
<button
type="button"
onClick={() => handleToggleSeat(seat)}
onMouseEnter={() => setHoveredSeat(seat)}
onMouseLeave={() => setHoveredSeat(null)}
disabled={isSold}
aria-label={`Row ${row}, Seat ${seatNum}, ${seat.tier}, ${currency}${seat.price}`}
aria-pressed={isSelected}
className={cn(
"relative group size-6 sm:size-7 rounded-t-md rounded-b-xs border flex items-center justify-center font-mono text-[10px] font-bold transition-all focus-visible:ring-2 ring-ring",
// Tier Styles
seat.tier === "vip" &&
"border-accent/70 bg-card text-foreground hover:border-accent hover:bg-accent/15",
seat.tier === "orchestra" &&
"border-border bg-card text-foreground hover:border-foreground hover:bg-secondary",
seat.tier === "balcony" &&
"border-border/60 bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-secondary",
seat.tier === "accessible" &&
"border-success bg-card text-success hover:bg-success/15",
isSold &&
"border-border/30 bg-muted/50 text-muted-foreground/30 cursor-not-allowed line-through",
// Selected State
isSelected &&
"border-accent bg-accent text-accent-foreground font-black shadow-md scale-110 ring-2 ring-accent ring-offset-1 z-10",
// Highlighted from Legend
isHighlighted && !isSelected && "ring-2 ring-foreground scale-105"
)}
>
{seat.accessible ? "♿" : isSold ? "✕" : seatNum}
{/* Tactile Seat Cushion bottom notch */}
<span
className={cn(
"absolute bottom-0 inset-x-1 h-[2px] rounded-t-xs",
isSelected ? "bg-white/40" : "bg-black/20 dark:bg-white/10"
)}
/>
</button>
{/* Aisle Spacer */}
{isAisle && (
<div
aria-hidden="true"
className="w-4 sm:w-6 border-b-2 border-dotted border-border/60"
title="Center Aisle"
/>
)}
</React.Fragment>
);
})}
</div>
{/* Right Row Indicator */}
<span className="w-5 text-center font-mono text-xs font-bold text-muted-foreground uppercase">
{row}
</span>
</div>
);
})}
</div>
{/* Live Hover Readout Bar */}
<div className="mt-6 h-7 flex items-center justify-center font-mono text-xs">
{hoveredSeat ? (
<div className="flex items-center gap-2 px-3 py-1 rounded border border-dashed border-border bg-secondary/40 text-foreground">
<span className="font-bold">ROW {hoveredSeat.row} · SEAT {hoveredSeat.number}</span>
<span className="text-muted-foreground">/</span>
<span className="uppercase text-accent font-semibold">{hoveredSeat.tier}</span>
<span className="text-muted-foreground">/</span>
<span className="font-mono font-bold">
{hoveredSeat.tier === "sold" ? "UNAVAILABLE" : `${currency}${hoveredSeat.price}.00`}
</span>
</div>
) : (
<span className="text-muted-foreground font-mono text-[11px] uppercase tracking-wider">
HOVER OVER ANY SEAT FOR ROW, TIER, AND ADMISSION RATE
</span>
)}
</div>
</div>
</div>
{/* Perforation Divider Line with Punch Notches */}
<div className="relative border-t-2 border-dashed border-border">
<div
aria-hidden="true"
className="absolute -left-2.5 -top-2.5 size-5 rounded-full border-2 border-foreground bg-background z-20"
/>
<div
aria-hidden="true"
className="absolute -right-2.5 -top-2.5 size-5 rounded-full border-2 border-foreground bg-background z-20"
/>
</div>
{/* Price Calculator & Ticket Stub Readout */}
<div className="p-4 sm:p-6 bg-secondary/40">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-5">
{/* Selected Seats Pills List */}
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
SELECTED STUBS ({selectedSeatsList.length}/{maxSeats})
</span>
{selectedSeatsList.length > 0 && (
<button
type="button"
onClick={handleClear}
className="font-mono text-[10px] uppercase font-bold text-accent hover:underline"
>
CLEAR ALL
</button>
)}
</div>
{selectedSeatsList.length === 0 ? (
<div className="rounded border border-dashed border-border bg-card p-3 text-center font-mono text-xs text-muted-foreground uppercase tracking-wider">
NO SEATS CURRENTLY RESERVED · CLICK AVAILABLE SEATS ABOVE
</div>
) : (
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pe-1">
{selectedSeatsList.map((s) => (
<span
key={s.id}
className="inline-flex items-center gap-1.5 rounded border border-foreground/30 bg-card px-2.5 py-1 font-mono text-xs text-foreground shadow-xs"
>
<span className="font-bold">
{s.row}-{s.number}
</span>
<span className="text-[10px] text-muted-foreground uppercase">
({s.tier})
</span>
<span className="font-semibold text-accent">
{currency}{s.price}
</span>
<button
type="button"
onClick={() => handleToggleSeat(s)}
className="ms-0.5 text-muted-foreground hover:text-destructive font-bold focus-visible:outline-hidden"
title={`Remove seat ${s.id}`}
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Calculator Readout & Checkout Button */}
<div className="flex flex-col sm:flex-row lg:flex-col sm:items-end justify-between gap-4 lg:w-72 pt-3 lg:pt-0 border-t lg:border-t-0 border-dashed border-border">
<div className="w-full space-y-1 font-mono text-xs">
<div className="flex items-center justify-between text-muted-foreground">
<span>STUBS SUBTOTAL:</span>
<span className="text-foreground">{currency}{subtotal.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>FACILITY / STAMP FEE:</span>
<span className="text-foreground">{currency}{totalFee.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between font-bold text-sm text-foreground pt-1.5 border-t border-dashed border-border">
<span className="uppercase tracking-wider">TOTAL ADMISSION:</span>
<span className="text-lg text-accent font-black tracking-tight">
{currency}{grandTotal.toFixed(2)}
</span>
</div>
</div>
<button
type="button"
onClick={handleCheckoutClick}
disabled={selectedSeatsList.length === 0}
className={cn(
"w-full py-2.5 px-4 rounded-md font-mono text-xs font-black uppercase tracking-wider transition-all focus-visible:ring-2 ring-ring flex items-center justify-center gap-2",
selectedSeatsList.length > 0
? "bg-foreground text-background hover:opacity-90 shadow-md active:translate-y-0.5"
: "bg-muted text-muted-foreground cursor-not-allowed border border-border"
)}
>
<span>ADMIT & RESERVE ({selectedSeatsList.length})</span>
<span>→</span>
</button>
</div>
</div>
{/* Security Stamp Micro Line */}
<div className="mt-4 pt-3 border-t border-dashed border-border/50 flex flex-col sm:flex-row items-center justify-between gap-1 text-center font-mono text-[9px] text-muted-foreground uppercase tracking-widest">
<span>TICKET CHARTER #SEC-8820 · VALID UPON TURNSTILE SCAN</span>
<span>ALL SEATS ARE SUBJECT TO VENUE CONDITIONS</span>
</div>
</div>
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| venueName? | string | |
| eventName? | string | |
| eventDate? | string | |
| stageLabel? | string | |
| rows? | string[] | |
| seatsPerRow? | number | |
| aisleAfterSeat? | number | |
| tiers? | Record<string, SeatTier> | |
| initialSoldSeats? | string[] | |
| initialAccessibleSeats? | string[] | |
| selectedSeatIds? | string[] | |
| defaultSelectedSeatIds? | string[] | |
| maxSeats? | number | |
| currency? | string | |
| serviceFeePerTicket? | number | |
| onSelectionChange? | (selectedSeats: SeatData[]) => void | |
| onSeatClick? | (seat: SeatData) => void | |
| onCheckout? | (selectedSeats: SeatData[], totalPrice: number) => void | |
| className? | string |