Where a subtask may go

Marks the lanes and times a piece of work may not go to as soon as a drag begins, and holds the ghost where it is allowed. Reach for it when rules of your domain - equipment, skills, leave - limit where work can stand.

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

canMoveTo(subtask, lane) is asked once per lane when a drag takes hold, and again at the drop. Refused lanes are drawn back and hatched; over one, the ghost stays on the last lane allowed, tied to the pointer by a line.

Blocked time refuses a place in time rather than a lane: a drag cannot put work into it, and Alt with an arrow key steps over it. Work the data already put there is never locked in.

A refusal costs only what it refuses - a drop across a closed lane still reports the move in time - and wears no warning colour, because a rule is nobody's mistake.

Examples

Keep work on the lanes that fit

canMoveTo names the lanes a subtask may go to; the others are hatched as the drag begins, and a drop there keeps only the move in time.

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

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

/* A plate is one word: a lane header that takes two lines must not tear it
   apart ("FP 214" / "K"). */
const plate = (text: string) => text.replaceAll(" ", "\u00a0");

const GOODS: Task[] = [
  { id: "dry", name: "Dry goods", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "chilled", name: "Chilled goods", color: "light-dark(#c2410c, #f08a52)" },
];

const COOLED = ["cool-1", "cool-2"];

const START: Subtask[] = [
  { id: "parcels", task: "dry", lane: "cool-1", from: at(7), to: at(9) },
  { id: "vaccines", task: "chilled", lane: "cool-2", from: at(9, 30), to: at(11, 30) },
  { id: "books", task: "dry", lane: "dry", from: at(12), to: at(14) },
];

/* Chilled goods go on a cooled van; dry goods go anywhere. */
const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "chilled" || COOLED.includes(lane);

export default function LanesThatFit() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(15)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  const [last, setLast] = useState("Drag the vaccines onto the dry van");

  /* Only `move` and `lane` are enabled, but the type is the whole union, and
     a `place` names no subtask. */
  const onIntent = (intent: Intent) => {
    if (intent.kind === "place") return;
    setWork((current) => current.map((s) => applyIntent(s, intent)));
    setLast(`${intent.subtask}: ${intent.kind}`);
  };

  return (
    <Stack gap={3}>
      <Schedule
        ariaLabel="Two cooled vans and a dry van"
        height={190}
        intents={["move", "lane"]}
        canMoveTo={mayGo}
        onIntent={onIntent}
      >
        <Lane id="cool-1" label={`Cooled van ${plate("FP 377 K")}`} />
        <Lane id="cool-2" label={`Cooled van ${plate("FP 455 R")}`} />
        <Lane id="dry" label={`Van ${plate("FP 214 K")}`} />
        <Subtasks data={work} tasks={GOODS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-move>
        {last}
      </Text>
    </Stack>
  );
}

Apply the same rule to work dragged in

canMoveTo is asked about work in placing too; the ghost holds on the last lane that allowed it, and a drop lands there.

import { useState } from "react";
import { Card, Stack, Text } from "@umriss-ui/core";
import { subtaskFromPlace, useSchedule } from "@umriss-ui/schedule";
import type { Intent, PlacingItem, Subtask, Task } from "@umriss-ui/schedule";

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

/* A plate is one word: a lane header that takes two lines must not tear it
   apart ("FP 214" / "K"). */
const plate = (text: string) => text.replaceAll(" ", "\u00a0");

const GOODS: Task[] = [
  { id: "dry", name: "Dry goods", color: "light-dark(#2563eb, #6b9bff)" },
  { id: "chilled", name: "Chilled goods", color: "light-dark(#c2410c, #f08a52)" },
];

const COOLED = ["cool-1", "cool-2"];

const START: Subtask[] = [
  { id: "parcels", task: "dry", lane: "cool-1", from: at(7), to: at(9) },
  { id: "books", task: "dry", lane: "dry", from: at(12), to: at(14) },
];

const mayGo = (subtask: Subtask, lane: string) => subtask.task !== "chilled" || COOLED.includes(lane);

const WAITING: PlacingItem & { label: string } = {
  item: "insulin",
  label: "Oakridge Pharmacy, chilled · 2 h",
  task: "chilled",
  duration: 2 * 60 * 60_000,
};

export default function DraggedIn() {
  const { Schedule, Lane, Subtasks } = useSchedule({ initialView: { domain: [at(6, 30), at(15)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  const [placing, setPlacing] = useState<PlacingItem | null>(null);
  const [last, setLast] = useState("Drag the chilled delivery onto the dry van");

  const onIntent = (intent: Intent) => {
    if (intent.kind !== "place") return;
    setWork((current) => [...current, subtaskFromPlace(intent, `${intent.item}-${current.length}`)]);
    setLast(`${intent.item}: place on ${intent.lane}`);
  };

  return (
    <Stack gap={3}>
      <Card>
        <div
          draggable
          data-waiting={WAITING.item}
          onDragStart={(event) => {
            event.dataTransfer.setData("text/plain", WAITING.item);
            event.dataTransfer.effectAllowed = "copy";
            setPlacing(WAITING);
          }}
          onDragEnd={() => setPlacing(null)}
          style={{ cursor: "grab", padding: "var(--u-space-2) var(--u-space-3)" }}
        >
          <Text size="sm">{WAITING.label}</Text>
        </div>
      </Card>
      <Schedule
        ariaLabel="A chilled delivery waiting beside two cooled vans and a dry van"
        height={190}
        intents={["place"]}
        placing={placing}
        canMoveTo={mayGo}
        onIntent={onIntent}
      >
        <Lane id="cool-1" label={`Cooled van ${plate("FP 377 K")}`} />
        <Lane id="cool-2" label={`Cooled van ${plate("FP 455 R")}`} />
        <Lane id="dry" label={`Van ${plate("FP 214 K")}`} />
        <Subtasks data={work} tasks={GOODS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-place>
        {last}
      </Text>
    </Stack>
  );
}

Keep work out of someone's leave

Blocked time refuses a place in time where canMoveTo refuses a lane; both hold together, and neither wears a warning colour.

import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { applyIntent, useSchedule } from "@umriss-ui/schedule";
import type { BlockedTime, 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"),
];

interface Leave {
  id: string;
  person: string;
  from: number;
  to: number;
  reason: "Holiday" | "Sick" | "Training";
}

/** When people are away: blocked time on their lane. */
const LEAVE: readonly Leave[] = [
  { id: "l-1", person: "noah", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Sick" },
  { id: "l-2", person: "arjun", from: at(18, 0, 0), to: at(21, 0, 0), reason: "Holiday" },
  { id: "l-3", person: "kofi", from: at(13, 0, 0), to: at(14, 0, 0), reason: "Training" },
  { id: "l-4", person: "freya", from: at(16, 0, 0), to: at(18, 0, 0), reason: "Holiday" },
  { id: "l-5", person: "david", from: at(9, 0, 0), to: at(14, 0, 0), reason: "Holiday" },
  { id: "l-6", person: "maya", from: at(19, 13, 0), to: at(20, 17, 0), reason: "Training" },
];

const day = (d: number, hours = 0) => new Date(2026, 2, d, hours).getTime();

const APPS = PEOPLE.filter((person) => person.team === "Apps" && person.role !== "Product manager");
const START: readonly Subtask[] = WORK.filter((item) => APPS.some((person) => person.id === item.lane));
const AWAY: readonly BlockedTime[] = LEAVE.filter((leave) => APPS.some((person) => person.id === leave.person)).map((leave) => ({
  id: leave.id,
  lane: leave.person,
  from: leave.from,
  to: leave.to,
  label: leave.reason,
}));

/* Work stays with people of the role it was planned for. */
const roleOf = (lane: string) => PEOPLE.find((person) => person.id === lane)?.role;
const START_ROLE = new Map(START.map((item) => [item.id, roleOf(item.lane)]));
const mayGo = (item: Subtask, lane: string) => START_ROLE.get(item.id) === roleOf(lane);

export default function AroundLeave() {
  const { Schedule, Lane, Subtasks, BlockedTimes } = useSchedule({ initialView: { domain: [day(9), day(21)] } });
  const [work, setWork] = useState<readonly Subtask[]>(START);
  const [last, setLast] = useState("Drag Freya's store screenshots into her holiday");

  const onIntent = (intent: Intent) => {
    if (intent.kind === "place") return;
    setWork((current) => current.map((item) => applyIntent(item, intent)));
    setLast(`${intent.subtask}: ${intent.kind}`);
  };

  return (
    <Stack gap={3}>
      <Schedule
        ariaLabel="The apps team this week, with leave"
        height={236}
        intents={["move", "lane"]}
        canMoveTo={mayGo}
        onIntent={onIntent}
      >
        {APPS.map((person) => (
          <Lane key={person.id} id={person.id} label={`${person.name}, ${person.role}`} />
        ))}
        <BlockedTimes data={AWAY} />
        <Subtasks data={work} tasks={PROJECTS} />
      </Schedule>
      <Text size="sm" mono tone="secondary" data-last-move>
        {last}
      </Text>
    </Stack>
  );
}

When to use something else

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.
intentsreadonly IntentKind[][]The intents the caller handles. Each one enables its interaction; none leaves a read-only schedule (ADR-0023).
canMoveTo(subtask: Subtask, lane: string) => booleanevery lane is openWhether a subtask may go to a lane. It narrows "lane", it does not enable it. Asked once per lane when a drag takes hold, and asked again at the drop. The lanes it turns down are marked from the first frame of the drag - a planner sees the refusal before meeting it - and over one of them the ghost stays on the last lane that was allowed, the cursor says no and a line ties the ghost to the pointer. A refused lane costs the lane and nothing else: a drop after it still reports the move in time. It is asked for work dragged in from outside as well, with the key and task the application declared in placing.
placingPlacingItem | null—What the application is dragging in from outside while it drags it - its key, task, the length of its main time, its lead-in and lead-out. The browser hands the dragged data over only on the drop, so the ghost before it can only come from here: set it on your own dragstart, clear it on dragend. Without "place" in intents no drop is accepted.
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.

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 BlockedTimesProps — the full table stands on Blocked time.

NameTypeDefaultDescription
datarequiredreadonly BlockedTime[]—The blocked time of the lanes - leave, maintenance, unavailability -, as the caller holds it: one list, each interval naming its lane.

From BlockedTime — the full table stands on Blocked time.

NameTypeDefaultDescription
idrequiredstring—The caller's identity of the interval - what a finding names.
lanerequiredstring—The id of the lane it blocks.
fromrequirednumber—Where it begins.
torequirednumber—Where it ends.
labelstring—Why the lane is blocked - "Leave", "Maintenance" -, 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.

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