Componentform3.73 KB gzipZero dependencies

Radio Cards

Rich selectable radio cards with prices, perks and active ticket border.

Preview

Live Preview

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add radio-cards

Or install the full npm package:

npm install bigbullui
import { 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.tsx

Zero 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

PropTypeDescription
valuestringControlled selected value
defaultValuestringInitial selected value
onValueChange(val: string) => voidCallback on card selection
columns1 | 2 | 3 | 4Number of grid columns (default 3)