Teams, depots or regions over the lanes, to any depth, folded into one row when a planner wants only the part they work on.
import { useSchedule } from "@umriss-ui/schedule";
A group is structure, never a lane: nothing sits on it, no finding is reported for it and no intent names it (ADR-0025). The lanes keep the order they were declared in, whatever group they are in.
Folded, a group shows a miniature: every lane in it as a thin strip with its work in the tasks' colours. Dependencies still arrive at the right strip, findings are marked on the row, and a strip can be hovered and selected. Labels, appearances and grips need room and wait for the group to open.
Folding is part of the schedule's view and never an intent: folded in initialView starts with groups folded, toggleGroup, foldAll and unfoldAll fold from outside, and onViewChange reports every fold the planner makes - see View.
Wrap lanes in a LaneGroup: a slim head names the team and counts its lanes, and its chevron folds it.
import { useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";
/* Declared by composition: a lane never names its group, so moving a person
to another team is moving a line of JSX. */
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const min = (n: number) => n * 60_000;
const PROJECTS: Task[] = [
{ id: "portal", name: "Member portal", color: "light-dark(#7c3aed, #a98bfa)" },
{ id: "shop", name: "Online shop relaunch", color: "light-dark(#2563eb, #6b9bff)" },
];
const WORK: Subtask[] = [
{ id: "w-102", task: "portal", lane: "arjun", from: at(7), to: at(9), leadIn: min(30) },
{ id: "w-104", task: "shop", lane: "chloe", from: at(8), to: at(10, 30), leadOut: min(15) },
{ id: "w-106", task: "portal", lane: "noah", from: at(10), to: at(12) },
{ id: "w-108", task: "shop", lane: "eva", from: at(11, 30), to: at(14) },
];
export default function AGroup() {
const { Schedule, Lane, LaneGroup, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(15)] } });
return (
<Schedule ariaLabel="Two developers in a team, a designer and a tester outside it" height={240} headerWidth={200}>
<LaneGroup id="developers" label="Developers">
<Lane id="arjun" label="Arjun Mehta" />
<Lane id="chloe" label="Chloe Durand" />
</LaneGroup>
<Lane id="noah" label="Noah Fischer" />
<Lane id="eva" label="Eva Novak" />
<Subtasks data={WORK} tasks={PROJECTS} />
</Schedule>
);
}
A depot holds a fleet of vans and a truck; an inner group keeps its fold while the outer one closes.
import { 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();
/* 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 TOURS: Task[] = [
{ id: "t-01", name: "T-01 Harbour", color: "light-dark(#2563eb, #6b9bff)" },
{ id: "t-03", name: "T-03 Old Town", color: "light-dark(#c2410c, #f08a52)" },
];
const LEGS: Subtask[] = [
{ id: "l1", task: "t-01", lane: "fp-214", from: at(7), to: at(9) },
{ id: "l2", task: "t-03", lane: "fp-377", from: at(8), to: at(10, 30) },
{ id: "l3", task: "t-01", lane: "fp-290", from: at(9, 30), to: at(11, 30) },
{ id: "l4", task: "t-03", lane: "fp-118", from: at(11), to: at(13) },
{ id: "l5", task: "t-01", lane: "fp-402", from: at(12), to: at(14) },
];
export default function Nesting() {
const { Schedule, Lane, LaneGroup, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(15)] } });
return (
<Schedule ariaLabel="North depot with its vans and a truck, and a van from Riverside" height={330} headerWidth={200}>
<LaneGroup id="north" label="North depot">
<LaneGroup id="vans" label="Vans">
<Lane id="fp-214" label={`Van ${plate("FP 214 K")}`} />
<Lane id="fp-377" label={`E-van ${plate("FP 377 K")}`} />
<Lane id="fp-290" label={`Van ${plate("FP 290 E")}`} />
</LaneGroup>
<Lane id="fp-118" label={`Truck ${plate("FP 118 R")}`} />
</LaneGroup>
<Lane id="fp-402" label={`Van ${plate("FP 402 R")}`} />
<Subtasks data={LEGS} tasks={TOURS} />
</Schedule>
);
}
foldAll, unfoldAll and toggleGroup fold from outside, and view.folded says what is folded - start from initialView, go to a whole view with setView, keep what onViewChange reports.
import { Button, Stack, Text } from "@umriss-ui/core";
import { useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";
/* The fold is part of the schedule's view, not of the plan: no intent, and
nothing the application has to hold for the chevrons to work. */
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const TASKS: Task[] = [{ id: "release", name: "Release 4.12", color: "light-dark(#0d9488, #3cc7b8)" }];
const WORK: Subtask[] = [
{ id: "s1", task: "release", lane: "priya", from: at(7), to: at(9) },
{ id: "s2", task: "release", lane: "jonas", from: at(9, 30), to: at(11) },
{ id: "s3", task: "release", lane: "tomasz", from: at(11, 30), to: at(13) },
{ id: "s4", task: "release", lane: "leila", from: at(13), to: at(14, 30) },
];
export default function FoldFromOutside() {
const { Schedule, Lane, LaneGroup, Subtasks, view, foldAll, unfoldAll } = useSchedule({ initialView: { domain: [at(6), at(15)], folded: ["discovery"] } });
const folded = view.folded ?? [];
return (
<Stack gap={3}>
<Stack direction="row" gap={2}>
<Button size="sm" variant="secondary" onClick={foldAll} data-fold-all>
Fold everything
</Button>
<Button size="sm" variant="secondary" onClick={unfoldAll} data-unfold-all>
Unfold everything
</Button>
</Stack>
<Schedule ariaLabel="Two teams rolling out a release, folded from outside" height={280}>
<LaneGroup id="payments" label="Payments">
<Lane id="priya" label="Priya Raman" />
<Lane id="jonas" label="Jonas Keller" />
</LaneGroup>
<LaneGroup id="discovery" label="Discovery">
<Lane id="tomasz" label="Tomasz Nowak" />
<Lane id="leila" label="Leila Haddad" />
</LaneGroup>
<Subtasks data={WORK} tasks={TASKS} />
</Schedule>
<Text size="sm" mono tone="secondary" data-folded>
{folded.length === 0 ? "nothing folded" : folded.join(", ")}
</Text>
</Stack>
);
}
Folded, the depot shows each van as a thin strip: handovers still arrive, the double booking stays marked, and a held drag opens it.
import { useState } from "react";
import { applyIntent, findings, useSchedule } from "@umriss-ui/schedule";
import { Stack, Text } from "@umriss-ui/core";
import type { Subtask, Task, Dependency } from "@umriss-ui/schedule";
/* A drag held over the folded depot opens it for the gesture and shuts it
again when the drag ends; the schedule's view never learns of it.
`findings()` is computed from the data, so the count below is the same
folded or not. */
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 min = (n: number) => n * 60_000;
const TOURS: Task[] = [
{ id: "t-01", name: "T-01 Harbour", color: "light-dark(#2563eb, #6b9bff)" },
{ id: "t-03", name: "T-03 Old Town", color: "light-dark(#c2410c, #f08a52)" },
];
const LEGS: Subtask[] = [
{ id: "t-01-1", task: "t-01", lane: "truck", from: at(6, 30), to: at(8), leadOut: min(15) },
{ id: "t-01-2", task: "t-01", lane: "van-2", from: at(9), to: at(11, 30), leadIn: min(30) },
{ id: "t-03-1", task: "t-03", lane: "van-1", from: at(7), to: at(9) },
/* Claims the e-van while the Harbour tour still has it: an overlap. */
{ id: "t-03-2", task: "t-03", lane: "van-2", from: at(11), to: at(13), leadIn: min(15) },
{ id: "t-03-3", task: "t-03", lane: "van-3", from: at(13, 30), to: at(15) },
];
const HANDOVERS: Dependency[] = [
/* Out of the truck, outside the depot group, into a van inside it. */
{ id: "h-1", from: "t-01-1", to: "t-01-2", lag: min(20) },
{ id: "h-2", from: "t-03-1", to: "t-03-2", lag: min(30) },
{ id: "h-3", from: "t-03-2", to: "t-03-3", lag: min(20) },
];
const FOUND = findings(LEGS, HANDOVERS);
export default function TheMiniature() {
const { Schedule, Lane, LaneGroup, Subtasks, Dependencies } = useSchedule({ initialView: { domain: [at(6), at(16)], folded: ["north"] } });
const [legs, setLegs] = useState<readonly Subtask[]>(LEGS);
const [last, setLast] = useState("Hold a bar over the folded depot");
return (
<Stack gap={3}>
<Schedule
ariaLabel="A depot that folds into one row"
height={240}
intents={["move", "lane"]}
onIntent={(intent) => {
if (intent.kind === "place") return;
setLegs((current) => current.map((leg) => applyIntent(leg, intent)));
setLast(`${intent.subtask}: ${intent.kind}`);
}}
>
<Lane id="truck" label={`Truck ${plate("FP 118 R")}`} />
<LaneGroup id="north" label="North depot">
<Lane id="van-1" label={`Van ${plate("FP 214 K")}`} />
<Lane id="van-2" label={`E-van ${plate("FP 377 K")}`} />
</LaneGroup>
<Lane id="van-3" label={`Van ${plate("FP 402 R")}`} />
<Dependencies data={HANDOVERS} />
<Subtasks data={legs} tasks={TOURS} />
</Schedule>
<Text size="sm" mono tone="secondary" data-last-move>
{last}
</Text>
<Text size="sm" mono tone="secondary" data-findings-count>
{FOUND.overlaps.length} overlap and {FOUND.violatedDependencies.length} violated dependency - folded or not
</Text>
</Stack>
);
}
folded in initialView folds groups when the schedule mounts and leaves the rest to the planner: Discovery starts folded, and its chevron opens it.
import { useSchedule } from "@umriss-ui/schedule";
import type { Subtask, Task } from "@umriss-ui/schedule";
/* The schedule keeps the fold from here on. To store it, keep what
`onViewChange` reports; to steer it, use `setView` and the setters
`useSchedule` hands back. `initialView` is read once. */
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const TASKS: Task[] = [{ id: "release", name: "Release 4.12", color: "light-dark(#0d9488, #3cc7b8)" }];
const WORK: Subtask[] = [
{ id: "s1", task: "release", lane: "priya", from: at(7), to: at(9) },
{ id: "s2", task: "release", lane: "jonas", from: at(9, 30), to: at(11) },
{ id: "s3", task: "release", lane: "tomasz", from: at(11, 30), to: at(13) },
{ id: "s4", task: "release", lane: "leila", from: at(13), to: at(14, 30) },
];
export default function StartWithGroupsFolded() {
const { Schedule, Lane, LaneGroup, Subtasks } = useSchedule({ initialView: { domain: [at(6), at(15)], folded: ["discovery"] } });
return (
<Schedule ariaLabel="Two teams rolling out a release, Discovery folded at the start" height={280}>
<LaneGroup id="payments" label="Payments">
<Lane id="priya" label="Priya Raman" />
<Lane id="jonas" label="Jonas Keller" />
</LaneGroup>
<LaneGroup id="discovery" label="Discovery">
<Lane id="tomasz" label="Tomasz Nowak" />
<Lane id="leila" label="Leila Haddad" />
</LaneGroup>
<Subtasks data={WORK} tasks={TASKS} />
</Schedule>
);
}
| Key | Action |
|---|---|
Tab |
Reaches each group's fold button - the only buttons the schedule puts in the tab order. |
Enter / Space |
Folds or unfolds the group. |
The keys of First schedule apply here.
LaneGroupProps| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | The group's identity - what the view's folded names. It is never a lane:
no subtask sits on it, no finding is reported for it, and no intent names
it (ADR-0025).Shown in: Group lanes under a head, Nest groups to any depth, Fold groups from outside and 5 more |
label | ReactNode | id | What the group's header says - the team, the depot, the region.
Real text, read by a screen reader. Shown in: Group lanes under a head, Nest groups to any depth, Fold groups from outside and 5 more |
children | ReactNode | — | Lanes and further LaneGroups, to any depth.Shown in: Group lanes under a head, Nest groups to any depth, Fold groups from outside and 5 more |
What umriss deliberately does not build, and why: ADR-0032.