---
title: Workbench
description: The frame around a tool rather than a page: title strip, toolbar, rail, a body that scrolls on its own, and a status line.
source: https://adamjurek.com/components/workbench
---

## Home


One paragraph on what this does and when to reach for it. This is the first
thing on the component's page and the line that goes into `llms.txt`, so it
should survive being read on its own.

<!-- ::start:showcase demo="workbench" height="380" -->
<!-- ::end:showcase -->

## Why it is built this way

The decision somebody would otherwise have to reverse-engineer from the source.
Not what the code does - the source says that - but what it is avoiding.

## What it does not do

The boundary. A component that lists what it is not is a component you can
decide against in ten seconds.

> [!NOTE] Install commands are not written here
> Anything in `packages/ui/registry.ts` gets its TanStack, shadcn and pnpm
> commands appended to the bottom of this tab, along with its version,
> dependencies and files. Do not add your own - the generated ones cannot go
> stale, and a second copy immediately does.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/workbench.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/workbench.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Containers |
| Files | `workbench.tsx` |
| Dependencies | None |
| Tags | block, shell, panel, container-query, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Workbench } from "@sushindustries/ui";
import "@sushindustries/atoms/atoms.css";

export function Example() {
	return (
		<Workbench title="documents" maxHeight="20rem">
			<p>Only the body is required. Every strip around it is optional.</p>
		</Workbench>
	);
}
```

## What you should see

A case in its own material with a screen sunk into it, and a centred
monospaced `DOCUMENTS` in the strip along the top. Nothing scrolls yet:
`maxHeight` is a cap rather than a height, so the body stays as tall as its
content until the content is taller than that.

## If nothing happens

A plain block with the title as ordinary text means
`@sushindustries/atoms/atoms.css` was never imported - this component ships
class names and no styles of its own, so without the stylesheet there is no
case, no screen and no strip. If the page scrolls instead of the body,
`maxHeight` was left off, and there is nothing for the body to scroll against.


## Guides


## Composing it

Every slot is optional except `children`. Each of `title`, `toolbar`, `rail`
and `status` adds furniture without moving anything already there, so a page
can start with a body and grow the rest as it needs them.

```tsx
<Workbench
	title="documents"
	label="Every document in the index"
	toolbar={<Button>New</Button>}
	rail={<KindFilters />}
	status={
		<span className="workbench-stat">
			<b>50</b> of <b>1,240</b>
		</span>
	}
	maxHeight="24rem"
>
	<Rows />
</Workbench>
```

The rail is a sibling of the body rather than a block inside it, so it stays
put while the body scrolls. A filter list that scrolls away is the one thing a
filter list must not do, because it is what you reach for after scrolling.

## The three variants

All three are the same markup and differ only in how much frame is drawn, so
switching between them can never move a slot or break the scroll container.

| `variant` | Draws | Reach for it when |
| --- | --- | --- |
| `machine` (default) | The case, with the screen sunk into it | The workbench is the page's content and should read as an object on it |
| `panel` | One border, no case | It sits inside a card, a dialog or another workbench - a second material inside a first reads as a surface floating on a surface |
| `bare` | The layout and nothing around it | It fills its container edge to edge, where a border would be a line drawn against the window |

## The rail folds on the panel, not the window

`.workbench` is a named container, so the rail moves above the body at 46rem of
panel width rather than of viewport width. One in a sidebar and one filling a
page then behave the same at the same size, which a media query cannot express
because it is asking a different question.

## Scrolling is handed back to the browser

The body carries `data-lenis-prevent`, so a smooth-scroll driver that has taken
over wheel and touch for the document lets go inside it. Without that, dragging
in the panel animates the page behind it. The attribute is inert for anyone not
running one.

## When not to use it

Use `Device` when the point is to show something off. A tilt, a perspective and
a lid-shaped aspect ratio are exactly wrong for a surface somebody sits in
front of: the ratio crops real content to the shape of a laptop, and text on a
rotated plane is text that is slightly blurred all day.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children` | `ReactNode` | - | The body. Scrolls on its own; the page does not scroll with it. |
| `title?` | `string` | - | In the strip at the top. Small, monospaced, the name of the surface. |
| `toolbar?` | `ReactNode` | - | Also in the strip, right-aligned. Search, filters, a menu, a count. |
| `rail?` | `ReactNode` | - | A column down the left of the body. Navigation, a tree, a filter list. |
| `status?` | `ReactNode` | - | Pinned along the bottom. Counts, a revision, when it last refreshed. |
| `maxHeight?` | `string` | - | How tall the body is allowed to grow before it scrolls. A CSS length. Left off, the body is as tall as its content and nothing scrolls - which is right for a short table and wrong for a browser over a database, so the caller decides rather than a default guessing. |
| `label?` | `string` | - | Announced as a region with this name, for anyone navigating by landmark. |
| `variant?` | `"machine" \| "panel" \| "bare"` | `"machine"` | How much frame to draw. `machine` is the case and the sunken screen - an object sitting on the page, which is right when the workbench *is* the page's content. `panel` is one border and no case. It exists because the case is a second material, and a second material inside a first one reads as a surface floating on a surface - so a workbench inside a card, a dialog or another workbench wants this rather than the full machine. `bare` is the layout with no frame at all: the strip, the rail, the scrolling body and the status line, and nothing drawn around them. For a workbench that fills its container edge to edge, where a border would be a line against the window. All three are the same markup. Only the case and the screen change, which is what keeps the choice cosmetic rather than structural - switching variants can never move a slot or break a scroll container. |

<!-- /generated:api -->

## Notes

Anything the types cannot say: which combinations are meaningless, which
prop is ignored when another is set, and what it does when handed
something it cannot render.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="workbench" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## A browser over an index

All four slots earning their place: the toolbar holds the one action, the rail
holds the filters that must not scroll away, the status line holds the counts
somebody checks without reading anything else.

```tsx
import { Button, DataTable, Workbench } from "@sushindustries/ui";

export function DocumentBrowser({ kind, kinds, rows, onKind }) {
	return (
		<Workbench
			title="documents"
			label="Every document in the index"
			maxHeight="32rem"
			toolbar={<Button>New</Button>}
			rail={
				<div className="flex col gap-2">
					<span className="label">Kind</span>
					{kinds.map((one) => (
						<button key={one} type="button" onClick={() => onKind(one)}>
							{one}
						</button>
					))}
				</div>
			}
			status={
				<span className="workbench-stat">
					<b>{rows.length}</b> of <b>1,240</b> in <b>{kind}</b>
				</span>
			}
		>
			<DataTable label="Documents" rows={rows} columns={columns} density="compact" />
		</Workbench>
	);
}
```

## Inside a card

A workbench nested in something that already has a border wants `panel`, so
there is one material rather than two.

```tsx
<Card title="Index">
	<Workbench variant="panel" title="recent" maxHeight="18rem">
		<Rows />
	</Workbench>
</Card>
```

## What this example is not

`maxHeight` is what makes the body its own scroller - drop it and the browser
grows to the height of 1,240 rows and the page scrolls instead. `columns` and
the filter state are the caller's; this component holds neither, so nothing
here is filtering anything on its own.
