IX Surfaces

Star on GitHubFeedback
IX SurfacesNative HTMLot-sheet

Sheet

A page slid in from the edge, its title running up the spine like a book's; or a row that unfolds into its page.

Report an issue with Sheet

Example

HaldenEdited today

The Halden brief

A new identity for a ferry line that has run between the islands since 1911. Keep the flag, lose the anchor. The timetable is the real product.

HaldenEdited today

The Halden brief

A new identity for a ferry line that has run between the islands since 1911. Keep the flag, lose the anchor. The timetable is the real product.

The Halden brief

A new identity for a ferry line that has run between the islands since 1911. Keep the flag, lose the anchor. The timetable is the real product.

Notes

Set the timetable large enough to read from the quay. The ferries' names go in the italic.

Settings

How the Halden workspace looks and who hears about it.

Keep the flag, lose the anchor.

The blue is older than the company, and every islander knows it. The anchor says harbour; the line is about the crossing.

Identity2026

Halden Line

A ferry line that has run between the islands since 1911. We kept the flag, lost the anchor, and set the timetable large enough to read from the quay.

Open

The brief

Keep the flag, lose the anchor.

panels, two steps in

Typefaces

Archivo speaks for the interface.

Pairs→
shelf, a sheet in front

Notes

Set the timetable large.

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base and dialog along.

npx shadcn@latest add https://thedirectors.agency/ui/r/sheet.json

Usage

This example also uses Button, Meta and Rows.

npx shadcn@latest add https://thedirectors.agency/ui/r/button.json https://thedirectors.agency/ui/r/meta.json https://thedirectors.agency/ui/r/rows.json
sheet-example.tsx
"use client"import { useState, type CSSProperties, type ReactNode } from "react"import { Button } from "@/components/ui/button"import { Meta } from "@/components/ui/meta"import { Row, RowKind, RowMeta, RowTitle, Rows } from "@/components/ui/rows"import {Sheet,SheetActions,SheetClose,SheetContent,SheetDescription,SheetPanel,SheetPanelLink,SheetPanels,SheetSpine,SheetTitle,SheetTrigger,} from "@/components/ui/sheet"const WORK = [["Halden Line", "Identity", "2026", "A ferry line that has run between the islands since 1911. We kept the flag, lost the anchor, and set the timetable large enough to read from the quay."],["Tidewater", "Type", "2025", "A text face for a tide table: figures that hold their column at nine points, and an italic for the names of the moons."],["Low Orchard", "Website", "2024", "A cider house's site with one page for every year's pressing, each one written by the person who pressed it."],]const brief = "A new identity for a ferry line that has run between the islands since 1911. Keep the flag, lose the anchor. The timetable is the real product."export default function Example() {const [which, setWhich] = useState(0)return (<div className="flex flex-wrap items-center gap-10">{(["end", "start"] as const).map((side) => (<Sheet key={side}><SheetTrigger asChild><Button variant="bracket">{side === "end" ? "Read the brief" : "Read it from the start"}</Button></SheetTrigger><SheetContent side={side}><SheetSpine>The brief</SheetSpine><Meta><span>Halden</span><span>Edited today</span></Meta><SheetTitle>The <span className="ot-yours">Halden</span> brief</SheetTitle><SheetDescription>{brief}</SheetDescription><SheetActions><SheetClose asChild><Button variant="bracket" data-autofocus>Close</Button></SheetClose></SheetActions></SheetContent></Sheet>))}<Sheet><SheetTrigger asChild><Button variant="bracket">Open the brief, then its notes</Button></SheetTrigger><SheetContent variant="shelf"><SheetSpine>The brief</SheetSpine><SheetTitle>The <span className="ot-yours">Halden</span> brief</SheetTitle><SheetDescription>{brief}</SheetDescription><SheetActions><Sheet><SheetTrigger asChild><Button variant="bracket" data-autofocus>Read the notes</Button></SheetTrigger><SheetContent><SheetSpine>Notes</SheetSpine><SheetTitle>Notes</SheetTitle><SheetDescription>Set the timetable large enough to read from the quay. The ferries&apos; names go in the italic.</SheetDescription><SheetActions><SheetClose asChild><Button variant="bracket" data-autofocus>Back to the brief</Button></SheetClose></SheetActions></SheetContent></Sheet></SheetActions></SheetContent></Sheet><Sheet><SheetTrigger asChild><Button variant="bracket">Open the settings</Button></SheetTrigger><SheetContent><SheetPanels defaultValue="settings"><SheetPanel value="settings" title="Settings"><SheetDescription>How the <span className="ot-yours">Halden</span> workspace looks and who hears about it.</SheetDescription><div><SheetPanelLink to="appearance" data-autofocus>Appearance</SheetPanelLink><SheetPanelLink to="notices">Notices</SheetPanelLink></div></SheetPanel><SheetPanel value="appearance" title="Appearance"><p className="ot-sheet-body">Day, with the parma pair. The page follows the reader&apos;s system after dark.</p><SheetPanelLink to="pair">Typefaces</SheetPanelLink></SheetPanel><SheetPanel value="pair" title="Typefaces"><p className="ot-sheet-body">Archivo speaks for the interface; <span className="ot-yours">Bodoni Moda</span> answers for you.</p></SheetPanel><SheetPanel value="notices" title="Notices"><p className="ot-sheet-body">A line each morning when a file changes. Nothing on weekends.</p></SheetPanel></SheetPanels></SheetContent></Sheet><Sheet><SheetTrigger asChild><Button variant="bracket">Read the note</Button></SheetTrigger><SheetContent variant="rag"><SheetTitle>Keep the <span className="ot-yours">flag</span>, lose the anchor.</SheetTitle><SheetDescription>The blue is older than the company, and every islander knows it. The anchor says harbour; the line is about the crossing.</SheetDescription><SheetActions><SheetClose asChild><Button variant="bracket" data-autofocus>Close</Button></SheetClose></SheetActions></SheetContent></Sheet><Sheet><Rows className="basis-full">{WORK.map(([name, kind, year], i) => (<Row key={name} asChild><SheetTrigger onClick={() => setWhich(i)}><RowTitle>{name}</RowTitle><RowKind>{kind}</RowKind><RowMeta>{year}</RowMeta></SheetTrigger></Row>))}</Rows><SheetContent variant="fold"><Meta><span>{WORK[which][1]}</span><span>{WORK[which][2]}</span></Meta><SheetTitle>{WORK[which][0]}</SheetTitle><SheetDescription>{WORK[which][3]}</SheetDescription><SheetActions><SheetClose asChild><Button variant="bracket" data-autofocus>Back to the index</Button></SheetClose></SheetActions></SheetContent></Sheet></div>)}/** A sheet drawn still, in the page, for the states row. */function Still({ variant, style, children }: { variant?: string; style?: CSSProperties; children: ReactNode }) {return (<dialogopenclassName="ot-sheet"data-side="end"data-variant={variant}style={{ position: "relative", inset: "auto", translate: "none", width: "20rem", maxWidth: "100%", height: "22rem", transition: "none", ...style }}>{children}</dialog>)}

Contract

  • Underneath: native <dialog>, through the dialog item.
  • Anatomy: <dialog class="ot-sheet" closedby="any"> with .ot-sheet-spine (the title again, aria-hidden). <dialog class="ot-drawer" closedby="any"> with <button class="ot-drawer-handle">. Both share the dialog backdrop.
  • A sheet is a page slid in from the end edge, its title running up the spine like a book's (ot-spine), in its own column: the sheet's start padding, on the left, or the right in right-to-left. A drawer rises from below and lands with spiccato; its handle is the fermata's arc. Dragging the handle down sets --pull and data-pulling, and past a threshold it closes.
  • Keyboard: Escape closes; the handle closes on Enter. Smooth scrolling pauses while open.
  • Sheet variants go on data-variant (SheetContent variant): spine (the default), shelf, rag, fold.
  • Shelf, after books standing on a shelf: a sheet opened from inside a shelf sheet stands in front of it, one spine column narrower, so the spine behind stays in view, set back to pencil, and is the way back: pointing at it (the bare backdrop) or Escape puts the front sheet away and focus returns to the control that opened it. Only the front sheet blurs the page. Below 40rem the column is space-7 and the spine mf.
  • Rag, after "Healthy habits" cut from its paper and "Less is more." set flush right: the words stand flush to the window's edge and rag toward the page, and the paper is cut to that rag, a step for every line, with a hairline along the cut. The script reads the line boxes (after the fonts), draws <svg class="ot-sheet-rag" aria-hidden="true"> behind them and sets data-laid; until then it is plain paper with its edge. The box hangs from the top only as tall as its words, and a click on its bare part is a click outside. Start and end sides only. It re-cuts on resize and when <html> changes its face. Forced colours: Canvas paper, CanvasText cut.
  • Fold, after the index entry that opens to its page and "Less is more." set between its hairlines: the row that opened it unfolds into the page. Before it opens (and again before it closes) the sheet writes the opener's box to --fold-t/r/b/l; the page is clipped to that band and opens out to the window, the row's two hairlines (ink, ::before and ::after) riding the clip's edges to the top and the foot, while the row's name (its rows-title, or the trigger's own words) grows into the title, set at --ot-ff (--ot-f below 40rem), by a transform measured from one to the other so their first lines meet. The rest arrives once there is room. A reversed row opens from its ink (data-from-ink: the page starts ink and washes to paper). Put away, it folds back into the row and the title flies back to the name. The whole window, no spine; side is ignored. The trigger remembers itself (SheetTrigger), so open it from <Row asChild><SheetTrigger> with one Sheet round the Rows; opened by state alone, a line across the middle parts. Reduced motion: it opens at once. Forced colours: the lines in CanvasText.
  • Panels (SheetPanels, .ot-sheet-panels holding nav.ot-sheet-spine.ot-sheet-trail and section.ot-sheet-panel pages): pages inside one sheet, the chapters of the book on its spine, and the scale contrast of "It has to be design.". The spine becomes the trail, read up from the foot (right to left as well): the names you came through small (mp) in the pencil, the one you are in large (f, 200) in ink, aria-current="page". Stepping in (SheetPanelLink to), the name you left shrinks into the trail and the new one writes up the spine; the new page slides in from the sheet's own edge while the old one leaves the other way, the two crossing in one grid cell (hidden and inert on the pages you are not in, the exit kept by display allow-discrete). The small names are buttons and the way back. Focus goes to the new page's heading, and going back returns it to the link that stepped in; the sheet scrolls to the top. Each opening starts at the root, whose title names the dialog. Below 40rem the trail is mf over p. It belongs to the sheet, not the drawer: a sheet is already a page with a spine to carry the path, where a drawer is a tray whose height the thirds decide.
  • Drawer variants go on data-variant (DrawerContent variant): arc (the default), thirds, solid.
  • Thirds, after SPECTRA's "1/3": the drawer stops at a third, two thirds or the whole height, and the share stands at the top end corner as a Fraction (aria-hidden), thin and large. The handle is then role="slider" (vertical, 1 to 3, "2 of 3"): drag it to the nearest third, or lower than half a third to put it away; a tap or Enter raises it a third, round to one; the arrow keys, Home and End step it. Each opening starts at a third.
  • Solid, after the compositor's leads: pulling the arc first takes the silence out between the drawer's parts (--solid from 0 to 1 closes the gaps and the padding), as type is set solid when the leads come out; only once it is set solid does the drawer itself move, and letting go past that closes it. Its give is the height it loses, measured on pointerdown. Keyboard as arc.

Props

Props of Sheet
PropTypeDefaultWhat it does
SheetDialog propsThe same open, defaultOpen, onOpenChange and alert as Dialog. Built on the native dialog, so focus and Escape are the browser's.
SheetContent side"start" | "end" | "top" | "bottom""end"Which edge it slides from. Start and end follow the reading direction.
SheetContent variant"spine" | "shelf" | "rag" | "fold""spine"spine: the title runs up the spine, in its own column. shelf: a sheet opened from inside it stands in front, one spine narrower, so this one's spine stays in view like a book on a shelf; pointing at it, or Escape, puts the front one away. rag: the words stand flush to the window's edge and the paper is cut to the rag of their lines; keep it to a title, a sentence or two and one action. Start and end only. fold: the row that opened it unfolds into the page: its two hairlines part to the window's top and foot, and its name (the row's RowTitle, or the trigger's words) grows into the page's title; the rest arrives once there is room. Put away, it folds back into the row. A reversed row opens from its ink. Open it from `<Row asChild><SheetTrigger>`, one Sheet round the whole Rows (a dialog can't sit in a list); side is ignored.
SheetTriggerbuttonOpens the sheet, and remembers itself, so a fold grows out of it. Without one (opened by state alone), a fold parts from a line across the middle.
SheetPanelsdivPages inside one sheet (spine or shelf). Its spine is the trail: the panel you are in written large up the spine, the ones you came through small in the pencil below it, and they are the way back. Put it in SheetContent in place of SheetSpine.
SheetPanels defaultValuestringThe panel it opens on, the root of the trail. Each opening of the sheet starts there again.
SheetPanels onValueChange(value: string) => voidCalled with the panel you arrive at.
SheetPanelsectionOne page, a direct child of SheetPanels, with value and title (its heading and its name on the spine). Only the one you are in is shown; the root's title names the sheet.
SheetPanelLink tostringSteps into the panel with that value; focus moves to its heading, and going back returns focus here. Bare, a line of type with an arrow; asChild lends the move to your own control.
SheetSpinepThe sheet's name, running up its spine. Decorative; SheetTitle is the readable heading.
data-autofocusattributePut it on the control that should take focus when the sheet opens.
SheetTrigger / SheetPanelLink asChildbooleanfalseLend the opening or panel navigation behaviour to one child control.
SheetPanel value / titlestring / ReactNodeRequired: the panel's unique value, used by SheetPanelLink.to, and its title, used for the heading and spine trail.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
SheetBook spinesThe title runs up the spine
Shelf sheetBooks on a shelfThe sheet behind shows its spine, and is the way back
Rag sheet"Healthy habits" silhouette; "Less is more." flush rightThe paper is cut to the rag of its lines
Fold sheetThe index entry that opens to its page; "Less is more." hairlinesThe row's hairlines part into the page; its name grows into the title
Sheet panelsBook spines; "It has to be design." scale contrastThe spine is the trail: the page you are in large, the way back small below it

How it moves

How it moves
ComponentArticulationWhat moves
SheetSpineThe page slides in; the title runs up the spine
Shelf sheetSpineThe front sheet slides in over the one behind; that spine sets back to pencil
Rag sheetReachThe cut is drawn down its steps as the sheet arrives
Fold sheetUnfoldThe row's hairlines part to the window's edges as its name grows into the title; put away, it folds back
Sheet panelsSpineThe name you left shrinks into the trail and the new one writes up the spine; the pages cross