Snapping

Makes a drag land on a raster - the axis' own step, a fixed duration, or a pattern with an offset - so dropped work starts on a time a planner can read.

import { useSchedule, snapTime } from "@umriss-ui/schedule";

Without snap a drag lands on the fine band's step, which follows the zoom. A number is a step in milliseconds; { step, offset } counts from local midnight; false places to the minute.

Snapping shapes the ghost and so the intent; it never moves data that is not being dragged. snapTime is the same arithmetic for your own use.

Examples

Snap to the ticks

Without snap, a drag lands on the fine band's step: quarter hours when zoomed in, whole hours when zoomed out.

import { useState } from "react";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();

const TOURS: readonly Task[] = [{ id: "river", name: "Riverside depot tours", color: "light-dark(#2563eb, #6b9bff)" }];

const START: readonly Subtask[] = [{ id: "T-04", task: "river", lane: "van-402", from: at(7, 30), to: at(12, 10) }];

export default function SnapToTheTicks() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(16)] } });
  const [tours, setTours] = useState(START);
  return (
    <Schedule
      ariaLabel="A tour on the default raster"
      height={100}
      intents={["move"]}
      onIntent={(intent) => setTours((current) => current.map((tour) => applyIntent(tour, intent)))}
    >
      <Lane id="van-402" label="Van FP 402 R" />
      <Subtasks data={tours} tasks={TOURS} />
    </Schedule>
  );
}

Snap to a duration

Give snap a number of milliseconds - here half a day - and every drag lands on that raster, whatever the zoom.

import { useState } from "react";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";

const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
const HALF_DAY = 12 * 60 * 60_000;

const PROJECTS: readonly Task[] = [{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" }];

const START: readonly Subtask[] = [
  { id: "w-104", task: "shop", lane: "chloe", from: day(17), to: day(18, 12), name: "Basket keeps items across devices" },
  { id: "w-107", task: "shop", lane: "noah", from: day(18, 12), to: day(20), name: "Search results layout" },
];

export default function SnapToADuration() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16), day(21)] } });
  const [work, setWork] = useState(START);
  return (
    <Schedule
      ariaLabel="Work planned in half days"
      height={140}
      snap={HALF_DAY}
      intents={["move", "stretch"]}
      onIntent={(intent) => setWork((current) => current.map((item) => applyIntent(item, intent)))}
    >
      <Lane id="chloe" label="Chloe Durand" />
      <Lane id="noah" label="Noah Fischer" />
      <Subtasks data={work} tasks={PROJECTS} />
    </Schedule>
  );
}

Snap to handover times

A snap raster with a step and an offset: eight-hour watches handed over at 06:00, 14:00 and 22:00 local time.

import { useState } from "react";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { SnapRaster, Subtask, Task } from "@umriss-ui/schedule";

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const HOUR = 3_600_000;

/* Offset from local midnight, whatever the time zone. */
const HANDOVERS: SnapRaster = { step: 8 * HOUR, offset: 6 * HOUR };

const ENGINEERS: readonly Task[] = [{ id: "ada", name: "Ada Mwangi", color: "light-dark(#7c3aed, #a98bfa)" }];

const START: readonly Subtask[] = [{ id: "watch-1", task: "ada", lane: "primary", from: at(6), to: at(14) }];

export default function SnapToHandovers() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(4), at(24)] } });
  const [watches, setWatches] = useState(START);
  return (
    <Schedule
      ariaLabel="An on-call watch on the handover raster"
      height={110}
      snap={HANDOVERS}
      intents={["move"]}
      onIntent={(intent) => setWatches((current) => current.map((watch) => applyIntent(watch, intent)))}
    >
      <Lane id="primary" label="Primary" />
      <Subtasks data={watches} tasks={ENGINEERS} />
    </Schedule>
  );
}

Place without snapping

snap={false} lets a drag land on any minute - for times that follow the pointer rather than a raster.

import { useState } from "react";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";

const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();

const ENGINEERS: readonly Task[] = [{ id: "sam", name: "Sam Okafor", color: "light-dark(#0d9488, #3cc7b8)" }];

const START: readonly Subtask[] = [{ id: "window-1", task: "sam", lane: "notifications", from: at(13, 5), to: at(13, 50), name: "Maintenance window" }];

export default function NoSnapping() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(10), at(18)] } });
  const [windows, setWindows] = useState(START);
  return (
    <Schedule
      ariaLabel="A maintenance window placed freely"
      height={100}
      snap={false}
      intents={["move"]}
      onIntent={(intent) => setWindows((current) => current.map((one) => applyIntent(one, intent)))}
    >
      <Lane id="notifications" label="Notifications" />
      <Subtasks data={windows} tasks={ENGINEERS} />
    </Schedule>
  );
}

Keyboard

The keys of First schedule apply here.

Props on this page

From ScheduleProps — the full table stands on First schedule.

NameTypeDefaultDescription
heightnumber400Height in CSS pixels, both bands included. The width is the host's.
snap| "ticks"
| number
| SnapRaster
| false
"ticks"The raster a drag lands on: "ticks" - the fine band's current step -, a step in milliseconds from local midnight, a step with an offset (shifts at 06:00, 14:00 and 22:00 are eight hours offset by six), or false for none. It shapes the ghost and the intent, never the data.
intentsreadonly IntentKind[][]The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023).
childrenReactNode—Lane, Dependencies and Subtasks, in the order they are to stand and be drawn.

Events

NameTypeDefaultDescription
onIntent(intent: Intent) => void—Called once per intent when a drag ends. The data changes only if the caller changes it.

Accessibility

NameTypeDefaultDescription
ariaLabelrequiredstring—What the schedule shows, for a screen reader: "Plan of week 38".

From LaneProps — the full table stands on Lanes.

NameTypeDefaultDescription
idrequiredstring—The identity subtasks name in their lane.
labelReactNodeidWhat the lane header says - the person, vehicle or room. Real text, read by a screen reader.

From SubtasksProps — the full table stands on Subtasks.

NameTypeDefaultDescription
datarequiredreadonly Subtask[]—The subtasks, as the caller holds them. The schedule never changes one.
tasksrequiredreadonly Task[]—The tasks the subtasks belong to - their identity and their colour. A subtask whose task is not among them is drawn in a muted colour.

From Subtask — the full table stands on Subtasks.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the subtask.
taskrequiredstring—The id of the task it belongs to.
lanerequiredstring—The id of the lane it sits on.
fromrequirednumber—Start of the main time.
torequirednumber—End of the main time.
namestring—What the subtask is called, where the schedule names it.

From Task — the full table stands on Subtasks.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the task.
colorrequiredstring—Any CSS colour - a literal, a token, light-dark(…). The colour means what the caller's domain needs it to mean.
namestring—What the task is called, where the schedule names it.

From ScheduleOptions — the full table stands on View.

NameTypeDefaultDescription
initialViewScheduleView—The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed defaultValue. To go to a view later, call setView. The schedule remembers none: where a view is kept is the application's decision.

From ScheduleView — the full table stands on View.

NameTypeDefaultDescription
domainreadonly [number, number]—The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within zoomLimits.

Every page of @umriss-ui/schedule

Getting started

Plan

Time

Reading

Editing

Measuring

API index