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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/event-card.jsonPrompt
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 throughIntl.DateTimeFormat(..., { timeZone }).formatToParts()and the year/month/day numbers are compared. This is why the same pair of timestamps is a two-day event inAmerica/New_Yorkand a one-day event inAsia/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,
→ 15across midnight,→ Sep 2across a month, and both years across New Year's Eve. The two cases naive cards get wrong are exactly the last two — a bare→ 2that hides the month, and a missing year on a span that changed year. - The zone abbreviation is stated once — except across a DST seam —
timeZoneName: "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-disabledplus 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 word —
onRegisterruns 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.
Tool Call Trace
An agent's tool-call tree — nested sub-calls with an indent that stops growing, structured arguments and results in expandable JSON, one shared duration unit, first-class running and failed calls, and four data states.
Keyboard Map
A keyboard layout diagram that paints your app's bound shortcuts onto the keys — platform-correct glyphs and modifier order, whole-chord highlighting, and a readable binding list.