Add to Calendar
A split button that writes one event to Google, Outlook, Office 365 or Yahoo — and generates a spec-correct .ics in the browser for Apple Calendar and everything else.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/add-to-calendar.jsonPrompt
Build a React + TypeScript + Tailwind "AddToCalendar" split button
(lucide-react icons, shadcn Button + DropdownMenu) that hands one event to
Google Calendar, Outlook.com, Office 365, Yahoo, or a browser-generated .ics
file for Apple Calendar and everything else.
Contract
- event: { title; description?; location?; start; end?; allDay?; timeZone;
rrule?; url?; uid? }. `start`/`end` accept three shapes and the difference
matters: "2026-09-14T09:30" is a wall clock IN `timeZone`, "2026-09-14" is a
date (which turns the event all-day), and "…Z" / "…+02:00" / a Date is an
absolute instant re-read through `timeZone`. `end` is EXCLUSIVE — DTEND
semantics, and what Google's API expects.
- providers?: ("google" | "outlook" | "office365" | "yahoo" | "ics")[] —
order matters, providers[0] is the primary half. onAdd?: (detail) => void
with { provider, url, ics, event }. Also defaultProvider, label, locale,
align, filename, productId, className, contentClassName.
- Export the pure pieces too: resolveCalendarEvent(event) ->
{ ok: true, event: ResolvedEvent } | { ok: false, error }, buildIcs(resolved,
{ now, productId }) -> string, buildCalendarUrl(provider, resolved).
resolveCalendarEvent must be clock-free and deterministic so it can run
during SSR; only buildIcs reads the clock, and only for DTSTAMP.
Behavior
- Resolve once, render from that: the date line, the duration, the repeat
sentence, the links and the file all read the same ResolvedEvent, so no two
figures on screen can disagree.
- Time zones go through Intl with the IANA id — never the visitor's offset.
Wall clock -> instant needs two passes (read the offset at the naive guess,
re-read it at the corrected instant) or transition days land an hour out.
A clock inside a spring-forward gap is not a time at all: resolve it forward,
read the instant back through the zone, WARN that the requested clock does
not exist, and write the shifted one — so DTSTART never names an hour the
zone skipped and the label never disagrees with the file.
- iCalendar output must be spec-correct: CRLF between every content line and
after the last, TEXT escaping (backslash first, then ; and , then newlines
to a literal \n; do NOT escape colons — RFC 5545 dropped that), folding at
75 OCTETS counting UTF-8 bytes with a continuation line's leading space
counted against its own 75 and no multi-byte character split across a fold.
- Value types follow the event: all-day writes DTSTART;VALUE=DATE +
DTEND;VALUE=DATE (exclusive, clamped to start+1 day when the caller passes
the same date); timed writes DTSTART;TZID=<zone> with local wall clock and
ships a VTIMEZONE for it; an unknown zone falls back to a UTC "Z" stamp,
which needs no VTIMEZONE. A timed event with no `end` writes no DTEND at
all — per the RFC that is a zero-duration event, and inventing an hour would
be stating something the data does not say.
- VTIMEZONE observances are explicit transitions found by sampling the zone's
offset day by day across the window the event occupies and bisecting to the
minute — never a generated yearly RRULE, which is wrong for Lord Howe,
Tehran, and any zone that changes its rules mid-window. DAYLIGHT is the
observance whose offset exceeds the smallest one in the window.
- RRULE is parsed and validated against §3.3.10 before it is written: FREQ
required and known, UNTIL xor COUNT, positive INTERVAL, BY* ranges,
numbered BYDAY only under MONTHLY/YEARLY, BYWEEKNO only under YEARLY,
BYSETPOS only alongside another BY part, no BYHOUR/BYMINUTE/BYSECOND on an
all-day event, and no UNTIL that falls before DTSTART — a series that ends
before it begins repeats not at all. A rule that fails is DROPPED and the
reason is shown — never written half-right. UNTIL is repaired to match
DTSTART's value type (UTC date-time for a zoned event, DATE for an all-day
one) and the repair is disclosed. A valid rule also becomes a sentence
("Every week on Mon, Wed · 8 times"), whose last day is named in the EVENT'S
zone even though the file's UNTIL is UTC, and which admits when it does not
cover every part.
- UID is derived from the event fields, so a second download updates the
entry instead of cloning it. DTSTAMP is UTC.
- Hosted links: Google gets local times plus ctz=<IANA zone> (so the event
keeps its zone) and recur=RRULE:…; Outlook and Office 365 share one compose
deeplink that has no zone field, so timed events go over as UTC instants;
Yahoo takes UTC stamps, or dates plus dur=allday. Outlook/365/Yahoo cannot
carry a repeat rule — those rows must SAY so rather than silently handing
over a single date.
- The split: primary half fires the active target, the chevron opens a menu
whose header previews exactly what will be created; picking a row makes it
the new primary. One provider = no chevron. Zero providers, an unreadable
date, or an end before a start = inert control with the reason on screen.
- The .ics download builds a Blob, clicks a detached <a download>, and revokes
the object URL on a timer — cleared, along with any live URL, on unmount.
Rendering & styling
- Semantic tokens only: bg-primary/text-primary-foreground on both halves of
the split, bg-popover menu, text-muted-foreground captions, text-destructive
for refusals and dropped rules. cn() merges className; the root is a
role="group" with an aria-label and it spreads the remaining props.
- Web targets render as real <a href target="_blank" rel="noreferrer noopener">
— no dead "#" links; the file target is a <button>. Menu rows are
DropdownMenuItem asChild anchors. Blocked states use aria-disabled and stay
focusable so the reason is announced. Results go to an sr-only role="status"
outside the portalled panel.
- The only motion is the chevron flip, with motion-reduce:transition-none.
Customization levers
- Provider set and order: `providers` decides which rows exist and which one
is primary; drop to one id for a plain button, or add an id by extending the
PROVIDERS table and buildCalendarUrl with a new URL shape.
- Copy: `label` overrides the primary text, per-provider `action`/`label`
strings live in the PROVIDERS table, and `locale` drives every on-screen
date without touching the file (which is always machine-formatted).
- Density: the menu is w-72 with a header block — drop the header for a bare
target list, or widen it and add the location line for a richer preview.
- Emphasis: both halves use buttonVariants({ size: "sm" }); switch to
variant="outline" for a quieter CTA, or size="lg" on a ticket page.
- File: `filename` and `productId` name the download and the PRODID; extend
buildIcs with ORGANIZER/ATTENDEE (and METHOD:REQUEST) to turn a publish into
an invitation, or with VALARM for a reminder.
- Zone window: VTIMEZONE covers a year before the event and five years after a
repeating one — raise it for a series with a distant UNTIL.Concepts
- Resolve once, render everywhere — the date line, the duration, the repeat sentence, the four URLs and the file all read one
ResolvedEvent. Two figures that come from one derivation cannot contradict each other, which is the failure mode of every add-to-calendar widget that formats the header separately from the file. - Zone carried, not flattened — the event's IANA id survives into
DTSTART;TZID=…plus a VTIMEZONE, and into Google'sctz. A widget that converts to the visitor's offset gets a weekly 09:30 wrong the moment either side changes clocks. - Exclusive end, inclusive label — the file writes the day after the last day, because that is what DTEND means; the label steps back one day from the very same number, so nobody has to know that to read it.
- Refuse rather than half-write — an RRULE that breaks the grammar is dropped with its reason instead of being trimmed into something that repeats wrongly; an end before a start makes the control inert instead of producing a file every client rejects.
- Honest link capabilities — Outlook, Office 365 and Yahoo deeplinks have no recurrence field, so those rows say the repeat is not in the URL rather than pretending the whole event travelled.
- Stable UID — the identifier is derived from the event, so a second download updates the entry the visitor already has instead of leaving two copies in their week.