Buttons

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CalendarPlus, ChevronDown, Download, ExternalLink, TriangleAlert } from "lucide-react"

import { buttonVariants } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/add-to-calendar.json

Prompt

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's ctz. 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.

On This Page