An Add to Calendar component in React

Every event page wants the same thing: a tidy button that lets a visitor add the event to their calendar of choice. Google takes a URL, Apple and Outlook want an .ics file. There are libraries for this, but the whole thing is small enough to write from scratch. Skipping the dependency means you fully control the markup and styling.

Demo

Google Calendar accepts a URL with the event encoded in query params. Apple and Outlook both consume the iCalendar (.ics) format, which we build as a string and ship to the browser as a downloadable Blob.

export type CalendarEvent = {
  title: string
  description?: string
  location?: string
  startDateTime: Date
  endDateTime: Date
}

const pad = (n: number) => n.toString().padStart(2, '0')

const formatLocalDateTime = (date: Date) => {
  const y = date.getFullYear()
  const m = pad(date.getMonth() + 1)
  const d = pad(date.getDate())
  const h = pad(date.getHours())
  const min = pad(date.getMinutes())
  return `${y}${m}${d}T${h}${min}00`
}

const formatUtcDateTime = (date: Date) =>
  date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'

export const createGoogleCalendarLink = (event: CalendarEvent) => {
  const params = new URLSearchParams({
    action: 'TEMPLATE',
    text: event.title,
    details: event.description ?? '',
    location: event.location ?? '',
    dates: `${formatLocalDateTime(event.startDateTime)}/${formatLocalDateTime(event.endDateTime)}`,
  })
  return `https://calendar.google.com/calendar/render?${params}`
}

For Apple and Outlook the recipe is the same. Assemble the ICS string, wrap it in a Blob, and trigger a download.

const buildIcs = (event: CalendarEvent, prodId: string) =>
  [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    `PRODID:${prodId}`,
    'CALSCALE:GREGORIAN',
    'BEGIN:VEVENT',
    `UID:${Date.now()}@urre.me`,
    `DTSTAMP:${formatUtcDateTime(new Date())}`,
    `DTSTART:${formatUtcDateTime(event.startDateTime)}`,
    `DTEND:${formatUtcDateTime(event.endDateTime)}`,
    `SUMMARY:${event.title}`,
    `DESCRIPTION:${event.description ?? ''}`,
    `LOCATION:${event.location ?? ''}`,
    'END:VEVENT',
    'END:VCALENDAR',
  ].join('\r\n')

const downloadIcs = (event: CalendarEvent, prodId: string) => {
  const blob = new Blob([buildIcs(event, prodId)], { type: 'text/calendar' })
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = `${event.title.replace(/\s+/g, '_')}.ics`
  document.body.appendChild(link)
  link.click()
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

Two small details worth flagging:

  • ICS lines are joined with \r\n, not \n. RFC 5545 is explicit about it, and a few stricter parsers refuse files that get this wrong.
  • Always URL.revokeObjectURL after the click. Object URLs aren’t garbage-collected on their own, so each download would otherwise leak a blob for the rest of the page’s life.

The component

A dropdown trigger and three options. Nothing fancy, but a few React details turn this from “works” into “behaves right”.

export default function AddToCalendar({
  title,
  startDate,
  endDate,
  description,
  location,
  buttonLabel = 'Add to Calendar',
}: AddToCalendarProps) {
  const [isOpen, setIsOpen] = useState(false)
  const rootRef = useRef<HTMLDivElement>(null)
  const menuId = useId()

  const event = useMemo<CalendarEvent>(() => {
    const start = toDate(startDate)
    const end = endDate ? toDate(endDate) : new Date(start.getTime() + 60 * 60 * 1000)
    return { title, description, location, startDateTime: start, endDateTime: end }
  }, [title, startDate, endDate, description, location])

  useEffect(() => {
    if (!isOpen) return
    const onPointerDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setIsOpen(false)
    }
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setIsOpen(false)
    }
    document.addEventListener('pointerdown', onPointerDown)
    document.addEventListener('keydown', onKeyDown)
    return () => {
      document.removeEventListener('pointerdown', onPointerDown)
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [isOpen])

  // ...
}

The choices that matter:

  • useMemo for the event object. Without it, every render produces a fresh object reference, which would invalidate any downstream useCallback or memoization.
  • Effect is gated on isOpen. No listeners are attached while the menu is closed.
  • pointerdown instead of click for outside-close. Pointer events fire before focus shifts, which avoids one-frame flickers.
  • useId for the menu so the trigger’s aria-controls lines up with the menu’s id, important for screen readers.
  • window.open(..., 'noopener,noreferrer') for the Google link, since we control where it goes but don’t need to leak window references.

Accept any date shape

The original component required a Date instance. Easy to forget, easy to break. A small toDate helper lets callers pass an ISO string, a timestamp, or a Date:

const toDate = (value: Date | string | number) =>
  value instanceof Date ? value : new Date(value)

That’s the whole thing. No libraries, no toast system, a single CSS module, and the visitor gets exactly what they wanted: their event, in their calendar.

Did you enjoy this post?