Calendar - headless
Styled SvCalendar and a compact custom day-grid built from panels / dayState / dayProps, sharing one value.
A live, editable Svelte 5 component example from the SvGrid gallery (Headless Editors). Read the SvGrid UI component docs for the full API.
What this example shows
Headless calendar - SvCalendar is headless-first, exactly like the grid. `createCalendar` is the framework-free-in-spirit state machine behind it: view page + drill level, roving focus, every selectionMode, range preview and full keyboard - all reused from the ./datetime engine and exposed as prop-getters you spread onto YOUR OWN markup. Below, the same core drives the styled <SvCalendar> AND a totally custom compact day-grid, both bound to one value. No forked date math, no re-implemented keyboard.
Imports, features and API used
Imports: @svgrid/grid
Source code (276-headless-calendar.svelte)
<script lang="ts">
/**
* Headless calendar - SvCalendar is headless-first, exactly like the grid.
* `createCalendar` is the framework-free-in-spirit state machine behind it:
* view page + drill level, roving focus, every selectionMode, range preview
* and full keyboard - all reused from the ./datetime engine and exposed as
* prop-getters you spread onto YOUR OWN markup. Below, the same core drives the
* styled <SvCalendar> AND a totally custom compact day-grid, both bound to one
* value. No forked date math, no re-implemented keyboard.
*/
import { SvCalendar, createCalendar } from '@svgrid/grid'
let value = $state<Date | null>(new Date(2026, 5, 15))
// The headless core - identical behavior, our own DOM. Clicking a day in either
// render updates the single shared `value`.
const cal = createCalendar({
value: () => value,
onChange: (d) => (value = d[0] ?? null),
})
const fmt = new Intl.DateTimeFormat(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
</script>
<div class="wrap">
<header>
<h2>Headless calendar</h2>
<p>
Every editor ships a headless core + a styled component - the same split as the grid
(<code>createSvGrid</code> / <code><SvGrid></code>). Here <code>createCalendar</code>
drives both renders below; selection, navigation, focus and ARIA come from the core, the markup is yours.
</p>
</header>
<div class="cols">
<section>
<h3>Styled <code><SvCalendar></code></h3>
<SvCalendar {value} footer onChange={(d) => (value = d[0] ?? null)} />
</section>
<section>
<h3>Your markup, same core</h3>
<!-- A compact custom calendar - nothing shared with SvCalendar but the core. -->
<div class="mini">
<div class="mini__head">
<button class="mini__nav" {...cal.navProps(-1)}>‹</button>
<span class="mini__title">{cal.title}</span>
<button class="mini__nav" {...cal.navProps(1)}>›</button>
</div>
<div class="mini__grid" {...cal.gridProps()}>
{#each cal.weekdayHeaders as wh (wh.weekday)}
<span class="mini__wd">{wh.label}</span>
{/each}
{#each cal.panels as panel (panel.month.getTime())}
{#each panel.matrix as week, wi (wi)}
{#each week as cell (cell.date.getTime())}
{@const st = cal.dayState(cell.date, panel.month)}
<button
class="mini__day"
class:on={st.selected}
class:today={st.today}
class:out={st.outside}
{...cal.dayProps(cell, panel.month)}
>{cell.date.getDate()}</button>
{/each}
{/each}
{/each}
</div>
</div>
<p class="hint">Arrow keys move the focus ring, Enter/Space picks, PageUp/Down changes month - all from <code>createCalendar</code>.</p>
</section>
</div>
<aside class="readout">
<h3>Selected</h3>
{#if value}
<div class="tags"><span class="tag">{fmt.format(value)}</span></div>
{:else}
<p class="empty">Nothing selected</p>
{/if}
</aside>
</div>
<style>
.wrap { padding: 20px; max-width: 900px; display: flex; flex-direction: column; gap: 18px; }
header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.55; max-width: 680px; }
code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
.cols { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
/* Fully custom calendar render - nothing shared with SvCalendar but the core. */
.mini {
width: 252px; padding: 10px; border-radius: 12px;
border: 1px solid var(--sg-border, #e2e8f0); background: var(--sg-input-bg, #fff);
}
.mini__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.mini__title { font-weight: 650; font-size: 13px; }
.mini__nav {
width: 28px; height: 28px; border-radius: 8px; cursor: pointer; font-size: 16px; line-height: 1;
border: 1px solid var(--sg-border, #e2e8f0); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
}
.mini__nav:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
.mini__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; outline: none; }
.mini__wd { text-align: center; font-size: 10px; font-weight: 700; color: var(--sg-muted, #94a3b8); padding-bottom: 4px; text-transform: uppercase; }
.mini__day {
aspect-ratio: 1; display: grid; place-items: center; font-size: 12px; cursor: pointer;
border: 0; background: none; color: var(--sg-fg, #0f172a); border-radius: 7px;
}
.mini__day:hover:not(.on) { background: var(--sg-row-hover-bg, #f1f5f9); }
.mini__day.out { color: var(--sg-muted, #94a3b8); opacity: 0.5; }
.mini__day.today { box-shadow: inset 0 0 0 1px var(--sg-accent, #4f46e5); }
.mini__day.on { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); font-weight: 650; }
.mini__day:focus-visible { outline: 2px solid var(--sg-accent, #4f46e5); outline-offset: -2px; }
.hint { margin: 10px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); line-height: 1.5; }
.readout h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { padding: 3px 10px; border-radius: 6px; font-size: 13px; background: var(--sg-row-hover-bg, #f1f5f9); }
.empty { color: var(--sg-muted, #94a3b8); font-style: italic; margin: 0; }
/* Phone: the mini month is a 7-column grid whose weekday initials set a min-content
width wider than the card. Let the columns shrink; the day cells stay square. */
@media (max-width: 639px), (max-height: 500px) and (pointer: coarse) {
.mini__grid { grid-template-columns: repeat(7, minmax(0, 1fr)); }
.mini__wd { overflow: hidden; }
/* The day cells set aspect-ratio: 1 with no width, so they took their width
FROM their row height (40px) and overflowed their 32px column. Pin the width
to the column and let the ratio drive the height instead. */
.mini__day { width: 100%; }
}
</style>Related articles
- svelte-headless-table and the Svelte 5 upgrade: your three options - svelte-headless-table has not shipped since October 2024 and declares svelte@^4. Here are the three real paths off it - the maintained fork, TanStack Table v9, or a rendered grid - and how to tell which one is yours.
- What Is a Headless Data Grid? - Headless grids separate behavior from rendering - sorting, filtering, grouping, and pagination logic with no markup attached. Here is what that means in practice, why it matters, and when the tradeoff is worth it.
- Inside SvGrid: Server-Side Data and the Headless Core - How SvGrid separates UI state from row processing, and what that means for building grids driven entirely by a backend.
More Headless Editors examples
- Headless editors - Headless-first, like the grid: createListbox is the state machine behind SvListBox (roving focus, single/multi selection, keyboard, ARIA) exposed as prop-getters you spread onto YOUR own markup. One core drives both the styled SvListBox and a custom chip-cloud render, bound to one value.
- Combobox - headless - createCombobox drives the styled SvComboBox and a custom filter-input render, both bound to one value with a readout.
- Dropdown list - headless - createDropdownList drives SvDropDownList and a custom trigger+menu render, sharing one value.
- Autocomplete - headless - createAutocomplete drives SvAutoComplete and a custom suggestion-list render over one text value.
- Tags input - headless - createTagsInput drives SvTagsInput and a custom chip-cloud render, both editing one string array.