Display

Event Card

An event card with a calendar tear-off that survives cross-day, cross-month and cross-year spans, day boundaries computed in an explicit IANA zone, and an ordered capacity machine where “ended” outranks every seat count.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Clock, LoaderCircle, MapPin, Video } from "lucide-react"
import { AvatarGroup, type AvatarGroupItem } from "@/registry/ui/avatar-group"
import { cn } from "@/lib/utils"

/** How often the live clock is re-read when the host does not pass `now`. */
const CLOCK_INTERVAL_MS = 60_000

/**
 * Capacity is an ORDERED machine, evaluated top-down — the first match wins:
 *   ended → full/waitlist → limited → open
 * "Ended" outranks everything: an event that is over must never advertise

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/event-card.json

Prompt

Build a React + TypeScript + Tailwind "EventCard" component (lucide-react: Check,
Clock, LoaderCircle, MapPin, Video; plus an AvatarGroup component that renders
overlapping avatars and collapses the overflow into "+N"). No other runtime deps.

Contract
- export const EventCard = forwardRef<HTMLElement, EventCardProps>, plus the types
  EventCapacityState, EventVenueLocation, EventOnlineLocation, EventLocation and
  EventHost. Props extend React.HTMLAttributes<HTMLElement>; the rest spreads onto
  the <article> root and className merges through cn().
- Required: title: string; start: string | number | Date; timeZone: string.
- Optional: href (the title link); end (same shape as start); locale (default
  "en-US"); now (the instant "ended" is measured against); location; host: { name,
  avatarUrl?, href? }; attendees: { src?, alt, fallback? }[];
  maxAttendeeAvatars (default 5); capacity; registeredCount;
  lowSpotsThreshold (default 5); waitlist (default false); registered;
  onRegister?: () => void | Promise<unknown>.
- location is a discriminated union, never a free-text string:
  { kind: "venue"; name; address?; mapUrl? } | { kind: "online"; platform?;
  joinUrl? }. Both URLs are real URLs supplied by the caller.
- TIME ZONE POLICY — pick one and say it out loud; this component picks the second:
  (a) accept pre-formatted display strings and let the caller own the zone, or
  (b) accept instants plus an explicit IANA zone and format with Intl. Here `start`
  and `end` are INSTANTS (ISO 8601 with an offset, epoch ms, or Date) and
  `timeZone` is a REQUIRED IANA name. There is no default and no fallback to the
  runtime zone: every Intl call passes { timeZone, locale } explicitly, so the
  server and the browser render the same string. Pass the venue's zone for on-site
  events; for an online event pass whichever zone your audience should read it in
  (usually the viewer's, resolved once at the app boundary).

Behavior
- Day boundaries are computed IN THE DISPLAY ZONE, never with getDate() and never
  in UTC. Take Intl.DateTimeFormat("en-US", { timeZone, year, month, day })
  .formatToParts() of each instant and compare the numbers ("en-US" keeps the
  digits ASCII, so the comparison is locale-independent). A 22:00 → 02:00 party in
  America/New_York spans two dates; the same two instants shown in Asia/Tokyo are
  11:00 → 15:00 on ONE date. "Cross-day" is a property of the zone, not of the
  timestamps.
- That comparison yields one of four spans, which is the only thing the tear-off
  block renders: "single" (month strip / big day / weekday), "cross-day" (same
  month → second line "→ 15"), "cross-month" (second line carries the month,
  "→ Sep 2"), "cross-year" (the strip carries the START year, "Dec 2026", and the
  second line carries the END year, "→ Jan 1, 2027"). Never print a bare "→ 2" for
  a span that changed month, and never hide the year on a span that changed year —
  those are exactly the two cases a naive card gets wrong.
- The time row is the machine-readable one: two <time> elements carrying
  dateTime={date.toISOString()}, separated by a visible aria-hidden en dash plus an
  sr-only " to " so listeners hear a range instead of two numbers. The start format
  is { weekday, month, day, year, hour, minute }; the end format drops the date
  parts when both instants land on the same calendar day, re-states weekday/month/
  day when they do not, adds the year when the year changed, and always carries
  timeZoneName: "short". The zone abbreviation rides on the LAST instant shown, so
  it is stated exactly once — UNLESS the two instants sit in different UTC offsets,
  i.e. the event crosses a DST transition, in which case BOTH carry it ("1:00 AM
  EDT – 3:00 AM EST"). Compare the two abbreviations with a fixed "en-US" formatter
  to decide. A lone "EST" on the night the clocks go back quietly turns a
  three-hour party into a two-hour one, and that night is exactly when
  cross-midnight events happen. The year is ALWAYS printed: dropping it for "this
  year" requires knowing "now", the server does not, and a year that appears only
  after hydration is worse than one that is always there.
- The tear-off block is aria-hidden: every fact in it is already spelled out in the
  time row, and a screen reader should not hear the date twice.
- Capacity is an ORDERED machine, evaluated top-down, first match wins:
    ended → (remaining === 0 ? (waitlist ? waitlist : full)) → limited → open
  "Ended" is checked FIRST and outranks every counter: an event with 37 unsold
  seats that finished last week says "Ended", never "37 spots left". remaining =
  max(0, capacity − registeredCount) so an oversubscribed event (44 of 40) never
  prints a negative; capacity/registeredCount are floored and clamped and a missing
  one simply means "no remaining count", which reads as "open". "limited" is
  remaining <= lowSpotsThreshold. Expose the result as data-capacity on the root so
  hosts can style or test against it.
- "now" is read through useSyncExternalStore, never with a bare Date.now() during
  render. getServerSnapshot returns null so SSR and the first client paint agree
  ("no clock yet" ⇒ not ended) and React settles it after hydration; the snapshot
  itself is CACHED in a module variable and refreshed only by the interval and on
  subscribe — returning a fresh Date.now() from getSnapshot makes React warn about
  an uncached snapshot and re-renders the card on every parent update. Passing the
  `now` prop freezes the clock entirely, which is what deterministic tests and
  server-rendered pages want.
- The register action is a small machine: idle → pending → (registered | error).
  The handler is invoked inside `new Promise(resolve => resolve(onRegister?.()))`,
  NOT `Promise.resolve(fn())`: the executor form turns a SYNCHRONOUS throw into a
  rejection too, instead of letting it escape the click handler and strand the
  button on "Registering…" forever. A rejection shows "Try again" and changes
  NOTHING else — there is no optimistic "you're going" to roll back, so the seat
  count, the span, the location and the avatars are all still there and the retry
  starts from where the user was.
- The button is inert while pending, once registered, and when the state is ended
  or full — expressed as aria-disabled + an early return in the handler, NOT the
  native disabled attribute. A button that disables itself on click gets blurred by
  the browser the instant it flips, stops being focusable and stops being readable;
  aria-disabled keeps it announceable and lets the guard swallow the 2nd..5th click
  of a rage-click. (Test runners treat aria-disabled as "not enabled" — click with
  force.) Labels: "Register" / "Join waitlist" (waitlist) / "Registering…" /
  "You're going" / "Try again" / "Event full" / "Registration closed". A polite
  sr-only live region announces success and failure. No onRegister ⇒ no button.
- Optional blocks are driven by data, not by boolean flags: no location row without
  `location`, no host row without `host`, no avatars without `attendees`, no
  capacity chip when there is no remaining count. Never render an inert control.
- Links are real or absent. A venue with no mapUrl renders its name as plain text
  and an online event with no joinUrl renders "Online · Zoom" as plain text —
  href="#" is forbidden. External map/meeting links get target="_blank"
  rel="noreferrer"; the title link stays a normal in-app link.
- Robustness: an unparseable `start` skips the date block and the time row instead
  of throwing (Intl.format on an invalid Date raises a RangeError and would take
  the page down); an `end` that is unparseable or not strictly after `start` is
  dropped rather than rendered backwards; an unknown IANA name falls back to "UTC"
  instead of letting the Intl constructor throw on one bad row of data.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + text-card-foreground
  (root), border, bg-primary + text-primary-foreground (tear-off month strip and the
  live register button), bg-muted + text-muted-foreground (the strip and the button
  once the event ended or filled, secondary text), bg-destructive/10 +
  text-destructive (the "Only 3 spots left" chip and the "Try again" button),
  bg-primary/10 + text-primary (waitlist chip, "You're going"), ring-ring +
  ring-offset-card for focus-visible.
- Inert and error states swap TOKENS, never a global opacity fade — fading a filled
  button is exactly how a label slides under 4.5:1 while still looking designed.
- Layout: a shrink-0 tear-off column (a coloured month strip over a big tabular day)
  beside a min-w-0 content column, so long titles and addresses compress on the
  correct side. The title clamps to two lines with line-clamp-2 + break-words; the
  footer row is flex-wrap so avatars, the capacity chip and the button reflow
  instead of overflowing at 375px.
- The host row is a <div>, not a <p>: the avatar group renders a block element and a
  <div> inside a <p> is invalid HTML — the browser closes the paragraph early and
  hydration breaks on the mismatch.
- Motion is limited to a card shadow transition and the pending spinner
  (animate-spin + motion-reduce:animate-none). No state depends on an animation.

Customization levers
- Time-zone policy: to switch to policy (a), replace `start`/`end`/`timeZone` with
  `startText`/`endText` strings plus an `isoStart`/`isoEnd` pair for the <time>
  attributes, and delete every Intl call. Keep the span logic — you will still need
  to know whether the event crossed a day to decide what the block prints.
- Date block: month strip + big day + sub-line is one <div>; swap the strip colour
  (bg-primary → bg-secondary / bg-destructive for cancelled), turn the sub-line into
  the weekday even for multi-day spans, or render two stacked mini-blocks for a
  range. min-w-14 is the only width — widen it if your locale's month abbreviations
  are longer.
- Capacity vocabulary: chip labels and button labels are two flat conditionals and
  CHIP_TONE is a Record of five token strings. Rename "spots" to "seats"/"tickets",
  add a "waitlist position" line, or add a thin capacity bar — but keep the ordering,
  and keep "ended" first.
- Thresholds: lowSpotsThreshold decides when urgency starts (5 for a workshop, 50
  for a stadium). Set it to 0 to disable the "limited" state entirely.
- Density: p-4 + gap-1.5 reads as a comfortable listing; p-3 + text-[0.6875rem]
  gives a dense agenda rail. Drop the host row or the avatar row and nothing else
  reads them.
- Avatars: maxAttendeeAvatars trades row width for the "+N" chip; the count is
  always items.length − shown, and "N going" comes from registeredCount so the
  avatar array can stay a small sample of a large crowd.
- Registration: for a cancel/undo flow, keep `registered` controlled and give your
  own handler both directions; for a ticketing flow, pass an onRegister that opens
  your checkout — the four labels and the guard do not care what the handler does.
- Whole-card click: only the title is a link here. If you want the whole card
  clickable, give the anchor an ::after with inset-0 over the relative root and lift
  every control (map link, host link, button) with relative z-10 — otherwise they
  disappear under the overlay.

Concepts

  • Day boundaries belong to a zone — the card never calls getDate() and never compares UTC days. Each instant is pushed through Intl.DateTimeFormat(..., { timeZone }).formatToParts() and the year/month/day numbers are compared. This is why the same pair of timestamps is a two-day event in America/New_York and a one-day event in Asia/Tokyo: "cross-day" is a fact about the display zone, not about the data.
  • Four spans, one block — the tear-off prints only what is ambiguous: a weekday for a single day, → 15 across midnight, → Sep 2 across a month, and both years across New Year's Eve. The two cases naive cards get wrong are exactly the last two — a bare → 2 that hides the month, and a missing year on a span that changed year.
  • The zone abbreviation is stated once — except across a DST seamtimeZoneName: "short" normally rides only on the last instant ("… – 2:00 AM EDT"), because an attendee in another zone needs it once and twice reads as noise. When the two instants land in different offsets the card prints both ("1:00 AM EDT – 3:00 AM EST"): on the night the clocks go back, a single "EST" would quietly turn a three-hour party into a two-hour one — and that is precisely the night cross-midnight events happen.
  • Ended is checked first — the capacity machine is ordered, not a bag of booleans. An event that finished with 37 seats unsold reports data-capacity="ended" and says "Ended"; the remaining count is never even formatted. Every other transition (open → limited → full → waitlist) is derived from the same two numbers.
  • aria-disabled, not disabled — the register button turns inert while pending, once you are going, and when the event is full or over. Using the native attribute would blur it mid-click and drop it out of the tab order; aria-disabled plus an early return in the handler keeps it announceable and makes the 2nd through 5th click of a rage-click a no-op.
  • Failure changes one wordonRegister runs inside a Promise executor so a synchronous throw becomes a rejection instead of a stuck spinner, and there is no optimistic "you're going" to undo. After a failure the seat count, the span, the location and the avatars are exactly as they were, and the same button retries.

On This Page