# Snapping

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/snapping/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms-full.txt).

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.

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

Demo page: https://romanhaendler.github.io/umriss-ui/schedule/snapping/

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`](https://romanhaendler.github.io/umriss-ui/schedule/api/#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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#keyboard-schedule) apply here.

## Props on this page

From `ScheduleProps` — the full table stands on [First schedule](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-ScheduleProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`height`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. |
| [`snap`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-snap) | `"ticks" \| number \| `[`SnapRaster`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-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. |
| [`intents`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-intents) | `readonly `[`IntentKind`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-IntentKind)`[]` | `[]` | The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule ([ADR-0023](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0023-editing-is-controlled-the-schedule-reports-intents.md)). |
| [`children`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| [`onIntent`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-onIntent) | `(intent: `[`Intent`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-Intent)`) => void` | — | Called once per intent when a drag ends. The data changes only if the caller changes it. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-ariaLabel) *required* | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38". |

From `LaneProps` — the full table stands on [Lanes](https://romanhaendler.github.io/umriss-ui/schedule/lane/#type-LaneProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. |
| [`label`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-label) | `ReactNode` | `id` | What the lane header says - the person, vehicle or room. Real text, read by a screen reader. |

From `SubtasksProps` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-SubtasksProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-data) *required* | `readonly `[`Subtask`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. |
| [`tasks`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-tasks) *required* | `readonly `[`Task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-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](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-id) *required* | `string` | — | The caller's identity of the subtask. |
| [`task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-task) *required* | `string` | — | The id of the task it belongs to. |
| [`lane`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-lane) *required* | `string` | — | The id of the lane it sits on. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-from) *required* | `number` | — | Start of the main time. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-to) *required* | `number` | — | End of the main time. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. |

From `Task` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Task).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-id) *required* | `string` | — | The caller's identity of the task. |
| [`color`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-color) *required* | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-name) | `string` | — | What the task is called, where the schedule names it. |

From `ScheduleOptions` — the full table stands on [View](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`initialView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleOptions-initialView) | [`ScheduleView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView) | — | 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](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`domain`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleView-domain) | `readonly [number, number]` | — | The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within `zoomLimits`. |
