Render a public Google Calendar into HTML you control. Template-driven, locale-aware, consent-friendly. Zero dependencies, ESM only.
npm install @copperdesign/gcalYou point it at a public Google Calendar and an HTML <template>. It fetches events from the Calendar v3 API, clones the template per event, fills data-slot attributes with event fields, and appends them to a target element.
That's it. No framework, no virtual DOM, no jQuery. ~7KB unminified.
<div id="events"></div>
<template id="gcal-row">
<article class="gcal-event">
<header data-slot="dates"></header>
<h3 data-slot="summary"></h3>
<p data-slot="description" data-html></p>
<a data-slot="mapLink" data-attr="href" hidden>Karte</a>
<a data-slot="htmlLink" data-attr="href" hidden>Details</a>
</article>
</template>
<script type="module">
import { GCal } from '@copperdesign/gcal';
new GCal({
target: '#events',
template: '#gcal-row',
calendarId: 'YOUR_CALENDAR_ID@group.calendar.google.com',
apiKey: 'YOUR_API_KEY',
}).mount();
</script>Three things have to be in place before the library can fetch anything:
-
Make the calendar public. Google Calendar → your calendar's Settings and sharing → Access permissions → tick Make available to public. The Calendar ID (
…@group.calendar.google.com) is further down on the same page under Integrate calendar. -
Create an API key. Google Cloud Console → APIs & Services → Library → enable Google Calendar API. Then Credentials → Create credentials → API key.
-
Restrict the key. It ships in your page source — anyone viewing your site can read it. Two restrictions stop it being reused elsewhere:
- Application restrictions → HTTP referrers (websites) → add every host the embed runs on. Google requires the
*wildcard form:https://example.com/*,https://www.example.com/*, plus any staging or preview domain. - API restrictions → Restrict key → select Google Calendar API only.
- Application restrictions → HTTP referrers (websites) → add every host the embed runs on. Google requires the
Without the restrictions the key still works, but any visitor who copies it can use your project's quota from anywhere.
The template is plain HTML inside a <template> element. Three attributes control rendering:
| Attribute | Effect |
|---|---|
data-slot="summary" |
element.textContent = data.summary (escaped) |
data-slot="description" data-html |
element.innerHTML = data.description (trusted) |
data-slot="mapLink" data-attr="href" |
element.setAttribute('href', data.mapLink) |
data-slot="..." data-remove-empty |
Remove the element when the bound field is empty (default: add hidden) |
Available fields after default formatting:
{
// Direct from Google
summary, description, location, htmlLink, start, end,
// Composed by formatEventDates()
dates, // "5. Juni bis 7. Juni 2026 um 14:00 Uhr"
allDay, // boolean
sameDay, // boolean
sameTime, // boolean
startDay, // "5"
startMonth, // "Juni"
startYear, // "2026"
startTime, // "14:00" (empty for all-day)
endDay, endMonth, endYear, endTime, // only set when different from start
// Derived
mapLink, // Google Maps URL built from location, or '' if no location
total, // total event count in this render (useful for sizing)
}new GCal({
// Required
target: '#events', // selector or Element
template: '#gcal-row', // selector, Element, or HTML string
calendarId: '…@group.calendar.google.com',
apiKey: '…',
// Calendar API knobs (optional)
maxResults: 100,
orderBy: 'startTime', // or 'updated'
timeMin: new Date().toISOString(),
timeMax: undefined, // ISO string to cap the range
// Localization
locale: 'de-DE', // default: document.documentElement.lang || 'de-DE'
timeZone: 'Europe/Berlin',
// Optional state templates (selectors, elements, or HTML strings)
emptyTemplate: '#gcal-empty',
errorTemplate: '#gcal-error',
loadingTemplate: '#gcal-loading',
// Consent gate (omit for no gating)
consent: {
check: () => window.consent?.hasConsent?.('gcal') ?? false,
request: async () => window.consent?.optIn?.('gcal'),
ctaTemplate: '#gcal-cta', // shown when check() is false
event: 'consentchange', // DOM event to re-render on (default: 'consentchange')
},
// Hooks
transformEvent: (event) => ({ ...event, mapLink: customMapUrl(event) }),
formatDates: (event) => formatEventDates(event, { locale: 'de-DE' }),
cleanLocation: (loc) => loc.replace(/, Deutschland$/, ''),
onError: (err) => console.error(err),
}).mount();The library never imports a specific consent SDK. You implement a small adapter:
import { GCal } from '@copperdesign/gcal';
const consent = {
check: () => window.myConsent.has('gcal'),
request: async () => window.myConsent.optIn('gcal'),
ctaTemplate: '#gcal-cta',
};
new GCal({ /* …, */ consent }).mount();<template id="gcal-cta">
<div class="consent-card">
<p>Beim Laden des Kalenders werden Daten an Google übertragen.</p>
<button data-gcal-optin>Termine laden</button>
</div>
</template>When consent is granted (synchronously or by request() resolving), the library fetches and renders. If a consentchange CustomEvent fires on document later (e.g. from a global cookie banner), it re-renders automatically.
The recommended pairing — a zero-dependency, click-to-load consent gate built to the same shape as gCal. The adapter is three lines:
import { GCal } from '@copperdesign/gcal';
import easyCookieConsent from '@copperdesign/easy-cookie-consent';
const ecc = easyCookieConsent({
// easy-cookie-consent shows a global modal on load by default.
// If gCal's CTA template is your only consent UI, set this to false.
// Leave it true (default) to pair the global banner with the per-embed CTA.
showModal: false,
// Re-render gCal when consent flips elsewhere on the page
// (global modal, revoke link, …).
onConsent: () => document.dispatchEvent(new CustomEvent('consentchange')),
});
new GCal({
// …,
consent: {
check: () => ecc.hasConsent('gcal'),
request: () => ecc.optIn('gcal'),
ctaTemplate: '#gcal-cta',
},
}).mount();gCal stays provider-agnostic — easy-cookie-consent is opt-in, not bundled.
<template id="gcal-empty">
<p class="gcal-empty">Keine aktuellen Termine.</p>
</template>
<template id="gcal-error">
<p class="gcal-error">Kalender konnte nicht geladen werden: <span data-slot="message"></span></p>
</template>
<template id="gcal-loading">
<p class="gcal-loading" aria-busy="true">Termine werden geladen…</p>
</template>The library doesn't ship a layout. Style your own template. If you want a starting point, the default stylesheet is at @copperdesign/gcal/css:
<link rel="stylesheet" href="https://unpkg.com/@copperdesign/gcal/dist/gcal.css">Tunable via CSS custom properties:
:root {
--gcal-accent: #294983;
--gcal-accent-bg: #99C1E3;
--gcal-time: #F5A623;
--gcal-border: rgba(0, 0, 0, 0.13);
}Weebly has no npm install — load gCal from a CDN and paste the whole snippet into a single Embed Code element on the page where the calendar should appear:
<link rel="stylesheet" href="https://unpkg.com/@copperdesign/gcal/dist/gcal.css">
<div id="events"></div>
<template id="gcal-row">
<article class="gcal-event">
<header data-slot="dates"></header>
<h3 data-slot="summary"></h3>
<p data-slot="description" data-html></p>
<a data-slot="mapLink" data-attr="href" hidden>Karte</a>
</article>
</template>
<script type="module">
import { GCal } from 'https://unpkg.com/@copperdesign/gcal';
new GCal({
target: '#events',
template: '#gcal-row',
calendarId: 'YOUR_CALENDAR_ID@group.calendar.google.com',
apiKey: 'YOUR_API_KEY',
locale: 'de-DE',
timeZone: 'Europe/Berlin',
}).mount();
</script>For a sitewide stylesheet, move the <link> into Settings → SEO → Header Code so every page gets it without re-pasting.
Before you paste this live: complete the steps in Google Cloud setup above — in particular, restrict the API key to your Weebly domain(s) under HTTP referrers, since the key ships in page source.
If you need consent gating before the fetch (DSGVO), see Consent flow above and pass a consent object alongside the other options.
import { GCal, fetchEvents, formatEventDates, renderTemplate, resolveTemplate } from '@copperdesign/gcal';
const cal = new GCal({ /* … */ });
// One-shot
await cal.render();
// SPA lifecycle
const unmount = cal.mount();
unmount();
// Pre-fetched items (SSR hydration, test fixtures)
cal.renderItems([{ summary: '…', start: {…}, end: {…} }]);
// Use the primitives directly
const items = await fetchEvents({ calendarId, apiKey });
const tpl = resolveTemplate('#gcal-row');
for (const event of items) {
const data = { ...event, ...formatEventDates(event) };
document.querySelector('#events').appendChild(renderTemplate(tpl, data));
}A common pattern — and the one this library was originally written against — is the date-pill listing: a coloured date block on the left, a stack of time / title / description / location on the right, and a "continuous-day" modifier that hides the date pill for back-to-back events on the same date.
Three derived fields cover the parts the defaults don't produce directly:
rowClass— the full container class string, so a neighbour-dependent modifier (continuous-day) can be precomputed.timeRange— a time-only string ("14:00 bis 16:00 Uhr"), since the built-indatesfield always includes the date.locationBlock— the wrapped<b>Ort:</b> <a href="…">address</a>fragment, bound throughdata-html. The library's "one binding rule per node" forbids putting bothhrefand a text label on the same<a>, and pre-composing the HTML is the cleanest way around it.
Because continuous-day depends on the previous event, the work
happens in a single pre-pass before renderItems (the per-event
transformEvent hook can't see neighbours):
import { GCal } from '@copperdesign/gcal';
const timeFmt = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Europe/Berlin',
});
function preprocess(items) {
const dayKey = (e) => (e.start.dateTime ?? e.start.date).slice(0, 10);
return items.map((e, i, arr) => {
const sameAsPrev = i > 0 && dayKey(arr[i - 1]) === dayKey(e);
const rowClass = sameAsPrev ? 'gcal-row gcal-continuous-day' : 'gcal-row';
const startTime = e.start.dateTime ? timeFmt.format(new Date(e.start.dateTime)) : '';
const endTime = e.end.dateTime ? timeFmt.format(new Date(e.end.dateTime)) : '';
const timeRange = startTime && endTime ? `${startTime} bis ${endTime} Uhr` : '';
const locationBlock = e.location
? `<b>Ort:</b> <a href="https://maps.google.com/maps?q=${encodeURIComponent(e.location)}" target="_blank">${e.location}</a>`
: '';
return { ...e, rowClass, timeRange, locationBlock };
});
}
const cal = new GCal({
target: '#events',
template: '#gcal-row',
calendarId: '…', apiKey: '…',
locale: 'de-DE', timeZone: 'Europe/Berlin',
});
// Drive the pipeline yourself when you need pre-render context:
const items = await fetchEvents({ calendarId: '…', apiKey: '…' });
cal.renderItems(preprocess(items));The matching template — structurally identical to the jQuery-era markup this layout grew out of:
<template id="gcal-row">
<div data-slot="rowClass" data-attr="class">
<div class="gcal-cal">
<div class="gcal-day">
<div class="gcal-dm" data-slot="startMonth"></div>
<div class="gcal-dd" data-slot="startDay"></div>
<div class="gcal-dy" data-slot="startYear"></div>
</div>
</div>
<div class="gcal-info">
<div class="gcal-time" data-slot="timeRange" data-remove-empty></div>
<h3 class="gcal-title" data-slot="summary"></h3>
<div class="gcal-description" data-slot="description" data-html data-remove-empty></div>
<div class="gcal-location" data-slot="locationBlock" data-html data-remove-empty></div>
</div>
</div>
</template>CSS hides the date pill on continuation rows and tightens the divider:
.gcal-continuous-day { border-top: none; }
.gcal-continuous-day .gcal-day { display: none; }
.gcal-continuous-day .gcal-info { border-top: 1px solid var(--gcal-border); }Events whose location field is empty drop the whole gcal-location
block (via data-remove-empty), so authors can inline an "Ort:" line
in the description for venues the calendar entry doesn't geocode.
Modern evergreens. Requires native fetch, Intl.DateTimeFormat, <template>, URLSearchParams. No build step required.
This module is the modern successor to a pair of older scripts — a 2018 jQuery plugin and a later vanilla rewrite — that rendered the same Google Calendar pattern in production. The current rewrite splits rendering from data, drops the bundled Steven Levithan dateFormat library in favor of Intl, and makes consent gating a contract rather than a built-in.
PRs and issues welcome. See CONTRIBUTING.md for setup, the PR workflow, and what fits the scope of the module. The repo follows the Contributor Covenant.
Quick version: fork, branch off master, exercise your change against
test/index.html (offline) and demo/index.html (live API) in at least
one non-Chromium browser, open a PR. I (@copperdesign) review and merge.
The package is published to npm as
@copperdesign/gcal
and installable in any project with:
npm install @copperdesign/gcalFor future releases:
npm version patch # or minor / major — bumps package.json, commits, tags vX.Y.Z
git push --follow-tags
gh release create vX.Y.Z --generate-notesThe release.yml GitHub Actions workflow handles the rest: it
smoke-checks every src/*.js, verifies the tag matches package.json,
confirms every exports subpath resolves, and publishes to npm with
provenance. Requires an NPM_TOKEN repo secret minted from the
copperdesign npm account.
MIT — see LICENSE.
Created by Christian Fillies.