how-to
How to Show a Data Grid as a Calendar
Last updated 1 October 2026
The same rows a grid holds can also show as a calendar: a month grid, a week grid, or an hour-axis week and day view for anything with a time as well as a date. Bind it to a grid you already built and it becomes another live view of those rows, not a copy: drag a card to reschedule it and the grid's own cells change with it, because the move is written back through the grid's own edit path.
Below, a week of a small team's schedule sits in a grid with Start and End columns, and the calendar underneath is bound to that same grid. Switch between the hour-axis week view and the month grid, and drag a card to another day or slot; a client call refuses to move outside business hours.
Bind it to a grid, a router feed, or nothing at all
createCalendar(element, config) takes its rows from grid (a live
Lattice grid, following its filtered and sorted rows) or a plain rows array. The
date field is startProperty (default 'start'); give it an
endProperty and a point event becomes a span, drawn as one bar across every
day it covers.
import { createGrid } from '@toclocoinc/lattice-grid';
import { createCalendar } from '@toclocoinc/lattice-grid/modules/calendar';
const grid = createGrid(document.querySelector('#grid'), {
rowKey: 'id',
edit: true,
columns: [
{ field: 'title', title: 'Title', edit: true },
{ field: 'owner', title: 'Owner', edit: true },
{ field: 'start', title: 'Start', type: 'datetime', edit: { enabled: true, editor: 'datetime' } },
{ field: 'end', title: 'End', type: 'datetime', edit: { enabled: true, editor: 'datetime' } },
],
rows,
});
// The calendar follows the grid's own rows: filter or sort the grid and the
// calendar narrows with it. A drag on the calendar writes back through
// grid.edit.setCells, so it is validated, undoable and kept in history.
const calendar = createCalendar(document.querySelector('#calendar'), {
grid,
startProperty: 'start',
endProperty: 'end',
view: 'week',
});
A calendar consumes data through the same keyed-diff contract a grid exposes
(rows.apply({ add, update, remove }), plus rows.get(key)), so a
Data Router feed can drive a calendar beside a grid and a chart from one source:
// A Data Router feed drives a calendar the same way it drives a grid or a
// chart: attach it once, and every add/update/remove the source produces
// reaches the calendar as a keyed diff.
const router = createDataRouter({ source: bookingsFeed, rowKey: 'id' });
router.attach(calendar, 'team'); // route the 'team' slice of the feed here
router.attach(teamGrid, 'team'); // ...and the same slice reaches a grid too
The grid it follows does not need to render anything either. A calendar bound to a headless grid still tracks a live filter or sort, useful wherever the calendar is the only thing on screen:
// A calendar can follow a grid that never renders: createHeadlessGrid runs
// the same sort, filter and row model with no DOM, so a calendar bound to it
// still reflects a live filter or sort with nothing painted underneath.
const headless = LatticeGrid.createHeadlessGrid({ rowKey: 'id', rows });
headless.filters.quick('northwind');
const calendar = createCalendar(document.querySelector('#calendar'), {
grid: headless,
startProperty: 'start',
});
The card face
card maps fields onto the default card template (a title and a subtitle line,
shown with the event's time); renderCard(row, el) takes over the element
entirely, and stays mounted across updates and navigation so an async fill (an
avatar, a status fetched later) survives a re-render.
const calendar = createCalendar(el, {
rows,
card: { title: 'title', subtitle: 'owner' }, // field-mapped, no renderCard needed
// Or take over the card element entirely. It stays mounted across
// updates and navigation, so an async image swap into the same el survives.
renderCard(row, el) {
el.textContent = '';
const time = document.createElement('strong');
time.textContent = row.title;
el.append(time);
},
});
The hour-axis week and day view
view: 'week-time' or view: 'day' add a vertical hour axis: a row with
a start time draws as a block positioned at its start and sized by its end,
overlapping blocks split the column side by side, and an all-day row stacks
date-only rows above the axis. businessHours shades the working span and a
now line marks the current time in today's column.
const calendar = createCalendar(el, {
grid,
startProperty: 'start',
endProperty: 'end',
allDayProperty: 'allDay',
view: 'week-time', // or 'day' for a single column
slotDuration: 30, // minutes per row on the hour axis
slotMinTime: '08:00',
slotMaxTime: '18:00',
scrollTime: '08:30', // where the axis opens, scrolled into view
businessHours: { startTime: '09:00', endTime: '17:00', daysOfWeek: [1, 2, 3, 4, 5] },
weekNumbers: true,
});
// Overlapping timed rows split into side-by-side columns automatically; a
// row with allDayProperty true (or a date-only start) stacks in the all-day
// row above the axis instead of taking a slot.
Drag to reschedule, reviewed before it commits
Dragging a card to another day or slot moves it; dragging the bottom edge of a
timed block resizes it, changing only its end. Every move fires the cancellable
beforeMove event first, synchronously or by promise, and card:move on
success with the old and new values:
// Every move is reviewed before it commits. Return nothing (or resolve) to
// allow it; call preventDefault with a reason to veto it, synchronously or
// by promise.
calendar.on('beforeMove', (e) => {
const hour = e.to.start ? e.to.start.getHours() : 0;
if (e.row.kind === 'external' && (hour < 9 || hour >= 17)) {
e.preventDefault('client calls stay inside business hours');
}
});
calendar.on('card:move', (e) => {
console.log(`moved ${e.key} from ${e.from.start} to ${e.to.start}`);
});
// A grid-bound move is written through grid.edit.setCells: a refused write
// (a validator, a read-only column) snaps the card back with the grid's own
// message beside it. A standalone calendar calls a host onCardMove, and
// returning false (or rejecting) reverts the move the same way.
React, Vue and Angular
The calendar is available everywhere a grid is: createLatticeCalendar in
React and Vue (live rows, view and date props, grid-bound through
context by default), LatticeCalendarComponent (<lattice-calendar>) in
Angular, <LatticeCalendar> in Svelte, and <lattice-calendar> as a plain
web component.
const { LatticeCalendar } = createLatticeReact({ React, createCalendar });
<LatticeCalendar
grid={grid}
startProperty="start"
endProperty="end"
view="week-time"
onCardMove={(e) => console.log('moved', e.key)}
/>;
const { LatticeCalendar } = createLatticeVue({ vue, createCalendar });
// <LatticeCalendar :grid="grid" start-property="start" view="week" @card-move="onMove" />
// LatticeCalendarComponent, selector <lattice-calendar>
// <lattice-calendar [grid]="grid" [view]="'week'" (card-move)="onMove($event)"></lattice-calendar>
On an htmx page, a calendar element wires itself to its grid with no setup code, and keeps working after a swap:
<!-- Auto-initialised by modules/htmx: no setup code, and re-bound to the
grid automatically after a swap. -->
<table data-lattice-grid id="bookings">…</table>
<div data-lattice-calendar data-lattice-bind="bookings"></div>
Coming from FullCalendar
The names differ where the shape is genuinely different; everything else keeps FullCalendar's own vocabulary.
| FullCalendar | Lattice Grid |
|---|---|
initialView: 'dayGridWeek' | 'dayGridMonth' | view: 'week' | 'month' |
timeGridWeek / timeGridDay | view: 'week-time' | 'day' |
headerToolbar: { left, center, right } | header: { navigation, title, views } |
events (array or fetch) | rows array, or { grid } to bind a live grid |
eventContent | renderCard(row, el), or a card field template |
eventClick | onCardClick / card:click |
eventDidMount | card:mount |
addEvent({ id, title, start, extendedProps }) | rows.apply({ add: [row] }) |
getEventById(id) | rows.get(key) |
event.remove() | rows.apply({ remove: [key] }) |
view.activeStart / activeEnd | getRange(), or range:change |
datesSet | onRangeChange / range:change |
editable + eventDrop / eventResize | card:move, written back through grid.edit.setCells |
eventAllow | beforeMove |
firstDay, locale, timeZone, dayMaxEvents, eventOrder | same names, same meaning |
Left out on purpose: create-on-click (dateClick / select), the agenda
list view, background events, recurrence and a resource timeline are not part of
the calendar yet.
See the calendar API reference for every option, method and event, or the Data Router for driving a calendar, a grid, a board and a chart from one feed.