Move and lane

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.

Examples

Move work in time

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>
  );
}

Move work onto another lane

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>
  );
}

Refuse a move that double-books someone

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>
  );
}

When to use something else

Keyboard

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.

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.
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.
leadInnumber—Duration of the lead-in before the main time; none is zero.
leadOutnumber—Duration of the lead-out after the main time; none is zero.
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 DependenciesProps — the full table stands on Dependencies.

NameTypeDefaultDescription
datarequiredreadonly Dependency[]—The dependencies, as the caller holds them.

From Dependency — the full table stands on Dependencies.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the dependency.
fromrequiredstring—The id of the subtask it leaves.
torequiredstring—The id of the subtask it arrives at.
lagrequirednumber—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.

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.

From Overlap — the full table stands on Findings as data.

NameTypeDefaultDescription
lanerequiredstring—The lane both sit on.
firstrequiredstring—The subtask that starts earlier (lead-in included); on a tie, the one that stands first in the data.
secondrequiredstring—The other one.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/schedule

Getting started

Plan

Time

Reading

Editing

Measuring

API index