# Registration Card

> Event registration status card — capacity progress, spots-left counter, status-aware call to action, and a share slot.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/registration-card).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/registration-card
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/registration-card-light-46dbbee2dd9be951.png
- Registry item: https://ui.ilinxa.com/r/registration-card.json
- Categories: data
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/registration-card.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `progress`, `button`

## Files

### `src/registry/components/data/registration-card/registration-card.tsx`

```tsx
"use client";

import { memo, useId, useMemo } from "react";
import { Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import {
  DEFAULT_REGISTRATION_CARD_LABELS,
  type RegistrationCardLabels,
  type RegistrationCardProps,
  type RegistrationStatus,
} from "./types";
import { deriveRegistrationStatus } from "./lib/registration-status";
import { defaultFormatSpotsLeftSuffix } from "./lib/format-default";

function getCtaConfig(
  status: RegistrationStatus,
  hasOnRegister: boolean,
  labels: Required<RegistrationCardLabels>,
): {
  label: string;
  variant: "default" | "secondary";
  disabled: boolean;
} {
  if (!hasOnRegister) {
    return {
      label: labels.ctaUnavailable,
      variant: "secondary",
      disabled: true,
    };
  }
  if (status === "full") {
    return { label: labels.ctaSoldOut, variant: "secondary", disabled: true };
  }
  if (status === "closed") {
    return { label: labels.ctaClosed, variant: "secondary", disabled: true };
  }
  return { label: labels.ctaRegister, variant: "default", disabled: false };
}

function RegistrationCardInner({
  capacity,
  registered,
  closed = false,
  statusOverride,
  lastSpotsRatio = 0.8,
  urgentSpotsCount = 10,
  formatSpotsLeftSuffix,
  heading,
  headingAs = "h3",
  framed = true,
  onRegister,
  onShare,
  actions,
  labels: labelsProp,
  className,
  headingClassName,
  barClassName,
  ctaClassName,
}: RegistrationCardProps) {
  const headingId = useId();
  const HeadingTag = headingAs;

  const labels = useMemo<Required<RegistrationCardLabels>>(
    () => ({ ...DEFAULT_REGISTRATION_CARD_LABELS, ...labelsProp }),
    [labelsProp],
  );

  const spotsLeftSuffixFormatter = useMemo<(count: number) => string>(() => {
    if (formatSpotsLeftSuffix) return formatSpotsLeftSuffix;
    if (labelsProp?.spotsLeftSuffix !== undefined) {
      const suffix = labelsProp.spotsLeftSuffix;
      return () => suffix;
    }
    return defaultFormatSpotsLeftSuffix;
  }, [formatSpotsLeftSuffix, labelsProp]);

  const status = useMemo<RegistrationStatus>(
    () =>
      statusOverride ??
      deriveRegistrationStatus({
        capacity,
        registered,
        closed,
        lastSpotsRatio,
      }),
    [statusOverride, capacity, registered, closed, lastSpotsRatio],
  );

  const hasQuota = capacity != null && registered != null;
  const spotsLeft = hasQuota ? Math.max(0, capacity - registered) : 0;
  const percent = hasQuota
    ? capacity === 0
      ? 100
      : Math.min(100, (registered / capacity) * 100)
    : 0;
  const isUrgent = hasQuota && spotsLeft > 0 && spotsLeft <= urgentSpotsCount;

  const cta = getCtaConfig(status, onRegister != null, labels);

  return (
    <section
      aria-labelledby={heading ? headingId : undefined}
      className={cn(
        framed &&
          "bg-card rounded-2xl p-6 border border-border/50 shadow-lg",
        className,
      )}
    >
      {heading && (
        <HeadingTag
          id={headingId}
          className={cn(
            "text-lg font-semibold text-foreground mb-4",
            headingClassName,
          )}
        >
          {heading}
        </HeadingTag>
      )}

      {hasQuota && (
        <div className={cn("mb-6", barClassName)}>
          <div className="flex justify-between text-sm mb-2">
            <span className="text-muted-foreground">{labels.capacityLabel}</span>
            <span
              className={cn(
                "font-medium",
                isUrgent ? "text-destructive" : "text-foreground",
              )}
            >
              {spotsLeft > 0
                ? `${spotsLeft} ${spotsLeftSuffixFormatter(spotsLeft)}`
                : labels.spotsLeftFull}
            </span>
          </div>
          <Progress
            value={percent}
            className="h-3"
            aria-label={labels.ariaLabel}
          />
          <div className="flex justify-between text-xs text-muted-foreground mt-1">
            <span>
              {registered} {labels.registeredSuffix}
            </span>
            <span>
              {capacity} {labels.capacitySuffix}
            </span>
          </div>
        </div>
      )}

      <Button
        type="button"
        className={cn("w-full", ctaClassName)}
        size="lg"
        variant={cta.variant}
        disabled={cta.disabled}
        onClick={cta.disabled ? undefined : onRegister}
      >
        {cta.label}
      </Button>

      {actions ? (
        <div className="mt-3">{actions}</div>
      ) : onShare ? (
        <Button
          type="button"
          variant="outline"
          className="w-full mt-3 gap-2"
          onClick={onShare}
        >
          <Share2 aria-hidden="true" className="w-4 h-4" />
          {labels.ctaShare}
        </Button>
      ) : null}
    </section>
  );
}

const RegistrationCard = memo(RegistrationCardInner);
RegistrationCard.displayName = "RegistrationCard";

export { RegistrationCard };
export default RegistrationCard;
```

### `src/registry/components/data/registration-card/index.ts`

```ts
export { RegistrationCard, default } from "./registration-card";

export type {
  RegistrationCardProps,
  RegistrationCardLabels,
  RegistrationStatus,
} from "./types";

export { DEFAULT_REGISTRATION_CARD_LABELS } from "./types";

export {
  deriveRegistrationStatus,
  type DeriveRegistrationStatusOptions,
} from "./lib/registration-status";
```

### `src/registry/components/data/registration-card/types.ts`

```ts
import type { ReactNode } from "react";
import type { RegistrationStatus } from "./lib/registration-status";

export type { RegistrationStatus };

export interface RegistrationCardLabels {
  /** Default: "Capacity". Top-row left label above the bar. */
  capacityLabel?: ReactNode;
  /**
   * Default: "spots left". Counter suffix when spots remain.
   * @deprecated Use `formatSpotsLeftSuffix` (callback) for plural-correct labels.
   *   Setting this still works (used as a static suffix for all counts) but
   *   produces ungrammatical output at count === 1 ("1 spots left").
   */
  spotsLeftSuffix?: string;
  /** Default: "Sold out". Counter text when capacity reached. */
  spotsLeftFull?: string;
  /** Default: "registered". Bottom-row left counter suffix. */
  registeredSuffix?: string;
  /** Default: "capacity". Bottom-row right counter suffix. */
  capacitySuffix?: string;
  /** Default: "Register". Primary CTA when registration is open. */
  ctaRegister?: string;
  /** Default: "Sold out". Primary CTA when status === 'full'. */
  ctaSoldOut?: string;
  /** Default: "Closed". Primary CTA when status === 'closed'. */
  ctaClosed?: string;
  /** Default: "Registration unavailable". Primary CTA when no onRegister provided. */
  ctaUnavailable?: string;
  /** Default: "Share". Secondary share button label (when default share is rendered). */
  ctaShare?: string;
  /** aria-label on the progress bar. Default: "Registration capacity". */
  ariaLabel?: string;
}

export interface RegistrationCardProps {
  /** Total seats / units. Optional — when absent, no-quota mode (bar hidden). */
  capacity?: number;
  /** Currently registered count. Optional — when absent, no-quota mode. */
  registered?: number;
  /** Explicitly close registration regardless of capacity. Default: false. */
  closed?: boolean;

  /** Override the auto-derived state. Rare — for preview / what-if. */
  statusOverride?: RegistrationStatus;

  // ─── Thresholds ─────────────────────────────────────────────────
  /** Percent (0–1) of capacity at which status flips to 'lastSpots'. Default: 0.8. */
  lastSpotsRatio?: number;
  /** Absolute spots-left count at which the counter color flips to text-destructive. Default: 10. */
  urgentSpotsCount?: number;

  // ─── Formatters ──────────────────────────────────────────────────
  /**
   * Format the spots-left suffix as a function of count. Default uses
   * `Intl.PluralRules` ("spot left" at 1, "spots left" otherwise). Receives
   * the integer spots-left count, returns a string. Takes precedence over
   * `labels.spotsLeftSuffix`.
   */
  formatSpotsLeftSuffix?: (count: number) => string;

  // ─── Heading ─────────────────────────────────────────────────────
  /** Optional section heading. */
  heading?: string;
  /** Heading semantic level. Default: 'h3'. */
  headingAs?: "h2" | "h3" | "h4";

  // ─── Visual ──────────────────────────────────────────────────────
  /** Wrap in card chrome (`bg-card rounded-2xl p-6 border shadow-lg`). Default: true. */
  framed?: boolean;

  // ─── Actions ─────────────────────────────────────────────────────
  /** Primary CTA click handler. Optional — when omitted, CTA renders as disabled with `ctaUnavailable` label. */
  onRegister?: () => void;
  /** Default share-button click handler. When provided + `actions` NOT provided, renders an outline Share button. */
  onShare?: () => void;
  /** Custom action(s) slot — fully replaces the default share button. */
  actions?: ReactNode;

  /** Localized labels. */
  labels?: RegistrationCardLabels;

  // ─── Style overrides ─────────────────────────────────────────────
  className?: string;
  headingClassName?: string;
  barClassName?: string;
  ctaClassName?: string;
}

/** Default English labels — exported for consumer composition. */
export const DEFAULT_REGISTRATION_CARD_LABELS: Required<RegistrationCardLabels> = {
  capacityLabel: "Capacity",
  spotsLeftSuffix: "spots left",
  spotsLeftFull: "Sold out",
  registeredSuffix: "registered",
  capacitySuffix: "capacity",
  ctaRegister: "Register",
  ctaSoldOut: "Sold out",
  ctaClosed: "Closed",
  ctaUnavailable: "Registration unavailable",
  ctaShare: "Share",
  ariaLabel: "Registration capacity",
};
```

### `src/registry/components/data/registration-card/lib/registration-status.ts`

```ts
export type RegistrationStatus = "open" | "lastSpots" | "full" | "closed";

export interface DeriveRegistrationStatusOptions {
  capacity?: number;
  registered?: number;
  /** Explicitly close registration regardless of capacity. */
  closed?: boolean;
  /** Percent (0–1) of capacity at which status flips to 'lastSpots'. Default: 0.8. */
  lastSpotsRatio?: number;
}

/**
 * Pure function. Derives registration status from capacity + registered + closed flag.
 *
 * - `closed: true` always returns "closed"
 * - capacity / registered missing → "open" (no-quota mode)
 * - registered >= capacity → "full"
 * - registered/capacity >= lastSpotsRatio → "lastSpots"
 * - otherwise → "open"
 */
export function deriveRegistrationStatus(
  opts: DeriveRegistrationStatusOptions,
): RegistrationStatus {
  if (opts.closed === true) return "closed";

  const { capacity, registered } = opts;
  if (capacity == null || registered == null) return "open";

  if (registered >= capacity) return "full";

  const ratio = capacity === 0 ? 1 : registered / capacity;
  const threshold = opts.lastSpotsRatio ?? 0.8;
  if (ratio >= threshold) return "lastSpots";

  return "open";
}
```

### `src/registry/components/data/registration-card/lib/format-default.ts`

```ts
const enPluralRules = new Intl.PluralRules("en");

/** Default English plural-correct spots-left suffix. "spot left" / "spots left". */
export function defaultFormatSpotsLeftSuffix(count: number): string {
  return enPluralRules.select(count) === "one" ? "spot left" : "spots left";
}
```
