Skip to content
ilinxa/pro-ui

Event Card

alphav0.2.0

Event preview card with six status states and four layouts — capacity-aware badges, overlay links, and soft-failure item handling.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

Use when listing events on a community / association / conference / training site, or when mixing events into a multi-content feed. The grid variant fits magazine grids; the feed variant fits social-style single-column feeds; the list variant fits info-rich sidebars / dashboard listings; the compact variant fits text-only sidebar widgets / 'upcoming events' tickers. Public helpers (`getEventStatus`, `EVENT_STATUS_CONFIG`, `formatEventDate`, `getDaysUntilEvent`) are exported alongside the card so consumers can derive status independently for header counters, calendar coloring, filter logic, or deterministic tests — without rendering a card. Migration origin: kasder kas-social-front-v0 EventCard.tsx + SocialEventCard.tsx.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/event-card

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/event-card-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Future of Open Source: Annual SummitRegistration openConference
30
days left

Future of Open Source: Annual Summit

Two days of talks, workshops, and hallway conversations with maintainers from across the open-source ecosystem.

  • July 1, 2026
  • 09:00 - 18:00
  • Istanbul Conference Center
50 / 200150 spots left
Tailwind v4 Deep-Dive WorkshopSoonWorkshop
5
days left

Tailwind v4 Deep-Dive Workshop

Hands-on workshop covering Tailwind v4's new theme system, container queries, and OKLCH color migration.

  • June 6, 2026
  • 14:00 - 17:00
  • Online (Zoom)
60 / 10040 spots left
Designing for Accessibility: Component AuditLast spotsWebinar
14
days left

Designing for Accessibility: Component Audit

Walk through a real WCAG 2.1 AA audit on a production component library and the fixes that followed.

  • June 15, 2026
  • 10:00 - 11:30
  • Online
85 / 10015 spots left
Live Build: Knowledge Graph EditorLive nowWebinar
Happening now

Live Build: Knowledge Graph Editor

Live coding session — building a force-directed knowledge graph editor on top of Sigma.js v3 and React 19.

  • June 1, 2026
  • 16:00 - 18:00
  • Online (Streaming)
312 / 500188 spots left
Founders' Roundtable: Hiring in 2026Sold outPanel
10
days left

Founders' Roundtable: Hiring in 2026

Five founders share what's working and what's not in technical hiring this year. Off the record.

  • June 11, 2026
  • 18:30 - 20:00
  • Galata Hub, Istanbul
50 / 50Sold out
GraphQL Federation in PracticeEndedTraining

GraphQL Federation in Practice

Two-day hands-on training on GraphQL federation v2 — from subgraph composition through production rollout.

  • May 23, 2026
  • 09:00 - 17:00
  • Atlas Plaza, Ankara
28 / 30
Knowledge Day 2026: Annual Member GatheringRegistration openConference
75
days left

Knowledge Day 2026: Annual Member Gathering (Featured event)

Full-day flagship event — keynotes, breakout tracks, networking, and the annual Knowledge Day awards ceremony.

  • August 15, 2026
  • 10:00 - 22:00
  • Istanbul Conference Center + Online
100 / 300200 spots left
Open Office Hours (Drop-In)Registration openWebinar
92
days left

Open Office Hours (Drop-In)

Casual drop-in office hours — no registration needed, no cap. Bring your questions about anything we ship.

  • September 1, 2026
  • 16:00 - 17:00
  • Online (Drop-in link emailed week-of)

Demo source

demo.tsxtsx
"use client"; import { useState } from "react";import { Bookmark, BookmarkCheck, CalendarPlus, Share2 } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { EventCard } from "./event-card";import {  dummyCustomTypeStyles,  dummyEvents,  dummyNow,  dummyTrEvents,  dummyTrLabels,  dummyTrTypeStyles,  dummyTypeStyles,  formatDateTr,} from "./dummy-data";import type { EventCardItem } from "./types"; const HREF_BASE = "/events"; function makeHref(event: EventCardItem) {  return `${HREF_BASE}/${event.id}`;} function ActionsCluster({  eventId,  saved,  onToggle,}: {  eventId: string;  saved: boolean;  onToggle: (id: string) => void;}) {  return (    <div className="flex gap-1.5">      <Button        variant="secondary"        size="sm"        onClick={(e) => {          e.preventDefault();          e.stopPropagation();          onToggle(eventId);        }}        aria-label={saved ? "Remove from saved events" : "Save event"}      >        {saved ? (          <BookmarkCheck aria-hidden="true" className="size-4" />        ) : (          <Bookmark aria-hidden="true" className="size-4" />        )}      </Button>      <Button        variant="secondary"        size="sm"        onClick={(e) => {          e.preventDefault();          e.stopPropagation();        }}        aria-label="Add to calendar"      >        <CalendarPlus aria-hidden="true" className="size-4" />      </Button>      <Button        variant="secondary"        size="sm"        onClick={(e) => {          e.preventDefault();          e.stopPropagation();        }}        aria-label="Share event"      >        <Share2 aria-hidden="true" className="size-4" />      </Button>    </div>  );} const feedSlice = dummyEvents.filter((e) =>  ["evt-open", "evt-ongoing", "evt-upcoming", "evt-lastspots"].includes(e.id),); const featuredEvent = dummyEvents.find((e) => e.id === "evt-featured")!;const openEvent = dummyEvents.find((e) => e.id === "evt-open")!; export default function EventCardDemo() {  const [saved, setSaved] = useState<Set<string>>(() => new Set());   const toggleSaved = (id: string) =>    setSaved((prev) => {      const next = new Set(prev);      if (next.has(id)) {        next.delete(id);      } else {        next.add(id);      }      return next;    });   return (    <Tabs defaultValue="grid" className="w-full">      <SwipeTabsList>        <TabsTrigger value="grid">Grid</TabsTrigger>        <TabsTrigger value="feed">Feed</TabsTrigger>        <TabsTrigger value="list">List</TabsTrigger>        <TabsTrigger value="compact">Compact</TabsTrigger>        <TabsTrigger value="featured">Featured</TabsTrigger>        <TabsTrigger value="localized">Localized (TR)</TabsTrigger>        <TabsTrigger value="custom-types">Custom types</TabsTrigger>        <TabsTrigger value="actions">Actions slot</TabsTrigger>      </SwipeTabsList>       {/* 1. Grid */}      <TabsContent value="grid" className="mt-6">        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">          {dummyEvents.map((event) => (            <EventCard              key={event.id}              event={event}              variant="grid"              href={makeHref(event)}              now={dummyNow}              typeStyles={dummyTypeStyles}            />          ))}        </div>      </TabsContent>       {/* 2. Feed */}      <TabsContent value="feed" className="mt-6">        <div className="flex flex-col gap-4 max-w-3xl mx-auto">          {feedSlice.map((event) => (            <EventCard              key={event.id}              event={event}              variant="feed"              href={makeHref(event)}              now={dummyNow}              typeStyles={dummyTypeStyles}            />          ))}        </div>      </TabsContent>       {/* 3. List — dense info-rich rows */}      <TabsContent value="list" className="mt-6">        <p className="mb-3 text-sm text-muted-foreground">          List variant — dense info-rich row with status + 4-icon meta line          (date / time / location / spots-left) + status-aware right indicator          (days-until count for upcoming, &quot;Live now&quot; pulsing pill for ongoing,          chevron for ended). Featured events get a left accent border.        </p>        <div className="rounded-2xl border border-border/50 bg-card max-w-3xl mx-auto">          {dummyEvents.map((event) => (            <EventCard              key={event.id}              event={event}              variant="list"              href={makeHref(event)}              now={dummyNow}              typeStyles={dummyTypeStyles}            />          ))}        </div>      </TabsContent>       {/* 4. Compact — text-only minimal rows for sidebars / widgets */}      <TabsContent value="compact" className="mt-6">        <div className="grid md:grid-cols-2 gap-8">          <div>            <p className="mb-3 text-sm text-muted-foreground">              Compact variant — text-only minimal row for sidebars / widgets.              No thumbnail, no status badge, no capacity bar — title + type              pill + 3 stacked meta lines (date / time / location). Localized              labels applied here to mirror the source pattern.            </p>            <div className="rounded-2xl border border-border/50 bg-card p-2 max-w-md">              {dummyTrEvents.slice(0, 4).map((event) => (                <EventCard                  key={event.id}                  event={event}                  variant="compact"                  href={makeHref(event)}                  now={dummyNow}                  typeStyles={dummyTrTypeStyles}                  labels={dummyTrLabels}                  formatDate={(d) =>                    new Date(d).toLocaleDateString("tr-TR", {                      day: "numeric",                      month: "long",                    })                  }                />              ))}              <div className="text-center pt-3 pb-2">                <a                  href="#"                  className="text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1"                >                  Tüm Etkinlikler →                </a>              </div>            </div>          </div>          <div>            <p className="mb-3 text-sm text-muted-foreground">              Same compact variant in English with the default labels &amp;              full date format.            </p>            <div className="rounded-2xl border border-border/50 bg-card p-2 max-w-md">              {dummyEvents.slice(0, 4).map((event) => (                <EventCard                  key={event.id}                  event={event}                  variant="compact"                  href={makeHref(event)}                  now={dummyNow}                  typeStyles={dummyTypeStyles}                />              ))}              <div className="text-center pt-3 pb-2">                <a                  href="#"                  className="text-sm text-muted-foreground hover:text-primary inline-flex items-center gap-1"                >                  All events →                </a>              </div>            </div>          </div>        </div>      </TabsContent>       {/* 5. Featured — grid + feed side by side */}      <TabsContent value="featured" className="mt-6">        <div className="space-y-8">          <div>            <p className="mb-3 text-sm text-muted-foreground">              Grid variant — top accent border + star prefix on title.            </p>            <div className="grid md:grid-cols-2 gap-6">              <EventCard                event={featuredEvent}                variant="grid"                href={makeHref(featuredEvent)}                now={dummyNow}                typeStyles={dummyTypeStyles}              />              <EventCard                event={openEvent}                variant="grid"                href={makeHref(openEvent)}                now={dummyNow}                typeStyles={dummyTypeStyles}              />            </div>          </div>          <div>            <p className="mb-3 text-sm text-muted-foreground">              Feed variant — inset ring + star prefix.            </p>            <div className="max-w-3xl mx-auto">              <EventCard                event={featuredEvent}                variant="feed"                href={makeHref(featuredEvent)}                now={dummyNow}                typeStyles={dummyTypeStyles}              />            </div>          </div>        </div>      </TabsContent>       {/* 4. Localized (TR) */}      <TabsContent value="localized" className="mt-6">        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">          {dummyTrEvents.map((event) => (            <EventCard              key={event.id}              event={event}              variant="grid"              href={makeHref(event)}              now={dummyNow}              typeStyles={dummyTrTypeStyles}              labels={dummyTrLabels}              formatDate={formatDateTr}            />          ))}        </div>      </TabsContent>       {/* 5. Custom types */}      <TabsContent value="custom-types" className="mt-6">        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">          {dummyEvents.map((event) => (            <EventCard              key={event.id}              event={event}              variant="grid"              href={makeHref(event)}              now={dummyNow}              typeStyles={dummyCustomTypeStyles}            />          ))}        </div>      </TabsContent>       {/* 6. Actions slot + custom href */}      <TabsContent value="actions" className="mt-6">        <div className="space-y-2">          <p className="text-sm text-muted-foreground">            Action buttons stop propagation — clicks on Save / Calendar / Share            don&apos;t navigate. The rest of the card surface still does.            Clicked saved-state:{" "}            <span className="font-mono text-xs">              {saved.size > 0 ? Array.from(saved).join(", ") : "(none)"}            </span>          </p>          <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">            {dummyEvents.slice(0, 3).map((event) => (              <EventCard                key={event.id}                event={event}                variant="grid"                getHref={(e) => `/etkinlik/${e.id}`}                now={dummyNow}                typeStyles={dummyTypeStyles}                actions={                  <ActionsCluster                    eventId={event.id}                    saved={saved.has(event.id)}                    onToggle={toggleSaved}                  />                }              />            ))}          </div>        </div>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for EventCard when you need an event preview with a live, time-and-capacity-aware status (registration open, almost full, sold out, happening right now, ended) plus a status-driven CTA. Four layouts: variant="grid" (image-on-top magazine card), variant="feed" (full-bleed image background, white-on-dark content), variant="list" (thumbnail + 4-icon meta + status indicator), and variant="compact" (text-only minimal row for sidebars / widgets).

Minimal example

import { EventCard } from "@/components/event-card";

<EventCard
  event={{
    id: "1",
    title: "Annual Summit",
    type: "Conference",
    date: "2026-07-01",
    time: "09:00 - 18:00",
    location: "Istanbul Conference Center",
    image: "/cover.jpg",
    capacity: 200,
    registered: 50,
  }}
  variant="grid"
  href="/events/1"
/>;

Four variants

  • grid — image-on-top magazine card with full meta lines, capacity progress bar, and decorative status CTA at the foot. Renders well in 1/2/3-column responsive grids.
  • feed — full-bleed image background, content overlaid white-on-dark. Single-column feed item. No capacity progress bar (intentional — spots-left is shown inline in the meta row instead).
  • list— info-rich row with thumbnail: 16/20 px square thumb left, content middle (status badge + type badge + title + 4-icon inline meta row showing date / time / location / spots-left), status-aware right indicator (days-until / pulsing "Live now" / chevron). Featured rows get a left accent border. Use when you want a scannable list with visual context per row.
  • compact— text-only minimal row for sidebars / widgets / "upcoming events" tickers. Title + type pill (top-right) + 3 stacked meta lines (date / time / location). NO thumbnail, NO status badge, NO capacity bar. Tightest variant — maximum density per pixel. Pairs naturally with a "See all →" footer link.

Six statuses (auto-derived)

Status flows from the event data + an optional now reference. The card never needs you to set status — it's derived from the event and clock:

  • open — registrations available, more than 7 days out.
  • upcoming — within 7 days. Amber badge.
  • lastSpots — ≥80% capacity hit. Amber badge + spots-left counter flips to text-destructive when ≤5 left.
  • ongoing — between start and end. Pulsing badge + live indicator.
  • full — capacity reached. Sold-out CTA, button disabled.
  • expired — past end date. Card fades to opacity-60 grayscale-30; CTA shows "View details."

Public helper kernel — use status logic without rendering the card

The status helpers are exported alongside the component so consumers can derive status, format dates, and compute days-until without rendering an actual card — for header counters, calendar day-cells, filter logic, deterministic tests:

import {
  EventCard,
  getEventStatus,
  EVENT_STATUS_CONFIG,
  formatEventDate,
  getDaysUntilEvent,
  type EventStatus,
} from "@/components/event-card";

// Header counter
const liveCount = events.filter(
  (e) => getEventStatus(e) === "ongoing",
).length;

// Calendar day cell — pure-helper composition, no card render
function DayCell({ events, day }: { events: EventCardItem[]; day: Date }) {
  const todays = events.filter((e) => sameDay(e.date, day));
  const hasOngoing = todays.some(
    (e) => getEventStatus(e, day) === "ongoing",
  );
  return (
    <div className={hasOngoing ? "bg-accent/30" : ""}>
      {todays.length > 0 && <span>{todays.length}</span>}
    </div>
  );
}

Polymorphic root + custom href

import NextLink from "next/link";

<EventCard
  event={event}
  variant="grid"
  linkComponent={NextLink}
  getHref={(e) => `/etkinlik/${e.slug}`}
/>;

Localized labels + custom date formatter

<EventCard
  event={event}
  variant="grid"
  href={href}
  formatDate={(d) =>
    new Date(d).toLocaleDateString("tr-TR", {
      day: "numeric",
      month: "long",
      year: "numeric",
    })
  }
  labels={{
    open: "Kayıt Açık",
    upcoming: "Yaklaşıyor",
    ctaRegister: "Kayıt Ol",
    daysUntilSuffix: "gün kaldı",
    capacityAriaPrefix: "Kayıtlı",
    capacityAriaSeparator: "/",
    // ... 17 keys total, all optional
  }}
  typeStyles={{
    Konferans: { className: "bg-primary/10 text-primary border-primary/20" },
  }}
/>;

Actions slot — overlay-link pattern

The whole card is a link via the overlay-link pattern. Drop nested interactive buttons (Save / Share / Calendar) into the actions prop — they render at z-10 above the link overlay. Each action button MUST call e.preventDefault() + e.stopPropagation() in its onClick or the click bubbles to the link and navigates. Tab order: card-link → action1 → action2 → next card.

Deterministic now for testing + live clocks

Pass a now Date to make status derivation deterministic. For demos / SSR / screenshot tests, pin nowto a fixed date so events don't silently drift over time. For minute-accurate live status, drive now from a parent setInterval:

function LiveEvents({ events }: { events: EventCardItem[] }) {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 60_000);
    return () => clearInterval(id);
  }, []);

  return events.map((e) => (
    <EventCard key={e.id} event={e} variant="grid" href={`/events/${e.id}`} now={now} />
  ));
}

Soft-failure

Only id, title, type, date are required. Missing optional fields gracefully omit:

  • No capacity / registered — capacity bar + spots-left counter omitted; status logic skips full / lastSpots states; CTA reverts to plain Register / View Details / Ended.
  • No image — tinted placeholder with calendar icon.
  • No endDate — falls back to date (single-day event spans 00:00 → 23:59:59).
  • No time / location / description — meta line omitted.

Notes

  • The CTA at the foot is decorative — clicking it navigates via the wrapping link overlay. To render a REAL register button (registration dialog, in-place form, etc.), drop one in actions.
  • Status is derived once at render — no internal setInterval. For live updates, drive now from upstream.
  • Helpers (getEventStatus, EVENT_STATUS_CONFIG, formatEventDate, getDaysUntilEvent) are part of the API contract — stable across v0.x.
  • Status differentiated by both color AND icon (color-blind safe).
  • All transforms + pulse animations gated via motion-safe: — reduced-motion users see static cards.

Features

  • 6-state status state machine (open / upcoming / lastSpots / ongoing / full / expired)
  • 4 visual variants — grid (image-on-top), feed (full-bleed background), list (info-rich row with thumbnail), compact (text-only minimal row)
  • Status differentiated by BOTH color AND icon (color-blind safe)
  • Public helper kernel — getEventStatus, EVENT_STATUS_CONFIG, formatEventDate, getDaysUntilEvent
  • Polymorphic root via linkComponent (works with NextLink / RemixLink / etc.)
  • Overlay-link pattern with optional actions slot for nested interactives
  • Soft-failure on optional fields (capacity-less events skip full/lastSpots states + capacity bar)
  • Deterministic status via optional `now` injection (testability + live-clock hosts)
  • 17-key labels object for full i18n
  • typeStyles map for consumer-defined event-type taxonomies
  • Featured treatment — top accent border (grid) / inset ring (feed) + star title prefix
  • WCAG 2.1 AA — aria-labelledby + useId, motion-safe gating, capacity bar aria-label, color-AND-icon status

Tags

event-cardeventsstatuscapacitycard

Dependencies

shadcn primitives: button, progress
npm peer deps: lucide-react@^1.11.0