Componentform3.73 KB gzipZero dependencies
Radio Cards
Rich selectable radio cards with prices, perks and active ticket border.
Preview
Live Preview
$45/pass
STATUS: AVAILABLEADMIT TIER
$120/pass
STATUS: SELECTEDADMIT TIER
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add radio-cardsOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { RadioCards, RadioCard, RadioCardHeader, RadioCardPrice } from "@/components/ui/radio-cards";
<RadioCards defaultValue="vip" columns={2}>
<RadioCard value="std"><RadioCardHeader title="General" /><RadioCardPrice amount="$45" /></RadioCard>
<RadioCard value="vip"><RadioCardHeader title="VIP" /><RadioCardPrice amount="$120" /></RadioCard>
</RadioCards>Usage guidance
Use Radio Cards when a ticket stub surface needs Rich selectable radio cards with prices, perks and active ticket border. Keep props minimal and prefer composition with sibling stubs.
Avoid Radio Cards for long form flows or dense data grids where a dedicated layout block fits better.
Source code
radio-cards.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
/* -------------------------------------------------------------------------
* Context & Types
* ----------------------------------------------------------------------- */
interface RadioCardsContextValue {
value?: string;
name?: string;
disabled?: boolean;
onSelect: (val: string) => void;
registerItem: (id: string, element: HTMLDivElement | null) => void;
}
const RadioCardsContext = React.createContext<RadioCardsContextValue | null>(null);
function useRadioCards() {
const context = React.useContext(RadioCardsContext);
if (!context) {
throw new Error("RadioCard components must be used within a RadioCards provider.");
}
return context;
}
export interface RadioCardsProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
name?: string;
disabled?: boolean;
orientation?: "horizontal" | "vertical" | "grid";
columns?: 1 | 2 | 3 | 4;
className?: string;
children?: React.ReactNode;
}
/* -------------------------------------------------------------------------
* Root: RadioCards
* ----------------------------------------------------------------------- */
export function RadioCards({
value,
defaultValue,
onValueChange,
name,
disabled = false,
orientation = "grid",
columns = 3,
className,
children,
...props
}: RadioCardsProps) {
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? "");
const isControlled = value !== undefined;
const currentValue = isControlled ? value : uncontrolledValue;
const generatedName = React.useId();
const groupName = name || generatedName;
const itemElementsRef = React.useRef<Map<string, HTMLDivElement>>(new Map());
const registerItem = React.useCallback((id: string, element: HTMLDivElement | null) => {
if (element) {
itemElementsRef.current.set(id, element);
} else {
itemElementsRef.current.delete(id);
}
}, []);
const handleSelect = React.useCallback(
(nextValue: string) => {
if (disabled) return;
if (!isControlled) {
setUncontrolledValue(nextValue);
}
onValueChange?.(nextValue);
},
[disabled, isControlled, onValueChange]
);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
// Collect all non-disabled cards in DOM order
const cardElements = Array.from(
event.currentTarget.querySelectorAll<HTMLDivElement>('[role="radio"]:not([aria-disabled="true"])')
);
if (cardElements.length === 0) return;
const currentIndex = cardElements.findIndex((el) => el === document.activeElement);
let nextIndex = currentIndex;
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
event.preventDefault();
nextIndex = currentIndex < cardElements.length - 1 ? currentIndex + 1 : 0;
break;
case "ArrowUp":
case "ArrowLeft":
event.preventDefault();
nextIndex = currentIndex > 0 ? currentIndex - 1 : cardElements.length - 1;
break;
case "Home":
event.preventDefault();
nextIndex = 0;
break;
case "End":
event.preventDefault();
nextIndex = cardElements.length - 1;
break;
case " ":
case "Enter":
if (currentIndex >= 0 && currentIndex < cardElements.length) {
event.preventDefault();
const target = cardElements[currentIndex];
const val = target.getAttribute("data-value");
if (val) handleSelect(val);
}
return;
default:
return;
}
const nextCard = cardElements[nextIndex];
if (nextCard) {
nextCard.focus();
const val = nextCard.getAttribute("data-value");
if (val) {
handleSelect(val);
}
}
};
const orientationStyles = {
vertical: "flex flex-col gap-4",
horizontal: "flex flex-row flex-wrap gap-4",
grid: cn(
"grid gap-4",
columns === 1 && "grid-cols-1",
columns === 2 && "grid-cols-1 md:grid-cols-2",
columns === 3 && "grid-cols-1 md:grid-cols-3",
columns === 4 && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
),
};
return (
<RadioCardsContext.Provider
value={{
value: currentValue,
name: groupName,
disabled,
onSelect: handleSelect,
registerItem,
}}
>
<div
role="radiogroup"
aria-disabled={disabled}
onKeyDown={handleKeyDown}
className={cn(orientationStyles[orientation], className)}
{...props}
>
{children}
</div>
</RadioCardsContext.Provider>
);
}
/* -------------------------------------------------------------------------
* Item: RadioCard
* ----------------------------------------------------------------------- */
export interface RadioCardProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
title?: string;
subtitle?: string;
tier?: string;
price?: string;
period?: string;
badge?: string;
badgeVariant?: "accent" | "default" | "secondary" | "destructive";
perks?: string[];
disabled?: boolean;
soldOut?: boolean;
notched?: boolean;
serial?: string;
className?: string;
children?: React.ReactNode;
}
export function RadioCard({
value,
title,
subtitle,
tier,
price,
period = "/pass",
badge,
badgeVariant = "accent",
perks,
disabled = false,
soldOut = false,
notched = true,
serial,
className,
children,
...props
}: RadioCardProps) {
const { value: groupValue, disabled: groupDisabled, onSelect, registerItem } = useRadioCards();
const isChecked = groupValue === value;
const isDisabled = disabled || groupDisabled || soldOut;
const cardRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
registerItem(value, cardRef.current);
return () => registerItem(value, null);
}, [value, registerItem]);
const handleClick = () => {
if (!isDisabled) {
onSelect(value);
}
};
const badgeVariantStyles = {
accent: "border-accent text-accent bg-accent/10",
default: "border-foreground text-foreground bg-foreground/10",
secondary: "border-border text-muted-foreground bg-secondary",
destructive: "border-destructive text-destructive bg-destructive/10",
};
return (
<div
ref={cardRef}
role="radio"
aria-checked={isChecked}
aria-disabled={isDisabled}
tabIndex={isDisabled ? -1 : isChecked ? 0 : -1}
data-value={value}
onClick={handleClick}
className={cn(
// Base ticket frame geometry
"relative flex flex-col justify-between rounded-lg border-2 p-5 text-start transition-all duration-200 select-none outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
// Interactive state
isDisabled
? "cursor-not-allowed opacity-55 border-dashed border-border bg-card/40"
: "cursor-pointer hover:-translate-y-0.5",
// Selection state
!isDisabled && isChecked
? "border-solid border-foreground bg-card shadow-md scale-[1.01]"
: !isDisabled && "border-dashed border-border bg-card/70 hover:border-foreground/60 hover:bg-card",
className
)}
{...props}
>
{/* Inner Decorative Framing */}
<div className="pointer-events-none absolute inset-1.5 rounded-[4px] border border-dashed border-border/40" />
{/* Ticket Semicircular Punch Notches */}
{notched && (
<>
<div
aria-hidden="true"
className="absolute -left-2 top-1/2 -translate-y-1/2 size-3.5 rounded-full border-2 border-foreground bg-background z-10"
/>
<div
aria-hidden="true"
className="absolute -right-2 top-1/2 -translate-y-1/2 size-3.5 rounded-full border-2 border-foreground bg-background z-10"
/>
</>
)}
{/* Sold Out Stamp Overlay */}
{soldOut && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="rotate-[-12deg] rounded border-2 border-destructive bg-background/95 px-3 py-1 font-mono text-xs font-black tracking-[0.25em] text-destructive shadow-sm animate-[stamp_0.3s_ease-out_both]">
SOLD OUT
</span>
</div>
)}
{/* Card Header Section */}
<div>
<div className="flex items-start justify-between gap-2">
{/* Tier Label or Serial */}
<div className="flex flex-col">
{tier && (
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-accent">
{tier}
</span>
)}
{serial && (
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">
{serial}
</span>
)}
</div>
{/* Stamped Badge & Custom Radio Indicator */}
<div className="flex items-center gap-2">
{badge && (
<span
className={cn(
"rotate-[-2deg] rounded-xs border px-1.5 py-0.5 font-mono text-[9px] font-extrabold uppercase tracking-widest",
badgeVariantStyles[badgeVariant]
)}
>
{badge}
</span>
)}
{/* Custom Ticket Stub Radio Button */}
<div
aria-hidden="true"
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-all duration-200",
isChecked
? "border-foreground bg-accent text-accent-foreground shadow-xs scale-105"
: "border-dashed border-muted-foreground/60 bg-transparent hover:border-foreground"
)}
>
{isChecked && (
<div className="size-2 rounded-full bg-accent-foreground animate-[scale-in_0.15s_ease-out]" />
)}
</div>
</div>
</div>
{/* Title and Subtitle */}
{title && (
<h3 className="mt-3 font-mono text-base font-bold uppercase tracking-wide text-foreground">
{title}
</h3>
)}
{subtitle && (
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
{subtitle}
</p>
)}
{/* Ticket Price Readout */}
{price && (
<div className="mt-4 flex items-baseline gap-1.5 border-b border-dashed border-border pb-4">
<span className="font-mono text-3xl font-black tracking-tight text-foreground">
{price}
</span>
{period && (
<span className="font-mono text-xs text-muted-foreground font-medium">
{period}
</span>
)}
</div>
)}
{/* Perks Checklist */}
{perks && perks.length > 0 && (
<ul className="mt-4 space-y-2">
{perks.map((perk, idx) => (
<li key={idx} className="flex items-start gap-2 font-mono text-xs text-foreground/80">
<svg
aria-hidden="true"
className={cn(
"mt-0.5 size-3.5 shrink-0 transition-colors",
isChecked ? "text-accent" : "text-muted-foreground/60"
)}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.5 8.5l3 3 6.5-6.5" />
</svg>
<span>{perk}</span>
</li>
))}
</ul>
)}
{/* Custom Children Container */}
{children && <div className="mt-3">{children}</div>}
</div>
{/* Bottom Perforation Footnote */}
<div className="mt-5 flex items-center justify-between pt-3 border-t border-dashed border-border/60 text-[9px] font-mono text-muted-foreground uppercase tracking-widest">
<span>STATUS: {soldOut ? "UNAVAILABLE" : isChecked ? "SELECTED" : "AVAILABLE"}</span>
<span>ADMIT TIER</span>
</div>
</div>
);
}
/* -------------------------------------------------------------------------
* Subcomponents for custom layout flexibility
* ----------------------------------------------------------------------- */
export interface RadioCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
children?: React.ReactNode;
}
export function RadioCardHeader({ className, children, ...props }: RadioCardHeaderProps) {
return (
<div className={cn("flex flex-col space-y-1", className)} {...props}>
{children}
</div>
);
}
export interface RadioCardPriceProps extends React.HTMLAttributes<HTMLDivElement> {
price: string;
period?: string;
className?: string;
}
export function RadioCardPrice({ price, period = "/pass", className, ...props }: RadioCardPriceProps) {
return (
<div className={cn("mt-4 flex items-baseline gap-1 border-b border-dashed border-border pb-3", className)} {...props}>
<span className="font-mono text-3xl font-black tracking-tight text-foreground">{price}</span>
{period && <span className="font-mono text-xs text-muted-foreground font-medium">{period}</span>}
</div>
);
}
export interface RadioCardPerksProps extends React.HTMLAttributes<HTMLUListElement> {
perks: string[];
className?: string;
}
export function RadioCardPerks({ perks, className, ...props }: RadioCardPerksProps) {
return (
<ul className={cn("mt-4 space-y-2", className)} {...props}>
{perks.map((perk, index) => (
<li key={index} className="flex items-start gap-2 font-mono text-xs text-foreground/80">
<svg
aria-hidden="true"
className="mt-0.5 size-3.5 shrink-0 text-accent"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.5 8.5l3 3 6.5-6.5" />
</svg>
<span>{perk}</span>
</li>
))}
</ul>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled selected value |
| defaultValue | string | Initial selected value |
| onValueChange | (val: string) => void | Callback on card selection |
| columns | 1 | 2 | 3 | 4 | Number of grid columns (default 3) |