# Lane groups

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/>. 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).

Teams, depots or regions over the lanes, to any depth, folded into one row when a planner wants only the part they work on.

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

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

A group is structure, never a lane: nothing sits on it, no finding is reported for it and no intent names it ([ADR-0025](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md)). 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](https://romanhaendler.github.io/umriss-ui/schedule/view/).

## Examples

### Group lanes under a head

Wrap lanes in a `LaneGroup`: a slim head names the team and counts its lanes, and its chevron folds it.

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

### Nest groups to any depth

A depot holds a fleet of vans and a truck; an inner group keeps its fold while the outer one closes.

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

### Fold groups from outside

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

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

### Read a folded group

Folded, the depot shows each van as a thin strip: handovers still arrive, the double booking stays marked, and a held drag opens it.

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

### Start with groups folded

`folded` in `initialView` folds groups when the schedule mounts and leaves the rest to the planner: Discovery starts folded, and its chevron opens it.

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

## Keyboard

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

## API

### `LaneGroupProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` *required* | `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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md)).<br>Shown in: [Group lanes under a head](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#a-group), [Nest groups to any depth](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#nesting), [Fold groups from outside](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#fold-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.<br>Shown in: [Group lanes under a head](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#a-group), [Nest groups to any depth](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#nesting), [Fold groups from outside](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#fold-from-outside) and 5 more |
| `children` | `ReactNode` | — | `Lane`s and further `LaneGroup`s, to any depth.<br>Shown in: [Group lanes under a head](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#a-group), [Nest groups to any depth](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#nesting), [Fold groups from outside](https://romanhaendler.github.io/umriss-ui/schedule/lane-groups/#fold-from-outside) and 5 more |

## Known limits

- A folded group shows no summary - no utilisation band, no bar spanning its work: a computed claim would look like a drawn fact ([ADR-0025](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0025-a-lane-group-is-structure-over-lanes-never-a-lane.md)).

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
