The full contract, in one place. You don't need the builder above to write a correct embed — hand this page, or /embed.md, to a coding assistant and it has everything it needs.
<div data-ics-url="https://example.com/calendar.ics"></div>
<script src="https://icsviewer.com/widget.js" async></script>
Swap in a public iCal feed URL. That's a complete embed — every other attribute is
optional. widget.js
mounts every element on the page carrying data-ics-url,
so include the script once no matter how many calendars you place.
Every option is an attribute on the container <div>.
Booleans are "1" for on; omit the attribute for off.
| Attribute | Values | Default | Description |
|---|---|---|---|
| data-ics-url | url | required | Public iCal feed URL (an .ics endpoint). Must be reachable without authentication — a private webcal:// or password-protected feed will not load. Pass several comma-separated URLs to merge them into one calendar; each feed gets its own colour and a legend appears above the grid. This is the only required attribute. |
| Attribute | Values | Default | Description |
|---|---|---|---|
| data-view | month | week | day | year | agenda | month | View shown when the calendar first loads. Must be one of the views listed in data-views. |
| data-views | any of month, week, day, year, agenda | month,week,agenda | Comma-separated list of views the visitor can switch between. Order controls the order of the switcher buttons. Pass a single value to lock the calendar to one view. |
| data-height | length | 600px | Height of the embed. A bare number is treated as pixels; any CSS length (600px, 80vh, 100%) is accepted. Use "auto" to size the embed to its content — the frame measures itself and reports its height to the page, so the calendar never scrolls internally and leaves no empty space below it. Ignored when the container already carries an explicit height from your own CSS. |
| data-radius | int | 8 | Corner radius of the frame in pixels. 0 for square corners. |
| data-compact | bool | 0 | Tighter spacing and smaller type. Use for sidebars and narrow columns under ~400px wide. |
| Attribute | Values | Default | Description |
|---|---|---|---|
| data-theme | light | dark | auto | light | Colour scheme. "auto" follows the visitor's system dark-mode setting and repaints if they change it mid-session. Setting data-bg overrides this — an explicit background always wins. |
| data-color | color | 7c3aed | Accent colour for event chips, the today marker and active controls. Hex, with or without the leading #. |
| data-bg | color | ffffff | Background colour of the calendar surface. Dark values automatically flip text and grid lines to a light-on-dark palette. Overrides data-theme, so set one or the other. |
| data-hide-header | bool | 0 | Hide the calendar's title bar and view switcher. Combine with a single value in data-views to drop a bare calendar into a page that supplies its own heading. |
| data-title | string | — | Replace the feed's own calendar name with custom header text. Ignored when data-hide-header is set. |
| Attribute | Values | Default | Description |
|---|---|---|---|
| data-upcoming | bool | 0 | Show only events from today forward. Past events are omitted entirely. |
| data-filter | string | — | Show only events whose title or category contains this keyword. Case-insensitive substring match, single keyword. |
| Attribute | Values | Default | Description |
|---|---|---|---|
| data-timezone | string | — | IANA timezone name used to render event times. Defaults to the visitor's own browser timezone, which is usually what you want for a public calendar. Set it explicitly to pin the calendar to a venue's local time. |
| data-locale | string | — | BCP 47 locale for month names, weekday labels and date formatting. Defaults to the visitor's browser locale. |
| data-weekstart | 0 | 1 | 0 | First day of the week in month and week views. 0 = Sunday, 1 = Monday. |
| data-timeformat | 12h | 24h | 12h | Clock format for event times. Note the query-param form differs: the direct-iframe equivalent is h24=1 for 24-hour. |
Agenda-only, past events dropped, tight spacing.
<div
data-ics-url="https://example.com/calendar.ics"
data-view="agenda"
data-views="agenda"
data-height="480"
data-compact="1"
data-upcoming="1"
></div>
<script src="https://icsviewer.com/widget.js" async></script> A dark background hex flips the whole palette; nothing else needed.
<div
data-ics-url="https://example.com/calendar.ics"
data-color="22d3ee"
data-bg="0b0b0f"
data-weekstart="1"
data-timeformat="24h"
></div>
<script src="https://icsviewer.com/widget.js" async></script> One keyword, your own header text, a fixed venue timezone.
<div
data-ics-url="https://example.com/calendar.ics"
data-color="059669"
data-title="Workshops"
data-filter="workshop"
data-timezone="America/New_York"
></div>
<script src="https://icsviewer.com/widget.js" async></script> widget.js loads the feed through icsviewer.com, not from the visitor's browser session. A Google Calendar 'secret address' works; a calendar shared only with signed-in users does not. If the calendar renders empty, open the feed URL in a private window first — if you get a login page, the embed will get one too.
The frame fills its container. Use data-height, or give the container an explicit height in your own CSS. Without either, the frame collapses to the browser's default iframe height of 150px.
Day and week are a fixed 24-hour grid, so sizing them to content would mean a 1536px embed. Those two keep an internal scroll at roughly 620px in auto mode; the other three grow and shrink to fit, reporting their height to the page as the visitor navigates.
Comma-separate URLs in data-ics-url to show several calendars at once — each gets a colour from a ten-slot palette in the order listed, and a legend row names them. Feeds are fetched in parallel and one that fails is skipped rather than breaking the whole embed, so a merged calendar still renders if a single host is down.
widget.js mounts every element carrying data-ics-url on the page. Repeat the div for multiple calendars and include the script once.
widget.js mounts on DOMContentLoaded, so placement is flexible in plain HTML. In a client-rendered framework the div may appear after the script has already run — see the framework snippets for the mounting pattern that avoids this.
An embed re-renders whenever its page loads — on a dashboard or a signage screen that is hundreds of times a day for one unchanged feed. So icsviewer.com serves embedded frames from a shared cache and fetches your feed at most once every 15 minutes, keyed on the feed URL alone: every screen showing the same calendar shares one upstream request, and your calendar host is not hit once per viewer. An edit to the calendar therefore takes up to 15 minutes to appear in an embed. Nothing else is cached this way — opening a feed directly on icsviewer.com always fetches it fresh, so you can check a change immediately there. Cache entries are keyed by the full feed URL, which is itself the calendar's read credential, so an entry is only ever reachable by a request already holding that URL.
widget.js adds the URL of the page it is running on to the frame URL as hosturl=, and we record it. This is the address of your page, not anything about the people viewing it — no visitor identifiers, no cookies, and the fragment (#...) is never included. It tells us where the widget is in use, which is what lets us keep the embed free: we can see when one page is generating heavy load and fix it, rather than rate-limiting everyone. Your browser already sends us your site in the Referer header on every embed request, so this changes the precision of what we see, not the kind. If your embed sits on a URL whose query string you would rather not share, put the calendar on a path without it — or use the direct-iframe form, which skips widget.js and sends no hosturl at all.
widget.js renders a small 'Calendar by ICS Viewer' link directly beneath the calendar, in your page's DOM. It is part of the free embed. Removing it in CSS is not supported.
Point it at one of these instead of copying from the builder.
/embed.md and
/embed.json are generated from the same spec
as this page and are served with permissive CORS, so an agent can fetch them directly.