# ICS Viewer — Calendar Embed API

Embed a live, browsable calendar from any public iCal (`.ics`) feed into any webpage with one script tag. No framework, no build step, no API key, no account. Free, including on commercial sites.

This document is the complete reference. You do not need to visit the visual builder at https://icsviewer.com/embed to write a correct embed — everything the widget accepts is specified below.

## Quick start

```html
<div data-ics-url="https://example.com/calendar.ics"></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

Replace the URL with a public iCal feed. That is a complete, working embed — every other attribute is optional.

## How it works

1. `widget.js` finds every element on the page carrying a `data-ics-url` attribute.
2. For each one it injects an iframe pointing at `https://icsviewer.com/view`, translating the element's `data-*` attributes into query parameters.
3. The feed is fetched and parsed (RFC 5545, including `RRULE` recurrence and `VTIMEZONE`) and rendered as an interactive calendar.
4. A small attribution link is added directly below the calendar, in your page's DOM.

Nothing is stored and no account is involved. The embed is read-only: visitors browse events, they cannot edit the calendar.

## Options

All options are attributes on the container `<div>`. Booleans are `"1"` for on; omit the attribute for off.

### Required

| 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. |

### Layout

| 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. |

### Appearance

| 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. |

### Content

| 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. |

### Localization

| 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. |

## Recipes

### Minimal — month view, light, default everything

```html
<div data-ics-url="https://example.com/calendar.ics"></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### Upcoming events list for a sidebar

```html
<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>
```

### Several feeds merged into one calendar

```html
<div data-ics-url="https://example.com/airbnb.ics,https://example.com/booking.ics,https://example.com/vrbo.ics"></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### Sized to its content, no fixed box

```html
<div
  data-ics-url="https://example.com/calendar.ics"
  data-view="agenda"
  data-views="agenda"
  data-height="auto"
  data-upcoming="1"
></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### Follows the visitor's system dark mode

```html
<div
  data-ics-url="https://example.com/calendar.ics"
  data-theme="auto"
  data-weekstart="1"
  data-timeformat="24h"
></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### Bare calendar with no chrome, for a page that supplies its own heading

```html
<div
  data-ics-url="https://example.com/calendar.ics"
  data-views="month"
  data-radius="0"
  data-hide-header="1"
></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### Single-topic calendar pinned to a venue's timezone

```html
<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>
```

## Framework integration

### Plain HTML

```html
<div data-ics-url="https://example.com/calendar.ics"></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### React / Next.js

```tsx
import { useEffect, useRef } from "react";

export function Calendar({ feedUrl }: { feedUrl: string }) {
  const ref = useRef<HTMLDivElement>(null);

  // The div is created by React after the page's scripts have already run, so
  // widget.js has to be injected once the container exists.
  useEffect(() => {
    if (!ref.current) return;
    const s = document.createElement("script");
    s.src = "https://icsviewer.com/widget.js";
    s.async = true;
    document.body.appendChild(s);
    return () => { s.remove(); };
  }, []);

  return <div ref={ref} data-ics-url={feedUrl} data-height="600" />;
}
```

### Vue 3

```vue
<script setup>
import { onMounted } from "vue";

onMounted(() => {
  const s = document.createElement("script");
  s.src = "https://icsviewer.com/widget.js";
  s.async = true;
  document.body.appendChild(s);
});
</script>

<template>
  <div data-ics-url="https://example.com/calendar.ics" data-height="600"></div>
</template>
```

### WordPress

```html
<!-- Add a "Custom HTML" block and paste this. Do not use the visual
     editor: it strips <script> tags. -->
<div data-ics-url="https://example.com/calendar.ics"></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

### Webflow, Squarespace, Wix, Framer

```html
<!-- Use the platform's "Embed" / "Custom code" element and paste
     both lines together into the same block. -->
<div
  data-ics-url="https://example.com/calendar.ics"
  data-height="700"
></div>
<script src="https://icsviewer.com/widget.js" async></script>
```

## Direct iframe (no JavaScript)

If the host platform blocks `<script>` tags, embed the frame URL directly. Every option maps to a query parameter on `https://icsviewer.com/view`:

```html
<iframe src="https://icsviewer.com/view?url=https%3A%2F%2Fexample.com%2Fcalendar.ics&embedded=1&view=agenda&show=month%2Cagenda&color=2563eb&weekstart=1&h24=1"
  width="100%" height="600" style="border:none;border-radius:8px" loading="lazy"></iframe>
```

Attribute-to-parameter mapping:

| Attribute | Query parameter |
| --- | --- |
| `data-ics-url` | `url` |
| `data-view` | `view` |
| `data-views` | `show` |
| `data-compact` | `compact` |
| `data-theme` | `theme` |
| `data-color` | `color` |
| `data-bg` | `bg` |
| `data-hide-header` | `noheader` |
| `data-title` | `title` |
| `data-upcoming` | `upcoming` |
| `data-filter` | `filter` |
| `data-timezone` | `tz` |
| `data-locale` | `locale` |
| `data-weekstart` | `weekstart` |
| `data-timeformat` | `h24` (`h24=1` for 24-hour) |

`embedded=1` is required on the frame URL. `data-radius` has no query-param form and a fixed `data-height` is set on the iframe element itself, but `data-height="auto"` maps to `autoheight=1` — note that a raw iframe cannot resize itself, so auto height only takes effect through `widget.js`. To merge feeds, repeat the parameter: `url=...&url=...`. Direct iframes render their own attribution inside the frame.

## Notes and constraints

### The feed must be publicly reachable

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.

### Height comes from the container

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.

### data-height="auto" sizes month, agenda and year to content

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.

### Merging feeds

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.

### One script tag, any number of calendars

widget.js mounts every element carrying data-ics-url on the page. Repeat the div for multiple calendars and include the script once.

### The script must run after the div exists

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.

### Embedded calendars are cached for 15 minutes

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.

### We record the page the calendar is embedded on

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.

### Attribution

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.

## Machine-readable spec

- Option schema as JSON: https://icsviewer.com/embed.json
- This document: https://icsviewer.com/embed.md
- Visual builder with live preview: https://icsviewer.com/embed
- Site overview for LLMs: https://icsviewer.com/llms.txt
