A countdown is more useful when it also lives in the calendar you already check every morning. An "Add to calendar" button is the simplest way to get it there, and you don't need a server or a library to build one. An .ics file is plain text, and the browser can create and download it on its own.

This tutorial builds that button step by step for an all-day countdown date like a wedding, a launch or a holiday. It also covers the details from RFC 5545 (the iCalendar spec) that are easy to get wrong.

The smallest valid file

An iCalendar file is a VCALENDAR that holds one or more VEVENTs. This is about as small as a valid one gets:

BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example//Countdown//EN CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VEVENT UID:3f9c2a1e-7b1d-4c55-9a0e-2d6f1b8e4c11@example.com DTSTAMP:20261102T120000Z DTSTART;VALUE=DATE:20261224 DTEND;VALUE=DATE:20261225 SUMMARY:Christmas Eve END:VEVENT END:VCALENDAR 

VERSION and PRODID are required on the calendar. UID and DTSTAMP are required on every event. CALSCALE and METHOD are optional, but METHOD:PUBLISH tells clients this is a one-way "here's an event" file and not a meeting invite that expects a reply.

All-day events: VALUE=DATE and an exclusive end

A countdown target is usually a day, not a moment in time. If you write DTSTART:20261224T000000Z, you've said "midnight UTC". For someone in New York, that shows up on the evening of the 23rd.

Use a date value instead:

DTSTART;VALUE=DATE:20261224 DTEND;VALUE=DATE:20261225 

A DATE value has no time and no time zone. It means "this calendar day, wherever you are", which is exactly what a countdown wants.

Note that DTEND is exclusive. A one-day event on the 24th ends on the 25th. If you set DTEND to the same day as DTSTART, some clients show a zero-length event or reject it. You can also leave DTEND out, because the spec says a date-only DTSTART alone lasts one day. Setting it explicitly is still the most widely compatible choice.

Here's a helper that does the date math on calendar parts, so the user's time zone can't shift the day:

// "2026-12-24" -> "20261224" function toIcsDate(isoDate) { return isoDate.replaceAll("-", ""); } // "2026-12-24" -> "2026-12-25", computed in UTC so DST can't interfere function nextDay(isoDate) { const [y, m, d] = isoDate.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d + 1)); return dt.toISOString().slice(0, 10); } 

UID and DTSTAMP

UID identifies the event for good. If a user imports the same file twice, a calendar that sees the same UID can update the event instead of creating a duplicate. So derive it from something stable, like your record's ID, and add a domain to make it globally unique:

const uid = `${countdown.id}@example.com`; 

DTSTAMP is when the file was created. It must be a UTC date-time with a trailing Z:

function toIcsUtc(date) { // 2026-11-02T12:00:00.000Z -> 20261102T120000Z return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); } 

Escaping text

In SUMMARY, DESCRIPTION and LOCATION, four things need escaping: backslash, semicolon, comma and newline. Escape the backslash first, or you'll double-escape everything else:

function escapeText(value) { return String(value) .replace(/\\/g, "\\\\") .replace(/;/g, "\\;") .replace(/,/g, "\\,") .replace(/\r\n|\r|\n/g, "\\n"); } escapeText("Trip: Rome, Florence; then home\nPack light"); // "Trip: Rome\, Florence\; then home\nPack light" 

Colons don't need escaping in values. Only the first colon on a line separates the property name from its value.

Line folding at 75 octets

RFC 5545 says lines should be no longer than 75 octets (bytes), not counting the line break. Longer lines are folded: break the line, then start the next one with a single space. When a client reads the file, it removes each "CRLF + space" pair.

Most clients will tolerate long lines anyway, but a long description is exactly where strict parsers fail. The catch is that the limit is in bytes, and you must never split a multi-byte UTF-8 character (an emoji, "ł", "é") in half. So count bytes per character:

const encoder = new TextEncoder(); function foldLine(line) { const parts = []; let current = ""; let bytes = 0; let limit = 75; // first line: 75 octets for (const char of line) { // iterates by code point, not UTF-16 unit const size = encoder.encode(char).length; if (bytes + size > limit) { parts.push(current); current = ""; bytes = 0; limit = 74; // continuation lines start with a space, which counts } current += char; bytes += size; } parts.push(current); return parts.join("\r\n "); } 

CRLF line endings

Every line in an iCalendar file ends with \r\n, including the last one. A file built with plain \n often still imports, but not everywhere, so build it with CRLF from the start:

const lines = [/* ... */]; const ics = lines.map(foldLine).join("\r\n") + "\r\n"; 

Putting it together

function buildIcs({ id, title, date, description, url, reminderDays }) { const lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Example//Countdown//EN", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "BEGIN:VEVENT", `UID:${id}@example.com`, `DTSTAMP:${toIcsUtc(new Date())}`, `DTSTART;VALUE=DATE:${toIcsDate(date)}`, `DTEND;VALUE=DATE:${toIcsDate(nextDay(date))}`, `SUMMARY:${escapeText(title)}`, ]; if (description) lines.push(`DESCRIPTION:${escapeText(description)}`); if (url) lines.push(`URL:${url}`); if (reminderDays) { lines.push( "BEGIN:VALARM", "ACTION:DISPLAY", `DESCRIPTION:${escapeText(title)}`, `TRIGGER:-P${reminderDays}D`, "END:VALARM" ); } lines.push("END:VEVENT", "END:VCALENDAR"); return lines.map(foldLine).join("\r\n") + "\r\n"; } 

The optional reminder

A VALARM inside the event adds a reminder. ACTION:DISPLAY requires both DESCRIPTION and TRIGGER. TRIGGER:-P1D means one day before the start. For an all-day event, the start is midnight local time, so the alert appears the day before at midnight. If you want "9:00 the day before", use -PT15H.

Be aware that support varies. Apple Calendar generally honours alarms in imported files. Google Calendar usually ignores them on import and applies the user's default notifications instead. Treat the reminder as a bonus, not a promise.

Downloading it with a Blob

No server needed:

function downloadIcs(countdown) { const ics = buildIcs(countdown); const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" }); const href = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = href; a.download = `${slugify(countdown.title) || "event"}.ics`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(href), 1000); } function slugify(text) { return text .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 50); } 

Call downloadIcs directly from a click handler. Browsers are stricter about downloads that aren't triggered by a user action.

The alternative: "add" links

A file download is one more step on desktop, so many apps also offer links that open a pre-filled event form.

Google Calendar takes all-day dates as YYYYMMDD/YYYYMMDD, with the same exclusive end:

function googleCalendarUrl({ title, date, description }) { const params = new URLSearchParams({ action: "TEMPLATE", text: title, dates: `${toIcsDate(date)}/${toIcsDate(nextDay(date))}`, details: description ?? "", }); return `https://calendar.google.com/calendar/render?${params}`; } 

Outlook on the web uses ISO dates and an allday flag:

function outlookUrl({ title, date, description }) { const params = new URLSearchParams({ path: "/calendar/action/compose", rru: "addevent", subject: title, startdt: date, enddt: nextDay(date), allday: "true", body: description ?? "", }); return `https://outlook.live.com/calendar/0/deeplink/compose?${params}`; } 

Use outlook.office.com instead of outlook.live.com for work and school accounts. These URL formats aren't formally documented specs, so test them now and then. The .ics file is the only option defined by a standard.

Common gotchas

  • Time zones. For a timed event, either use UTC (...T090000Z) or TZID=Europe/Warsaw together with a matching VTIMEZONE block. A TZID without its VTIMEZONE is technically invalid, even though many clients guess correctly. For countdown days, skip all of that and use VALUE=DATE.
  • Inclusive end dates. Setting DTEND to the same day as DTSTART is the most common all-day bug. The end is the day after.
  • Apple vs Google. On iOS Safari, a text/calendar download opens the "Add to Calendar" sheet. Google Calendar has no "open this file" step on mobile, so users have to import it from the desktop web settings. That's why a Google link next to the download button helps.
  • MIME type and extension. Use text/calendar and a .ics filename. If you serve the file from a server instead, also send Content-Disposition: attachment; filename="event.ics".
  • Filenames. Strip slashes, emoji and other odd characters. Some systems save or open the file badly otherwise.
  • Duplicates. A random UID per click means every re-import creates a new event. Keep it stable.

Wrapping up

The checklist is short: date values for all-day events, an exclusive end, a stable UID, UTC DTSTAMP, escaped text, folded lines, CRLF, and a text/calendar Blob. Add a Google link for people who live in a browser, and you've covered almost everyone.

I wrote this while thinking about calendar export for CountMyDays, a free app for countdowns and "days since" trackers, where you can add any countdown to Google Calendar or download it as an .ics file. If you've run into a calendar client that breaks one of these rules, tell me in the comments.