Drag a bar to a new time or onto another lane (also called drag-and-drop rescheduling). A ghost shows where it would land and what that would cause; the drop reports the change for your application to apply.
import { useSchedule, applyIntent } from "@umriss-ui/schedule";
Each name in intents enables one gesture. Without intents the schedule is read-only, and pressing a bar pans the plot.
The schedule never changes your data (ADR-0023): onIntent reports a move and a lane change as values, and applyIntent is the arithmetic to apply them. A drop across lanes and time reports both, and your handler may apply them, refuse them, or apply one.
Held at the edge of the plot, a drag pans it along; Escape cancels the drag.
List move in intents and apply what onIntent reports with applyIntent; the plan changes only in your state.
import { useState } from "react";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";
const day = (d: number, hours = 9) => new Date(2026, 2, d, hours).getTime();
const PROJECTS: readonly Task[] = [{ id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" }];
const START: readonly Subtask[] = [
{ id: "w-102", task: "portal", lane: "arjun", from: day(17), to: day(18, 17), name: "Profile page" },
{ id: "w-105", task: "portal", lane: "chloe", from: day(18), to: day(19, 17), name: "Change of address form" },
];
export default function MoveInTime() {
const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(16, 0), day(21, 0)] } });
const [work, setWork] = useState(START);
return (
<Schedule
ariaLabel="Two people's work this week"
height={140}
intents={["move"]}
onIntent={(intent) => setWork((current) => current.map((item) => applyIntent(item, intent)))}
>
<Lane id="arjun" label="Arjun Mehta" />
<Lane id="chloe" label="Chloe Durand" />
<Subtasks data={work} tasks={PROJECTS} />
</Schedule>
);
}
Add lane to intents: a drop reports a move and a lane change separately, and the ghost shows the findings first.
import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { Dependency, Intent, Subtask, Task } from "@umriss-ui/schedule";
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const min = (n: number) => n * 60_000;
const VEHICLES = [
{ id: "truck-118", label: "Truck FP 118 R" },
{ id: "van-402", label: "Van FP 402 R" },
{ id: "truck-520", label: "Truck FP 520 E" },
{ id: "van-455", label: "E-van FP 455 R" },
{ id: "van-290", label: "Van FP 290 E" },
];
const CONSIGNMENTS: readonly Task[] = [
{ id: "c-2041", name: "C-2041 Holloway Garden Supplies", color: "light-dark(#2563eb, #6b9bff)" },
{ id: "c-2043", name: "C-2043 Oakridge Pharmacy", color: "light-dark(#c2410c, #f08a52)" },
{ id: "c-2045", name: "C-2045 Tamsin's Bakery", color: "light-dark(#be185d, #f06aa6)" },
{ id: "c-2046", name: "C-2046 Pellham Hardware", color: "light-dark(#4d7c0f, #8fc43e)" },
];
const LEGS: readonly Subtask[] = [
{ id: "c-2041-1", task: "c-2041", lane: "truck-118", from: at(6), to: at(7), leadIn: min(15), leadOut: min(10) },
{ id: "c-2043-1", task: "c-2043", lane: "truck-520", from: at(6, 30), to: at(8), leadIn: min(30), leadOut: min(15) },
{ id: "c-2043-2", task: "c-2043", lane: "van-402", from: at(10), to: at(11, 30), leadIn: min(15) },
{ id: "c-2046-2", task: "c-2046", lane: "van-402", from: at(13), to: at(14, 30), leadIn: min(20), leadOut: min(10) },
{ id: "c-2043-3", task: "c-2043", lane: "van-455", from: at(12), to: at(14), leadIn: min(20), leadOut: min(20) },
{ id: "c-2045-3", task: "c-2045", lane: "van-290", from: at(15, 45), to: at(16, 30) },
];
const TRANSFERS: readonly Dependency[] = [
{ id: "t-2043-1", from: "c-2043-1", to: "c-2043-2", lag: min(45) },
{ id: "t-2043-2", from: "c-2043-2", to: "c-2043-3", lag: min(25) },
];
export default function MoveToAnotherLane() {
const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(5, 30), at(18)] } });
const [legs, setLegs] = useState(LEGS);
const [last, setLast] = useState<Intent | null>(null);
return (
<Stack gap={3}>
<Schedule
ariaLabel="Five vehicles on Tuesday, 17 March, editable"
height={284}
intents={["move", "lane"]}
onIntent={(intent) => {
setLast(intent);
setLegs((current) => current.map((leg) => applyIntent(leg, intent)));
}}
>
{VEHICLES.map((vehicle) => (
<Lane key={vehicle.id} id={vehicle.id} label={vehicle.label} />
))}
<Dependencies data={TRANSFERS} />
<Subtasks data={legs} tasks={CONSIGNMENTS} />
</Schedule>
<Text size="sm" mono tone="secondary" data-last-intent>
{last === null ? "No intent yet" : JSON.stringify(last)}
</Text>
</Stack>
);
}
Your onIntent decides: here it applies a drop only when overlaps finds no new double booking, and says why otherwise.
import { useRef, useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { applyIntent, overlaps, useSchedule } from "@umriss-ui/schedule";
import type { Intent, Subtask } from "@umriss-ui/schedule";
/* Data from the planning world, written out here so the example runs on its own. */
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
interface Person {
id: string;
name: string;
role: "Developer" | "Designer" | "Product manager" | "QA engineer";
team: "Web" | "Apps";
/** Hours a week they can be planned for. */
capacity: number;
}
const PEOPLE: readonly Person[] = [
{ id: "maya", name: "Maya Lindgren", role: "Product manager", team: "Web", capacity: 32 },
{ id: "arjun", name: "Arjun Mehta", role: "Developer", team: "Web", capacity: 40 },
{ id: "chloe", name: "Chloe Durand", role: "Developer", team: "Web", capacity: 40 },
{ id: "noah", name: "Noah Fischer", role: "Designer", team: "Web", capacity: 24 },
{ id: "eva", name: "Eva Novak", role: "QA engineer", team: "Web", capacity: 40 },
{ id: "luis", name: "Luis Moreno", role: "Product manager", team: "Apps", capacity: 40 },
{ id: "hana", name: "Hana Sato", role: "Developer", team: "Apps", capacity: 40 },
{ id: "kofi", name: "Kofi Mensah", role: "Developer", team: "Apps", capacity: 32 },
{ id: "freya", name: "Freya Olsen", role: "Designer", team: "Apps", capacity: 40 },
{ id: "david", name: "David Kowalski", role: "QA engineer", team: "Apps", capacity: 20 },
];
interface Project {
id: string;
name: string;
client: string;
color: string;
}
const PROJECTS: readonly Project[] = [
{ id: "shop", name: "Online shop relaunch", client: "Pembury Home", color: "light-dark(#2563eb, #6b9bff)" },
{ id: "booking", name: "Booking app", client: "Saltmarsh Clinics", color: "light-dark(#0d9488, #3cc7b8)" },
{ id: "portal", name: "Member portal", client: "Rowan Credit Union", color: "light-dark(#7c3aed, #a98bfa)" },
{ id: "intranet", name: "Intranet", client: "Tidewell (internal)", color: "light-dark(#c2410c, #f08a52)" },
];
/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
id: string;
/** The project. */
task: string;
/** The person. */
lane: string;
from: number;
to: number;
name: string;
sprint: string;
/** Hours, as estimated at planning. */
estimate: number;
status: "to do" | "in progress" | "in review" | "done";
}
const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });
const WORK: readonly WorkItem[] = [
item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];
const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();
const WEB = PEOPLE.filter((person) => person.team === "Web" && WORK.some((item) => item.lane === person.id));
const START: readonly Subtask[] = WORK.filter((item) => WEB.some((person) => person.id === item.lane));
export default function RefuseADoubleBooking() {
const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [day(9), day(21)] } });
const [work, setWork] = useState<readonly Subtask[]>(START);
/* A drop onto another lane at another time reports two intents in one
tick; each is decided on the plan the one before it left. */
const plan = useRef(work);
const [said, setSaid] = useState("Drag a work item onto someone who is busy");
const onIntent = (intent: Intent) => {
if (intent.kind === "place") return;
const next = plan.current.map((item) => applyIntent(item, intent));
const moved = intent.subtask;
const clash = overlaps(next).find((o) => o.first === moved || o.second === moved);
if (clash !== undefined) {
setSaid(`Not applied: ${moved} would share ${clash.lane}'s time with ${clash.first === moved ? clash.second : clash.first}.`);
return;
}
plan.current = next;
setWork(next);
setSaid(`Applied: ${intent.kind} ${moved}.`);
};
return (
<Stack gap={3}>
<Schedule
ariaLabel="Sprint 14 of the web team, editable"
height={240}
intents={["move", "lane"]}
onIntent={onIntent}
>
{WEB.map((person) => (
<Lane key={person.id} id={person.id} label={person.name} />
))}
<Subtasks data={work} tasks={PROJECTS} />
</Schedule>
<Text size="sm" tone="secondary" data-decision>
{said}
</Text>
</Stack>
);
}
| Key | Action |
|---|---|
Alt+← Alt+→ |
Proposes moving the active subtask one raster step earlier or later, stepping over blocked time. |
Escape |
Cancels a drag in flight: the ghost goes and nothing is reported. |
The keys of First schedule apply here.
From ScheduleProps — the full table stands on First schedule.
| Name | Type | Default | Description |
|---|---|---|---|
height | number | 400 | Height in CSS pixels, both bands included. The width is the host's. |
intents | readonly IntentKind[] | [] | The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023). |
children | ReactNode | — | Lane, Dependencies and Subtasks, in the order they are to stand and
be drawn. |
| Name | Type | Default | Description |
|---|---|---|---|
onIntent | (intent: Intent) => void | — | Called once per intent when a drag ends. The data changes only if the caller changes it. |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabelrequired | string | — | What the schedule shows, for a screen reader: "Plan of week 38". |
From LaneProps — the full table stands on Lanes.
From SubtasksProps — the full table stands on Subtasks.
From Subtask — the full table stands on Subtasks.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | The caller's identity of the subtask. |
taskrequired | string | — | The id of the task it belongs to. |
lanerequired | string | — | The id of the lane it sits on. |
fromrequired | number | — | Start of the main time. |
torequired | number | — | End of the main time. |
leadIn | number | — | Duration of the lead-in before the main time; none is zero. |
leadOut | number | — | Duration of the lead-out after the main time; none is zero. |
name | string | — | What the subtask is called, where the schedule names it. |
From Task — the full table stands on Subtasks.
From DependenciesProps — the full table stands on Dependencies.
| Name | Type | Default | Description |
|---|---|---|---|
datarequired | readonly Dependency[] | — | The dependencies, as the caller holds them. |
From Dependency — the full table stands on Dependencies.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | The caller's identity of the dependency. |
fromrequired | string | — | The id of the subtask it leaves. |
torequired | string | — | The id of the subtask it arrives at. |
lagrequired | number | — | The least time between the two anchors; zero where the successor may begin as soon as the predecessor ends. |
From ScheduleOptions — the full table stands on View.
| Name | Type | Default | Description |
|---|---|---|---|
initialView | 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.
| Name | Type | Default | Description |
|---|---|---|---|
domain | readonly [number, number] | — | The span in view, as two wall-clock instants. Absent, the schedule shows
the extent of its subtasks, within zoomLimits. |
From Overlap — the full table stands on Findings as data.
What umriss deliberately does not build, and why: ADR-0032.