Create an Add-to-Calendar widget with a form

today April 20, 2023

Are you familiar with the Add-to-Calendar widget that shows up in confirmation emails and web pages after a user registers to an event or a meeting? the widget enables the user to quickly add the event to their favorite calendar, and usually looks something like this:

Add to calendar

If you were brave enough to click above on the calendar you're using, you saw an 'add-to-calendar' page show up that's pre-populated with some info. And if you'd like to have the ability to quickly create such widgets for your own audiences, then you're in luck, because today I'll be sharing the code for an online form that you can use to generate these widgets. The output page would look like this:

Calendar event widget form

The ICS file

The widget-creation form can be saved in a standard HTML file that you can keep right on your computer's hard-drive. However, if you'd like the widget to also contain a link to download an ICS file (this is a file that enables users to add the event to their offline calendar, like Apple Calendar) then you need a web server that can respond to GET requests and direct the browser to download the file (instead of attempting to open it in the browser.)

The reason you need a web server to download the file is because the standard way of including the ics data in a "data link" doesn't work with numerous email software providers. Gmail, for instance, strips off any href attribute of an anchor link that doesn't point to a server.

And so, because we want to enable the ICS file, I'm going to deploy the form in a Google Apps Script web app. The app will both serve the form and respond to ICS file requests.

The form

Here's the code for the form. Please watch my video at the top of this page if you need help understanding any of the code.

<!DOCTYPE html>

    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      content="Create a widget to add an event calendar that you can paste in emails or on web pages"
    <title>Create "Add Event" widget | Ben Ronkin</title>

      <section id="top">
        <div class="container">
          <h1>Add-an-Event widget</h1>

      <section id="input">
        <div class="container" style="border: 1px solid #eeeeee; padding: 10px">
            <div class="row">
              <div class="col s6 input-field">
                <input id="title" type="text" />
                <label for="title">Add title</label>
            <div class="row">
              <div class="col s2 input-field">
                  class="datepicker cursorPointer"
              <div class="col s2 input-field">
              <div class="col s1" style="margin-top: 25px">to</div>
              <div class="col s2 input-field">
                  class="datepicker cursorPointer"
              <div class="col s2 input-field">
                <input id="toTime" name="to-time" type="text" class="text" />
              <div class="input-field col s3">
                <select id="timezone">
                  <!-- <option selected disabled>Select timezone</option> -->
            <div class="row">
              <div class="col s6 input-field">
                <input id="location" type="text" />
                <label for="location">Add location</label>
            <div class="row">
              <div class="col s9 input-field">
                <label for="description">Add description</label>
      <div class="container">
        <h2>Your widget</h2>

      <section id="widget">
        <div class="container">
          <div class="row">
            <div class="col s6">
              <div id="widget-container">
                    border: 1px solid #cccccc;
                    background-color: #f5f5f5;
                    padding: 20px;
                    width: 410px;
                    font-size: 16px;
                  <div style="width: 100%; text-align: center">
                    Add to calendar
                  <div style="width: 100%">
                    <a style="text-decoration: none; margin-right: 10px" href=""
                    <a style="text-decoration: none; margin-right: 10px" href=""
                    <a style="text-decoration: none; margin-right: 10px" href=""
                    <a style="text-decoration: none; margin-right: 10px" href=""
                    <a style="text-decoration: none" href="">ics file</a>
          <div class="row">
            <div class="col s2 input-field" style="margin-left: 10px">
              <button class="btn-small blue">Copy Widget</button>
              class="col s8"
              style="margin: 20px 0 0 -20px; font-style: italic"
              <span id="message"
                >Widget links update automatically when you edit the
    <script src=""></script>
      document.addEventListener('DOMContentLoaded', function () {
        // Globals
        const title = document.querySelector('#title');
        const fromDate = document.querySelector('#fromDate');
        const fromTime = document.querySelector('#fromTime');
        const toDate = document.querySelector('#toDate');
        const toTime = document.querySelector('#toTime');
        const timezone = document.querySelector('#timezone');
        const location = document.querySelector('#location');
        const description = document.querySelector('#description');
        const widget = document.querySelector('#widget-container');
        const button = document.querySelector('button');
        const message = document.querySelector('#message');

        let from = new Date();
        let to = new Date();
        let fromUtc;
        let toUtc;
        let utcOffsets = {};

        function initForm() {
          const minutesNow = from.getMinutes();
          if (minutesNow <= 15) {
          } else if (minutesNow <= 30) {
          } else if (minutesNow <= 45) {
          } else {
            from.setHours(from.getHours() + 1, 0);
          const today = from.toLocaleDateString('en-US', {
            year: 'numeric',
            month: 'short',
            day: 'numeric',
          fromDate.value = today;
          toDate.value = today;
          fromTime.value = from.toLocaleTimeString('en-US', {
            hour: '2-digit',
            minute: '2-digit',
          to.setHours(from.getHours() + 1, from.getMinutes());
          toTime.value = to.toLocaleTimeString('en-US', {
            hour: '2-digit',
            minute: '2-digit',

        function pad(x, digits = 2) {
          return String(x).padStart(digits, 0);

        function getUtcString(date) {
          const dateWithTimeZoneString =
            String(date.getFullYear()) +
            '-' +
            String(date.getMonth() + 1).padStart(2, 0) +
            '-' +
            String(date.getDate()).padStart(2, 0) +
            'T' +
            String(date.getHours()).padStart(2, 0) +
            ':' +
            String(date.getMinutes()).padStart(2, 0) +
            ':00.000' +
          const dateWithTimezone = new Date(dateWithTimeZoneString);
          let utcString = dateWithTimezone.toISOString();
          // Remove milliseconds before returning.
          utcString = utcString.split('.')[0] + 'Z';
          // Remove dashes and colons
          utcString = utcString.replace(/[-:]/g, '');
          return utcString;

        async function setUtcOffset(timezone) {
          if (!utcOffsets[timezone]) {
            const resp = await fetch(
              '' + timezone
            const jsn = await resp.json();
            if (jsn && jsn.utc_offset) {
              utcOffsets[timezone] = jsn.utc_offset;

        function updateLinks() {
          from = new Date(fromDate.value);
          let [hh, mm] = fromTime.value.split(/[^\d]/).map((e) => parseInt(e));
          if (fromTime.value.toLowerCase().includes('p') && hh < 12) {
            hh += 12;
          from.setHours(hh, mm);
          to = new Date(toDate.value);
          [hh, mm] = toTime.value.split(/[^\d]/).map((e) => parseInt(e));
          if (toTime.value.toLowerCase().includes('p') && hh < 12) {
            hh += 12;
          to.setHours(hh, mm);
          const anchorEls = widget.querySelectorAll('a');

          anchorEls[0].href = genGoogle();
          anchorEls[1].href = genOutlook();
          anchorEls[2].href = genOffice365();
          anchorEls[3].href = genYahoo();
          anchorEls[4].href = genIcs();

        function genGoogle() {
          const url =
            '' +
            '&text=' +
            title.value.replace(/\s/g, '+') +
            '&dates=' +
            getUtcString(from) +
            '/' +
            getUtcString(to) +
            '&location=' +
            location.value.replace(/\s/g, '+') +
            '&details=' +
            description.value.replace(/\s/g, '+');
          return url;

        function genOutlook() {
          return genMicrosoft('');

        function genOffice365() {
          return genMicrosoft('');

        function genMicrosoft(domain) {
          const url =
            domain +
            '/calendar/0/deeplink/compose?path=%2Fcalendar%2Faction%2Fcompose&rru=addevent' +
            '&subject=' +
            title.value.replace(/\s/g, '%20') +
            '&startdt=' +
            String(from.getFullYear()) +
            '-' +
            pad(from.getMonth() + 1) +
            '-' +
            pad(from.getDate()) +
            'T' +
            pad(from.getHours()) +
            '%3A' +
            pad(from.getMinutes()) +
            '%3A' +
            '00' +
            utcOffsets[timezone.value] +
            '&enddt=' +
            String(to.getFullYear()) +
            '-' +
            pad(to.getMonth() + 1) +
            '-' +
            pad(to.getDate()) +
            'T' +
            pad(to.getHours()) +
            '%3A' +
            pad(to.getMinutes()) +
            '%3A' +
            '00' +
            utcOffsets[timezone.value] +
            '&location=' +
            location.value.replace(/\s/g, '%20') +
            '&body=' +
            description.value.replace(/\s/g, '%20');
          return url;

        function genYahoo() {
          const url =
            '' +
            '&title=' +
            title.value.replace(/\s/g, '%20') +
            '&st=' +
            getUtcString(from) +
            '&et=' +
            getUtcString(to) +
            '&in_loc=' +
            location.value.replace(/\s/g, '%20') +
            '&desc=' +
            description.value.replace(/\s/g, '%20');
          return url;

        function genIcs() {
          const now = new Date();
          const ics =
            'BEGIN:VCALENDAR\n' +
            'PRODID:-//\n' +
            'VERSION:2.0\n' +
            'CALSCALE:GREGORIAN\n' +
            'METHOD:PUBLISH\n' +
            'BEGIN:VEVENT\n' +
            'DTSTAMP:' +
            getUtcString(now) +
            '\n' +
            'DTSTART:' +
            getUtcString(from) +
            '\n' +
            'DTEND:' +
            getUtcString(to) +
            '\n' +
            'UID:' +
            now.getTime() +
            '\n' +
            'SUMMARY:' +
            title.value +
            '\n' +
            'DESCRIPTION:' +
            description.value +
            '\n' +
            'LOCATION:' +
            location.value +
            '\n' +
            'END:VEVENT\n' +
          const icsUrl =
            'paste-here-the-URL-of-your-google-apps-script-web-app' +
            '?ics=' +
          return icsUrl;
          // return 'data:text/calendar;charset=utf8,' + encodeURIComponent(ics);

        function copyWidget() {
          message.innerText = 'Widget copied to clipboard.';

        function populateTimeZone() {
          const tzs = [
          tzs.forEach((tz) => {
            const option = document.createElement('option');
            option.value = tz;
            option.innerText = tz;
          const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
          const userOption = document.querySelector(
          userOption.setAttribute('selected', 'selected');

        async function init() {
          await setUtcOffset(timezone.value);
          let els = document.querySelectorAll('.datepicker');
          M.Datepicker.init(els, {});
          els = document.querySelectorAll('select'); // required here
          M.FormSelect.init(els, {});
          title.addEventListener('change', updateLinks);
          fromDate.addEventListener('change', updateLinks);
          fromTime.addEventListener('change', updateLinks);
          toDate.addEventListener('change', updateLinks);
          toTime.addEventListener('change', updateLinks);
          location.addEventListener('change', updateLinks);
          description.addEventListener('change', updateLinks);
          timezone.addEventListener('change', async function (e) {
            await setUtcOffset(;
          button.addEventListener('click', copyWidget);


      }); // End of DOMContentLoaded

The web app

I recommend that you create a new Google Apps Script file. In it, create an HTML file, and name it "index.html". Then, in "", enter the following code:

function doGet(e) {
  const icsStr = e.parameter && e.parameter.ics ? e.parameter.ics : null;
  if (icsStr) {
    const file = DriveApp.createFile(icsStr, 'event.ics');
    return ContentService.createTextOutput() // Create textOutput Object
      .append(file) // Append the text from our csv file
      .downloadAsFile('event.ics'); // Have browser download, rather than display
  } else {
    const htmlTemplate = HtmlService.createTemplateFromFile('index');
    const htmlOutput = htmlTemplate.evaluate();
    return htmlOutput;

Again, watch the video to understand how to deploy the script as a web app. Once you deploy it, you want to copy the generated web app URL, go back to your index.html file and paste the URL instead of the 'paste-here-the-URL-of-your-google-apps-script-web-app' string. Then you want to redeploy the web app by selecting a New Version, and you're done.

Once your web app is deployed, you can enter the web app URL in your browser's address bar. You'll be able to change the widget URLs automatically by entering data in the form. Note that once you copy the widget, you can paste it as-is in email messages, but if you add it to HTML pages, you may need to replace the "&" entries in the URLs with "&".