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
Generating the links
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.revokeObjectURLafter 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:
useMemofor the event object. Without it, every render produces a fresh object reference, which would invalidate any downstreamuseCallbackor memoization.- Effect is gated on
isOpen. No listeners are attached while the menu is closed. pointerdowninstead ofclickfor outside-close. Pointer events fire before focus shifts, which avoids one-frame flickers.useIdfor the menu so the trigger’saria-controlslines up with the menu’sid, 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.