About
Calendar events in an assistant reply: date, time with duration and time zone, place, a Join button for calls, guests, RSVP with Accept, Maybe and Decline, and an overlap warning. Compact and full layouts.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Event Card to your project and fits it to your theme.
Code
How Event Card is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { EventAttendee, EventCard } from "@/components/ui/event-card"
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
/** Guests for the design review used across the event card examples. */
const reviewGuests: EventAttendee[] = [
{ name: "Inês Carvalho", email: "ines@fieldnote.studio", status: "accepted", organizer: true },
{ name: "Tomás Ribeiro", email: "tomas@fieldnote.studio", status: "accepted" },
{ name: "Priya Raman", email: "priya@fieldnote.studio", status: "tentative" },
{ name: "Daniel Okafor", email: "daniel@fieldnote.studio", status: "accepted" },
{ name: "Hana Kobayashi", email: "hana@fieldnote.studio" },
{ name: "Lucas Moreau", email: "lucas@fieldnote.studio", status: "declined" },
{ name: "Sofia Lindqvist", email: "sofia@fieldnote.studio" },
]
function EventCardDemo() {
return (
<div className="w-full max-w-md">
<EventCard
title="Checkout redesign review"
start="2026-10-29T14:00:00Z"
end="2026-10-29T14:45:00Z"
timeZone="Europe/Lisbon"
secondaryTimeZone="America/New_York"
secondaryTimeZoneLabel="New York"
locale="en-GB"
videoUrl="https://meet.google.com/qzv-hpwm-tre"
videoLabel="Google Meet"
location="Room 3B, Rua do Alecrim 47, Lisbon"
attendees={reviewGuests}
calendar="Work"
description="Walk through the new address step and the saved card flow before the Friday freeze."
defaultRsvp={null}
onRsvpChange={() => new Promise((r) => setTimeout(r, 700))}
/>
</div>
)
}
function EventCardCompact() {
return (
<div className="flex w-full max-w-lg flex-col gap-2">
<EventCard
layout="compact"
title="Standup, payments team"
start="2026-10-27T09:30:00Z"
end="2026-10-27T09:45:00Z"
timeZone="Europe/Lisbon"
locale="en-GB"
videoUrl="https://meet.google.com/xke-rtpa-mqd"
defaultRsvp="accepted"
/>
<EventCard
layout="compact"
title="Dentist, Dr. Albuquerque"
start="2026-10-28T11:00:00Z"
end="2026-10-28T12:00:00Z"
timeZone="Europe/Lisbon"
locale="en-GB"
location="Avenida da Liberdade 110"
conflicts={[{ title: "Hiring sync", time: "11:30 to 12:00" }]}
/>
<EventCard
layout="compact"
title="Web Summit"
start="2026-11-09T00:00:00Z"
end="2026-11-13T00:00:00Z"
allDay
timeZone="UTC"
locale="en-GB"
location="Altice Arena, Lisbon"
/>
</div>
)
}
const VIEWS = [EventCardDemo, EventCardCompact]
const VIEW_MS = 2200
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/**
* The kit's own examples, crossfading every couple of seconds. They share one grid cell, so the
* frame keeps the size of the larger one and never rescales.
*/
export default function EventCardPreview() {
const [view, setView] = React.useState(0)
React.useEffect(() => {
if (reducedMotion()) return
const id = window.setInterval(() => setView((v) => (v + 1) % VIEWS.length), VIEW_MS)
return () => window.clearInterval(id)
}, [])
return (
<FitFrame minWidth="36rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="grid w-full max-w-2xl">
{VIEWS.map((View, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed list.
key={i}
aria-hidden={i !== view}
inert={i !== view}
className="col-start-1 row-start-1 flex items-center justify-center transition-opacity duration-300"
style={{ opacity: i === view ? 1 : 0 }}
>
<View />
</div>
))}
</div>
<MoreOn slug="event-card">Calendar events in a reply, with time, place and RSVP. On</MoreOn>
</div>
</FitFrame>
)
}
















