Lunia DS

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.

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

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
eventsCalendarEvent[]undefinedControlled events: { id, title, start, end, tone?: 1 | 2 | 3 | 4 | 5, busy? }. Busy events are drawn striped and cannot move.
defaultEventsCalendarEvent[][]Starting events when uncontrolled.
dateDateundefinedControlled: any day in the range to show.
defaultDateDatetodayStarting day when uncontrolled.
days1 | 3 | 5 | 7 | "auto""auto"Days side by side; auto shows 7, 3 or 1 to fit the width; 5 is Monday to Friday.
weekStartsOn0 | 11Weeks start on Sunday (0) or Monday (1).
startHournumber7First hour shown.
endHournumber21Last hour shown.
stepnumber15Snap step in minutes.
defaultDurationnumber30Length in minutes of an event made by a click, a touch hold or Enter.
hourHeightnumber44Height of one hour in px.
heightnumber420Height of the scrolling hours in px.
localesstring"en-US"Locale for dates and times (12 or 24 hour follows it).
labelstring"Calendar"Accessible name of the grid.
EventDescription
eventsChangeFires with the full list after each create, move, resize or delete.
createFires with a new event once it has a name. Resolve with the stored event to take its id; reject to remove it again.
updateFires with (event, previous) after a move or resize. Reject to put it back.
deleteFires with the event after Delete or Backspace. Reject to bring it back.
dateChangeFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
WC1hover Today, previous or nextheader buttonsbackground-color, colortransparent, fg-quaternary → bg-primary_hover, fg-primaryduration-xs (160)ease-outSwaps at once
WC2hover an eventbottom resize gripopacity0 → 0.3duration-xs (160)ease-outShows at once
WC3Arrow keys on the columnskeyboard slot outlinetop + leftprevious slot → next slotduration-sm (200)ease-outMoves at once
WC4press and drag an empty slot (touch: hold 360ms first)new event blockheight (grows from the press point, snapped to step)1 step → pointer timeduration-sm (200)ease-outResizes at once
WC5pick up an event (Lifted)event blocktransform scale + background tint + ring1, tone 13%, ring 42% → 1.015, tone 20%, ring 85%duration-sm (200)ease-outSwaps at once
WC6drag a lifted event across a step or a dayevent blocktranslate (additive glide from the old slot)old slot → new slotduration-sm (200)ease-outMoves at once
WC7drag a lifted event, or move or resize with keysstart and end clocknumber roll (hours and minutes), text morph (am or pm)old time → new timeduration-4xl (520)ease-outSwaps at once
WC8drag the top or bottom edge (resize)event blocktop + heightold time → new timeduration-sm (200)ease-outResizes at once
WC9an event lands on another while held, or leaves itother event blocksleft + width (move aside into side-by-side columns)full width → column width (each reaches 1.6 columns)duration-md (260)ease-outResizes at once
WC10a held event overlaps another (tall events only)overlap line ("Overlaps <title>")grid-template-rows + opacity0fr, 0 → 1fr, 1duration-sm (200)ease-outShows at once
WC11release, or focus an event (Selected)event blocktop, left, height, width, scale, tint, ring (settle into the slot)lifted → rest or selected (ring 85%, 1.5px)duration-md (260)ease-outMoves at once
WC12event saving (create or update in progress)event blockmask-position sheen150% → -50%, repeatsduration-loop (1600)ease-linearNo loop
WC13save fails (move or resize)event blockposition back to the old slot (as WC11), then a translateX shake0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitdelay 240msStays still
WC14save of a new event failsevent blocktranslateX shake (as WC13), then it leaves (WC16)0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitStays still
WC15a notice appears or ends (save failed)notice linegrid-template-rows (fold) + text crossfade (new rises 0.35em out of blur-text, old lifts away)0fr → 1frduration-lg (320)ease-outResizes at once
WC16delete, discard a new event (Escape while naming), or failed createleaving event copyopacity + transform scale + filter blur1, 1, 0 → 0, scale-press, blur-textduration-md (260)ease-outHides at once
WC17previous, next, Today, Page Up or Page Downoutgoing week copyopacity + transform translateX + filter blur1, 0, 0 → 0, -distance-slide x direction, blur-textduration-md (260)ease-outHides at once
WC18previous, next, Today, Page Up or Page Downevents layeropacity + transform translateX + filter blur0, distance-slide x direction, blur-text → 1, 0, 0duration-3xl (460)ease-outShows at once
WC19range changesday head datesnumber roll (up when going forward, down when going back)old date → new dateduration-5xl (640)ease-outSwaps at once
WC20range changesrange label + weekday namestext morphold text → new textduration-3xl (460)ease-outSwaps at once
WC21each minutenow linetopprevious minute → current minuteduration-6xl (900)ease-outMoves 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.