Actions and inputs
Week calendar
A scheduling week where you drag to create, move and resize events that snap to the time step, overlapping events move aside into columns while you hold, and the days slide in from the side you go to.
Live preview
A product team week with 6 events in two tones ("Standup" each morning, "Design review", "1:1 with Sam", "Launch prep") and one busy block from another calendar. Saving waits 500ms; moving "Launch prep" onto Friday fails once to show the shake and the notice. Replay resets the events and scripts: drag "Design review" down 30 minutes onto "1:1 with Sam" (both move aside), release, then go to the next week and back.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add week-calendarUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { type CalendarEvent, WeekCalendar } from './week-calendar';
const noSubscribe = () => () => {};
const readToday = () => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
};
const SAVE_MS = 500;
/** A product team week, Monday to Friday of the week that holds `today`. */
function teamWeek(today: Date): CalendarEvent[] {
const monday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - ((today.getDay() + 6) % 7),
);
const at = (day: number, hour: number, minute = 0) =>
new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + day, hour, minute);
const standups = [0, 1, 2, 3, 4].map((day) => ({
id: `standup-${day}`,
title: 'Standup',
start: at(day, 9),
end: at(day, 9, 30),
tone: 2 as const,
}));
return [
...standups,
{ id: 'review', title: 'Design review', start: at(1, 10), end: at(1, 11, 30), tone: 1 },
{ id: 'sam', title: '1:1 with Sam', start: at(1, 11, 30), end: at(1, 12, 30), tone: 2 },
{ id: 'launch', title: 'Launch prep', start: at(3, 12), end: at(3, 13, 30), tone: 1 },
{ id: 'busy', title: 'Busy', start: at(2, 10), end: at(2, 11), busy: true },
];
}
const wait = () => new Promise((resolve) => setTimeout(resolve, SAVE_MS));
/** Replay: drag Design review 30 minutes down onto 1:1 with Sam, let go, then go to the next week and back. */
function runScript(root: HTMLElement, later: (ms: number, run: () => void) => void) {
const review = () => root.querySelector<HTMLElement>('[data-event-id="review"]');
const send = (type: string, x: number, y: number) =>
review()?.dispatchEvent(
new PointerEvent(type, { bubbles: true, pointerId: 1, button: 0, clientX: x, clientY: y }),
);
const click = (name: string) => root.querySelector<HTMLElement>(`[aria-label="${name}"]`)?.click();
later(300, () => {
const rect = review()?.getBoundingClientRect();
if (!rect) return;
const x = rect.left + rect.width / 2;
const y = rect.top + 12;
send('pointerdown', x, y);
const steps = 10;
for (let i = 1; i <= steps; i++) later(i * 60, () => send('pointermove', x, y + (22 * i) / steps));
later(steps * 60 + 700, () => send('pointerup', x, y + 22));
});
later(3000, () => click('Next week'));
later(4200, () => click('Previous week'));
}
export default function WeekCalendarDemo({ replay = 0 }: { replay?: number }) {
const area = React.useRef<HTMLDivElement>(null);
// The clock is read on the client only, so a page built on another day hydrates without a mismatch.
const todayMs = React.useSyncExternalStore(noSubscribe, readToday, () => null);
const failed = React.useRef(false);
React.useEffect(() => {
if (replay === 0 || !area.current || todayMs === null) return;
const timers: number[] = [];
runScript(area.current, (ms, run) => timers.push(window.setTimeout(run, ms)));
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay, todayMs]);
const today = todayMs === null ? null : new Date(todayMs);
return (
<div ref={area} className="w-full">
{today && (
// Replay mounts the calendar again, so the week starts from the same events.
<WeekCalendar
key={replay}
label="Team calendar"
days={5}
startHour={9}
endHour={18}
defaultDate={today}
defaultEvents={teamWeek(today)}
onCreate={(event) => wait().then(() => ({ ...event, id: `saved-${event.id}` }))}
onDelete={() => wait()}
onUpdate={async (event) => {
await wait();
// Moving Launch prep onto Friday fails once, to show the notice.
if (event.id === 'launch' && event.start.getDay() === 5 && !failed.current) {
failed.current = true;
throw new Error('The server is busy.');
}
}}
/>
)}
</div>
);
}
API
Parts: root, header (range label text morph, Today button, previous and next buttons), notice line (fold, crossfading text), frame, day heads (weekday text morph, date number roll), scroller, hour labels, columns (focusable group, hour lines), keyboard slot outline, event block (title or name input, start and end clock, overlap line, resize edges), busy block (striped, not movable), outgoing week copy, leaving event copy, now line (dot + hairline), keyboard help (screen reader only, grid and event), status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| events | CalendarEvent[] | undefined | Controlled events: { id, title, start, end, tone?: 1 | 2 | 3 | 4 | 5, busy? }. Busy events are drawn striped and cannot move. |
| defaultEvents | CalendarEvent[] | [] | Starting events when uncontrolled. |
| date | Date | undefined | Controlled: any day in the range to show. |
| defaultDate | Date | today | Starting day when uncontrolled. |
| days | 1 | 3 | 5 | 7 | "auto" | "auto" | Days side by side; auto shows 7, 3 or 1 to fit the width; 5 is Monday to Friday. |
| weekStartsOn | 0 | 1 | 1 | Weeks start on Sunday (0) or Monday (1). |
| startHour | number | 7 | First hour shown. |
| endHour | number | 21 | Last hour shown. |
| step | number | 15 | Snap step in minutes. |
| defaultDuration | number | 30 | Length in minutes of an event made by a click, a touch hold or Enter. |
| hourHeight | number | 44 | Height of one hour in px. |
| height | number | 420 | Height of the scrolling hours in px. |
| locales | string | "en-US" | Locale for dates and times (12 or 24 hour follows it). |
| label | string | "Calendar" | Accessible name of the grid. |
| Event | Description |
|---|---|
| eventsChange | Fires with the full list after each create, move, resize or delete. |
| create | Fires with a new event once it has a name. Resolve with the stored event to take its id; reject to remove it again. |
| update | Fires with (event, previous) after a move or resize. Reject to put it back. |
| delete | Fires with the event after Delete or Backspace. Reject to bring it back. |
| dateChange | Fires with the first day of the new range. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| WC1 | hover Today, previous or next | header buttons | background-color, color | transparent, fg-quaternary → bg-primary_hover, fg-primary | duration-xs (160)ease-out | Swaps at once |
| WC2 | hover an event | bottom resize grip | opacity | 0 → 0.3 | duration-xs (160)ease-out | Shows at once |
| WC3 | Arrow keys on the columns | keyboard slot outline | top + left | previous slot → next slot | duration-sm (200)ease-out | Moves at once |
| WC4 | press and drag an empty slot (touch: hold 360ms first) | new event block | height (grows from the press point, snapped to step) | 1 step → pointer time | duration-sm (200)ease-out | Resizes at once |
| WC5 | pick up an event (Lifted) | event block | transform scale + background tint + ring | 1, tone 13%, ring 42% → 1.015, tone 20%, ring 85% | duration-sm (200)ease-out | Swaps at once |
| WC6 | drag a lifted event across a step or a day | event block | translate (additive glide from the old slot) | old slot → new slot | duration-sm (200)ease-out | Moves at once |
| WC7 | drag a lifted event, or move or resize with keys | start and end clock | number roll (hours and minutes), text morph (am or pm) | old time → new time | duration-4xl (520)ease-out | Swaps at once |
| WC8 | drag the top or bottom edge (resize) | event block | top + height | old time → new time | duration-sm (200)ease-out | Resizes at once |
| WC9 | an event lands on another while held, or leaves it | other event blocks | left + width (move aside into side-by-side columns) | full width → column width (each reaches 1.6 columns) | duration-md (260)ease-out | Resizes at once |
| WC10 | a held event overlaps another (tall events only) | overlap line ("Overlaps <title>") | grid-template-rows + opacity | 0fr, 0 → 1fr, 1 | duration-sm (200)ease-out | Shows at once |
| WC11 | release, or focus an event (Selected) | event block | top, left, height, width, scale, tint, ring (settle into the slot) | lifted → rest or selected (ring 85%, 1.5px) | duration-md (260)ease-out | Moves at once |
| WC12 | event saving (create or update in progress) | event block | mask-position sheen | 150% → -50%, repeats | duration-loop (1600)ease-linear | No loop |
| WC13 | save fails (move or resize) | event block | position back to the old slot (as WC11), then a translateX shake | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exitdelay 240ms | Stays still |
| WC14 | save of a new event fails | event block | translateX shake (as WC13), then it leaves (WC16) | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
| WC15 | a notice appears or ends (save failed) | notice line | grid-template-rows (fold) + text crossfade (new rises 0.35em out of blur-text, old lifts away) | 0fr → 1fr | duration-lg (320)ease-out | Resizes at once |
| WC16 | delete, discard a new event (Escape while naming), or failed create | leaving event copy | opacity + transform scale + filter blur | 1, 1, 0 → 0, scale-press, blur-text | duration-md (260)ease-out | Hides at once |
| WC17 | previous, next, Today, Page Up or Page Down | outgoing week copy | opacity + transform translateX + filter blur | 1, 0, 0 → 0, -distance-slide x direction, blur-text | duration-md (260)ease-out | Hides at once |
| WC18 | previous, next, Today, Page Up or Page Down | events layer | opacity + transform translateX + filter blur | 0, distance-slide x direction, blur-text → 1, 0, 0 | duration-3xl (460)ease-out | Shows at once |
| WC19 | range changes | day head dates | number roll (up when going forward, down when going back) | old date → new date | duration-5xl (640)ease-out | Swaps at once |
| WC20 | range changes | range label + weekday names | text morph | old text → new text | duration-3xl (460)ease-out | Swaps at once |
| WC21 | each minute | now line | top | previous minute → current minute | duration-6xl (900)ease-out | Moves at once |
Accessibility
- The columns are one focusable group named "Calendar, <range>"; Arrow keys move a slot outline by step and day, Enter adds an event there, Page Up and Page Down change the range, Tab moves to the events.
- Events are buttons (aria-pressed when selected) with a full label, for example "Design review, Tuesday, March 3, 2026 10:00 AM to 11:00 AM", plus "Overlaps <title>" while they overlap.
- On an event: Arrow Up and Down move it by one step, Shift with Arrow Up or Down changes its length, Arrow Left and Right move it a day, Enter renames, Delete or Backspace removes, Escape goes back to the columns.
- The name input of a new event has aria-label "Event name"; Enter saves, Escape discards.
- Two hidden help lines describe the keys for the grid and for events: "Use the arrow keys to pick a time and Enter to add an event. Page Up and Page Down change the week. Tab moves to the events." and "Use the arrow keys to move the event, Shift and Arrow Up or Down to change its length, Enter to rename it and Delete to remove it."
- A polite live region reads each result: "Added ...", "Moved to ...", "Deleted ...", "Could not save the change. Design review is back at its old time."
- Busy blocks have role note and an aria-label; they cannot be focused or moved.
- On touch, a hold of 360ms (with an 8ms vibration where allowed) picks up or creates, so a swipe still scrolls; the scroller scrolls by itself near its top and bottom edge while dragging.
- Header buttons are 32px (Button xs for Today, Utility button sm for previous and next); events are at least 20px tall but the column itself is the main target; resize edges are 6 to 8px and keys give the same control.
- Reduced motion: blocks jump to their slots, the week swaps without a slide, numbers swap, no sheen or shake.