---
title: Folder Shelf
description: A desktop of folders that open into draggable windows, several at once, remembered between visits.
source: https://adamjurek.com/components/folder-shelf
---

## Home


The desktop on the home page is this, fed by a Markdown file.

<!-- ::start:showcase demo="folder-shelf" height="520" -->
<!-- ::end:showcase -->

## What it is made of

<!-- ::start:grid min="15rem" gap="4" -->

**`FolderShelf`** is the desktop and the window manager: the icons, and which
windows exist.

**`DeskWindow`** is one window - dragging, resizing, closing, stacking.

**`useDeskState`** is where the arrangement lives, and the only part that
touches storage.

**`ContextMenu`** is the menu on every icon, reachable three ways.

<!-- ::end:grid -->

Four pieces rather than one, because three of them are useful on their own and
the fourth is only interesting when it has somewhere to put things.

## It stopped being a `<dialog>`

The first version opened folders with `showModal()`, which is genuinely the
better answer for a modal on a page: focus trapping, Escape, inertness behind
it and top-layer stacking, all free.

It is the wrong answer for a desktop. A modal dialog goes to the top layer *by
definition*, so it covered the browser window rather than the screen it belongs
to, and only one could ever be open. Windows are absolutely positioned panels
now; Escape and focus are done by hand, and the stacking is a `z` each window
carries - which is also what makes front-to-back survive a reload.

> [!NOTE] The portal went with it
> Positioning them inside the desk removed the portal, and with it a whole
> class of bug: a hydration mismatch from a `typeof document` branch, and
> `position: fixed` measuring against a rotated laptop lid instead of the
> viewport. Both had already happened.


## Install

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

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/folder-shelf.json
```

### shadcn

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

### pnpm

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

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

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Containers |
| Files | `folder-shelf.tsx`, `context-menu.tsx`, `desk-window.tsx`, `use-desk-state.ts`, `use-drag-place.ts` |
| Dependencies | None |
| Also installs | `icon`, `context-menu`, `desk-window`, `use-desk-state` |
| Tags | block, dialog, tree, touch, 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 { FolderShelf, type ShelfEntry } from "@sushindustries/ui";

const entries: ShelfEntry[] = [
	{
		id: "components",
		label: "Components",
		children: [
			{ id: "grid", label: "Grid", href: "/packages/ui/docs/grid" },
			{ id: "icon", label: "Icon", href: "/packages/ui/docs/icon" },
		],
	},
	{ id: "readme", label: "README", href: "/README.md" },
];

export function Example() {
	return <FolderShelf entries={entries} label="Site" />;
}
```

## What you should see

A grid of folder and file icons: one folder tile named "Components", one file
tile named "README". Clicking the folder opens a window on top of the grid
showing "Grid" and "Icon" as file tiles, with a path bar reading "Components".
Clicking "README" follows its `href` directly, because it has no children and
nothing was passed to `renderEntry`.

Drag an icon and it detaches from the grid's own flow and stays where you
dropped it on reload - that is `useDeskState` writing to storage under the
default key, `sushindustries.desk`. Two shelves on the same page with no
`rememberAs` given share that key and therefore share layout, which is
usually not what you want outside a demo.

## If nothing happens

A leaf entry with neither `href` nor a `renderEntry` prop still renders - as
a link to `#`, because `renderLink` always gets called with
`entry.href ?? "#"`. Give every leaf one or the other.

The desktop grid and its windows are laid out with the `shelf` classes from
`@sushindustries/atoms`. Without that stylesheet loaded, entries still render
and are still clickable, just as unstyled boxes stacked in document order.


## Guides


## Dragging, in one rule

**Position is written to the element during the drag and to state only on
release.**

Sixty state updates a second would re-render a window's whole contents on every
frame, and the contents here are grids of icons. During a drag the handler
writes two custom properties; when the pointer lifts, one state update records
where it ended up.

`setPointerCapture` keeps the drag alive when the pointer outruns the title bar,
which is exactly the moment somebody is throwing a window across the screen and
most notices it break. One code path serves mouse, touch and pen, because they
are pointer events.

Resizing is the same three events against the other corner. Not `resize: both`,
which is one line of CSS and cannot be told about a minimum, cannot be clamped
to the desk, and writes to the element's inline size without telling React - so
the size is forgotten the moment anything re-renders.

<!-- ::start:spacer size="6" rule="true" -->
<!-- ::end:spacer -->

## Pages open here

`renderEntry` takes a leaf and returns its page, or nothing.

```tsx
import { renderShelfPage } from "./shelf-page";

<FolderShelf entries={entries} renderEntry={renderShelfPage} />;
```

Return nothing and the leaf stays a link, which is the right default - this
component has no idea what is at the other end of an href. Return something and
the desktop stops being a directory of the site and becomes where the site is.

The rule that matters is that it is all or nothing per kind of thing. A folder
where some items open in a window and others jump to another page is worse than
either, so on this site: components, packages and posts render here because
their Markdown is on hand, and the machine-readable files stay links because
the honest way to look at what a crawler fetches is to fetch it.

## Search is not in here

It used to be: a field above the icons, filtering the tree.

It came out when the dock grew a search palette, because two search boxes over
the same tree on the same screen is the duplication this repo keeps deleting
everywhere else. The dock's is better placed - centred, over a dimmed screen,
where the eye goes when you decide to search - and one of them had to go.

What is left here is the walk, exported so whoever is doing the searching can
use it:

```ts
import { flatten, matches } from "@sushindustries/ui";

flatten(entries).filter(({ entry }) => matches(entry, query));
```

`flatten` returns each entry with the path that leads to it, so a result can say
which folder it lives in - the difference between a name and an answer. And it
deliberately walks past the folders: somebody typing into a desktop is looking
for a file, not for the drawer it is in.

## The data

```ts
interface ShelfEntry {
	id: string;
	label: string;
	description?: string;
	href?: string;
	meta?: string;
	icon?: IconName;
	children?: ShelfEntry[];
}
```

`children` is the whole type system: present and non-empty makes it a folder,
absent makes it a thing. There is no `kind` field to get wrong.

## Where this is used

| Where | What |
| --- | --- |
| The home page | inside `Laptop`, with a `Dock` along the bottom |
| `apps/web/content/shelf.md` | the tree, as a nested Markdown list |
| `shelf.catalogue.ts` | expands `{components}`, `{packages}`, `{posts}`, `{files}` from the registry |
| `shelf-actions.ts` | what the right-click menu can do |
| `shelf-page.tsx` | `renderEntry`: the Markdown lookup |

`rememberAs` is a storage key, used when the shelf makes its own desk.

> [!CAUTION] Two `useDeskState` calls with one key are not one desk
> They are two React states that happen to write to the same place. Opening a
> window through one leaves the other rendering the desk it last knew about -
> which is exactly what happened when the dock's search button wrote to the
> site's desk while the shelf kept rendering its own: a task appeared in the
> dock and no window appeared on screen.
>
> Whenever something outside the shelf needs to open, close or list windows,
> hold the desk above both and pass it in as `desk`.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `entries` | `readonly ShelfEntry[]` | - | The whole tree. The top level is the desktop; the rest appears once a window opens onto it. |
| `actionsFor?` | `(entry: ShelfEntry, path: readonly ShelfEntry[]) => MenuAction[]` | - | The menu for an entry, built by the consumer. This component knows how to summon a menu and where to put it. It does not know what "save as Markdown" means, and it should not: the actions are about the host's content, and a shelf that hard-coded them could only ever list one kind of thing. |
| `query?` | `string` | `""` | Text in the search window's field. Controlled by the consumer. |
| `onQuery?` | `(query: string) => void` | - | Every keystroke in that field. Without it the field cannot be typed into. |
| `onChoose?` | `(entry: ShelfEntry, path: readonly ShelfEntry[]) => void` | - | What a search result does when chosen. |
| `renderEntry?` | `(entry: ShelfEntry) => ReactNode` | - | Renders a leaf's page, to be shown in a window rather than navigated to. Return nothing and the leaf stays a link, which is the right default - this component has no idea what is at the other end of an href. Return something and the desktop stops being a directory of somewhere else and becomes the place the content is. |
| `renderLink?` | `(props: { id: string; href: string; className: string; children: ReactNode; }) => ReactNode` | `(props) => <a {...props} />` | Renders the link for an entry that has an href. |
| `label?` | `string` | `"Folders"` | Announced to screen readers as the name of the shelf. |
| `rememberAs?` | `string` | `"sushindustries.desk"` | Storage key for the arrangement: which windows are open, where they sit, and what has been put away. Only used when `desk` is not supplied. |
| `desk?` | `DeskApi` | - | An existing desk to render, rather than one of its own. Supply this whenever something outside also needs to open, close or list windows - a dock, most obviously. Two `useDeskState` calls with the same storage key are not one desk shared: they are two Reacts states that happen to write to the same place, so opening a window through one leaves the other still rendering the desk it last knew about. That is not hypothetical. The dock's search button wrote to the site's desk and the shelf kept rendering its own, so pressing search added a task to the dock and put no window on screen. |
| `columns?` | `number` | `4` | How many cells across the desktop is. Passed rather than measured, so the server and the client agree about the arrangement on the first paint. On this site it comes from `devices.md` via `useDeviceKind`, which is the same table the stylesheet's `--device-columns` is compiled from. Only the top-level shelf uses it. Icons inside a window are never placed, so a window never needs to know. |

<!-- /generated:api -->

## Notes

`columns` only shapes the top-level desktop. A window's contents always
auto-flow as a plain grid, whatever `columns` says, because nothing inside a
window is ever placed by drag.

`query`, `onQuery` and `onChoose` do nothing unless something also opens the
reserved search window - `desk.open(SEARCH_PATH)`, exported alongside
`flatten` and `matches` for exactly that. This component draws the search
window when it exists; it never opens one on its own.

`renderEntry` and `actionsFor` are per-call, not per-entry: leave either
unset and every leaf behaves the same way (a link, or no "..." button) rather
than needing to be opted in one entry at a time. `desk`, when supplied, makes
`rememberAs` unread.


## 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="folder-shelf" 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.

## In a page

```tsx
import { DEVICES, FolderShelf, useDeviceKind, type ShelfEntry } from "@sushindustries/ui";

const entries: ShelfEntry[] = [
	{
		id: "packages",
		label: "Packages",
		children: [
			{ id: "ui", label: "ui", href: "/packages/ui" },
			{ id: "atoms", label: "atoms", href: "/packages/atoms" },
			{ id: "db", label: "db", href: "/packages/db" },
		],
	},
];

// The narrowest machine, so the server render and the first client frame
// agree - `useDeviceKind` is null until mounted.
function columnsFor(kind: ReturnType<typeof useDeviceKind>): number {
	return DEVICES.find((device) => device.kind === kind)?.columns ?? DEVICES[0].columns;
}

export function PackagesDesk() {
	const columns = columnsFor(useDeviceKind());

	return (
		<main className="container section">
			<FolderShelf
				entries={entries}
				label="Packages"
				columns={columns}
				rememberAs="sushindustries.packages-desk"
			/>
		</main>
	);
}
```

## What this example is not

The `columnsFor` helper is the same lookup the site's own desktop does
against `DEVICES` - the table the stylesheet's breakpoints are compiled from
- so the icon grid agrees with the CSS about how many columns are on screen.
A hardcoded number would still render, just not in step with it. `rememberAs`
is given its own key because this shelf is not the site's main desktop -
sharing the default key would mix its window state into the one on the home
page.
