IV Space and line

Star on GitHubFeedback
IV Space and lineNative HTMLot-tiling

Tiling

Tiles on the 12-column grid, held apart by space and one hairline, never boxed. Leave the sheet still, or drag a tile and pull its corner: the grid makes room for your arrangement.

Report an issue with Tiling

Example

Make room for your own arrangement

Drag to move. Drag the corner to resize.

Enter / Space picks up or drops. Arrows move. Shift + arrows resize. Escape cancels.

Less
Space
but
better.

Layout JSON

Fewer things, more meaning

Less

Space does the layout

Take something away before adding anything, and the line that is left means more.

but

better.

Focus on what matters

Clarity
Purpose
Silence
Type

01

Identity

Names, marks, and the rules that keep them from drifting.

Websites

Built to be read slowly and used for years.

Motion

Titles that move once, and mean it.

Rules
01
02
03
04
05
Crosses
01
02
03
04
05
One row, no lines outside
01
02
Right to left
01
02
03
04
05
Editing, rules
A sheet to arrange

Drag to move. Drag the corner to resize.

Enter / Space picks up or drops. Arrows move. Shift + arrows resize. Escape cancels.

Space
Type

Layout JSON
Holding, crosses
Space, picked up

Drag to move. Drag the corner to resize.

Enter / Space picks up or drops. Arrows move. Shift + arrows resize. Escape cancels.

Space
Type

Editing, right to left
مساحة للكلمات

Drag to move. Drag the corner to resize.

Enter / Space picks up or drops. Arrows move. Shift + arrows resize. Escape cancels.

مساحة
كلمات

Layout JSON
Content stays clickable
Choose a word, or drag it

Drag to move. Drag the corner to resize.

Enter / Space picks up or drops. Arrows move. Shift + arrows resize. Escape cancels.

Layout JSON

Choose a word.

Empty, ready for a tile
Your first arrangement

Drag to move. Drag the corner to resize.

Enter / Space picks up or drops. Arrows move. Shift + arrows resize. Escape cancels.

A little space.
Add your first tile.

Layout JSON

Install

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

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

Usage

tiling-example.tsx
"use client"import * as React from "react"import { Tile, Tiling, TilingEditor, type TilingLayout } from "@/components/ui/tiling"const word = "ot-fff font-medium text-(--ot-ink)"const note = "ot-pp max-w-[18ch] text-(--ot-graphite)"const arrangement: TilingLayout = [{ id: "less", label: "Less", column: 1, row: 1, span: 7, rows: 2 },{ id: "space", label: "Space", column: 9, row: 1, span: 4, rows: 3 },{ id: "but", label: "but", column: 1, row: 3, span: 4, rows: 1 },{ id: "better", label: "better.", column: 1, row: 4, span: 7, rows: 1 },]const small: TilingLayout = [{ id: "space", label: "Space", column: 1, row: 1, span: 4, rows: 1 },{ id: "type", label: "Type", column: 7, row: 2, span: 5, rows: 1 },]export default function Example() {const [layout, setLayout] = React.useState(arrangement)return (<div className="grid w-full gap-(--ot-space-9)"><TilingEditor label="Make room for your own arrangement" value={layout} onValueChange={setLayout} copyLayout />{/* One sheet after "Less is more.": the headline set a word to a tile, the notes kept to the corners, a large number at the foot. */}<Tiling aria-label="Less but better"><Tile span={8}><p className={note}>Fewer things, more meaning</p><p className={`${word} mt-(--ot-space-7)`}>Less</p></Tile><Tile span={4} rows={3} place="end-top"><p className={note}>Space does the layout</p><p className="ot-p mt-auto max-w-[16ch] text-(--ot-ink)">Take something away before adding anything, and the line that is left means more.</p></Tile><Tile span={8} place="start-foot"><p className={word}>but</p></Tile><Tile span={8} place="start-foot"><p className={word}>better.</p></Tile><Tile span={4} place="start-foot"><span aria-hidden="true" className="mb-(--ot-space-4) block h-(--ot-stroke) w-(--ot-space-5) bg-(--ot-ink)" /><p className={note}>Focus on what matters</p></Tile><Tile span={4}><p className="ot-pp text-(--ot-graphite)">Clarity<br />Purpose<br />Silence<br />Type</p></Tile><Tile span={4} place="end-foot"><p className="ot-ff font-light tabular-nums text-(--ot-ink)">01</p></Tile></Tiling>{/* crosses: the same space, no lines, a registration cross at every corner. */}<Tiling variant="crosses" aria-label="Services">{[["Identity", "Names, marks, and the rules that keep them from drifting."],["Websites", "Built to be read slowly and used for years."],["Motion", "Titles that move once, and mean it."],].map(([name, body]) => (<Tile key={name}><p className="ot-mf font-light text-(--ot-ink)">{name}</p><p className="ot-p mt-(--ot-space-3) max-w-[26ch] text-(--ot-graphite)">{body}</p></Tile>))}</Tiling></div>)}const cells = (n: number) =>Array.from({ length: n }, (_, i) => (<Tile key={i} span={i === 0 ? 8 : 4} place={i % 2 ? "end-foot" : "start-top"}><span className="ot-pp tabular-nums text-(--ot-graphite)">{String(i + 1).padStart(2, "0")}</span></Tile>))

Contract

  • Underneath: native CSS grid. Tiling and Tile keep their static server-rendered markup, responsive rules and crosses. TilingEditor is an opt-in interactive part in the same client entry; every part is server-renderable, with no browser work during render.

  • Anatomy: <div class="ot-tiling" data-variant="rules | crosses"> (the container its narrow layouts are measured against) holding .ot-tiling-sheet, the grid, of .ot-tiles (data-place="start-top | end-top | start-foot | end-foot", --span and --rows from span and rows). Each tile's lines are its ::before.

  • The type-led answer to the bento grid, after "Less is more.": tiles on the 12-column grid held apart by space (--ot-space-6) and one ink hairline, never boxed, filled or rounded. Named for the tiling of a plane, which leaves no gaps and no overlaps: neighbours share an edge, so each tile draws only its start and top edge, halfway into the gap, and one line serves both sides (the top line whole, the start line under it, so no pixel is drawn twice). The edges that would fall on the tiling's own border lie half a gap outside it and are clipped, so nothing frames the whole; the lines that stay run a step (--ot-space-2) past the edge, as a draughtsman's construction lines overrun, and focus rings keep that step of room.

  • A tile's content keeps to one corner (place), as the poster keeps its notes; the end corners set it flush to the end. A child with margin-block-start: auto goes to the foot, so one tall tile can hold a note at its head and a paragraph at its foot. Type is the content: a headline a word to a tile, standing at the foot on its line; a large number in the last corner.

  • Narrow (a tiling under 40rem, by container query), twelve columns' gaps alone would outgrow it, so the sheet falls to two columns: a tile of 6 columns or fewer takes one, a wider one both. Under 24rem it is one column, every tile the whole width and one row high.

  • variant (data-variant; none for the default, rules): crosses.

  • crosses: after "the uncreative", which marks the corners of its block with + signs. No lines: the space alone holds the tiles apart (Principle 1), and a registration cross in the pencil (--ot-space-3 arms) stands on every corner of every tile, where neighbours' crosses coincide; the outer ones stand half a gap outside the block, unclipped.

  • Right to left the start edge, and so the vertical lines, are on the right. Forced colours keep the lines and crosses in CanvasText.

  • Editor anatomy: .ot-tiling.ot-tiling-editor holds a named action group, a one-line pointer hint, a keyboard-focusable region, numbered columns and the sheet of focusable tiles. Twelve columns fit its container at every width, keeping saved coordinates stable and every resize corner reachable on touch. Fixed row tracks use space-8 through space-9, independent of content height.

  • Its move is the drafting sheet: large personal words in expression italic, faint construction guides (dots for crosses), and one separate hairline boundary per editable tile. These functional boundaries never share, overlap or use the static tiling's overrunning lines. A held tile lifts in ink without fill or shadow; a dotted accent hairline previews its landing cells. The end corner has a visible corner mark that grows on hover, within a space-6 hit area; all four edges offer resize marks on hover, selection or focus. A small fraction-style column × row figure accompanies the held tile, left to right and isolated.

  • TilingLayout is a plain array of { id, label, column, row, span, rows }, with exported TilingItem and TilingEditorProps. Coordinates are positive whole numbers from 1 at inline start; spans stay within twelve columns, rows at least 1. Ids and labels are non-empty, ids unique, rectangles non-overlapping. Invalid supplied layouts show an alert. Empty space is allowed. Moving onto a matching rectangle swaps the two tiles; other collisions keep neighbours' columns and reflow them down in reading order, preserving content and preventing overlap. Geometry clamps at the grid edges and one-cell minimum.

  • value, defaultValue and onValueChange offer controlled or uncontrolled data. Gestures preview locally, including reflow; pointer release or keyboard drop commits a fresh array. Escape or pointer cancellation discards the whole preview without changing saved data. A new external controlled value takes priority over a pending preview. Add and Remove commit directly. renderTile supplies content separately. copyLayout offers saved JSON on the page and the clipboard, with a selectable fallback when access fails. defaultHeld starts a holding specimen. New tiles take the first free four-column space, one row high.

  • Drag the whole tile with pointer or touch; six pixels distinguish a drag from a content click. Capture begins only after the threshold, and dragging suppresses the trailing click. Inputs, editable text and descendants marked data-tiling-no-drag retain their pointer interaction. Pull the end corner to resize both spans; edges change their own dimension, keeping the opposite edge fixed. RTL mirrors horizontal deltas, keys and the end corner. Click selects a tile for Remove; the selection remains after a drop. Add focuses the new tile, Remove focuses its neighbour or Add when empty.

  • Keyboard: Tab reaches each tile and resize control. Enter or Space picks up and drops. Arrows move, Shift + arrows resize; on a corner or edge control arrows resize that dimension. Escape discards the preview. Tab remains normal navigation. The pointer hint is one line, “Drag to move. Drag the corner to resize.” The keyboard line appears only while focus is within the sheet, without moving it. Focus is visible; a polite, atomic live region announces position, size, placement, cancellation, deletion, limits and clipboard results. Forced colours retain boundaries, guides, handles and Highlight landing marks.

  • Motion: Glide, answering edits only. The held tile follows the pointer directly; snapping moves the landing outline and reflows neighbours. Neighbours, keyboard edits and the released tile glide over moderato with breath. Resize snaps to whole tracks, with allegro corner growth. Mounts and external updates are still; reduced motion places geometry directly. States: static rules and crosses, editing, holding, RTL editing, interactive content and empty.

Props

Props of Tiling
PropTypeDefaultWhat it does
variant"rules" | "crosses""rules"rules: one ink hairline between neighbouring tiles, running a step past the tiling's edge, and none round the outside, so nothing is boxed. crosses: no lines at all; the space holds the tiles apart and a registration cross in the pencil marks every corner of every tile, the outer ones half a gap outside the block, as "the uncreative" marks its corners with + signs.
TileTilePropsOne tile, with native div props. In a tiling narrower than 40rem the grid falls to two columns: a tile of 6 or fewer takes one and a wider one both; under 24rem there is one column, and every tile is the whole width and one row high.
spannumber4On Tile: width in columns out of 12. The static grid caps its column span at 12; responsive layouts use this width to choose one or two columns.
rowsnumber1On Tile: how many grid rows it stands through. Below 24rem the static tiling makes every tile one row high. TilingEditor instead keeps the row span in its saved layout.
place"start-top" | "end-top" | "start-foot" | "end-foot""start-top"On Tile: the corner its content keeps to, as the poster keeps its notes to the corners; the end corners set the text flush to the end. A child with margin-block-start: auto goes to the foot, so one tile can hold a note at its head and a paragraph at its foot.
TilingEditordivOpt-in editor. All twelve columns fit the available width, including on touch screens; saved coordinates stay the same. Each tile has its own hairline outline. Faint guides, or construction dots for crosses, show the grid; a dotted accent outline previews the landing cells. Drag the tile body to move it. Pull the visible end corner or an edge to resize; the held tile shows its column × row span. Static Tiling and Tile keep their existing rules and crosses.
labelstringRequired on TilingEditor. Names the editor, its scrollable editing sheet and its JSON output.
value / defaultValueTilingLayout[]On TilingEditor: controlled or initial uncontrolled layout. Plain serialisable array of { id: string, label: string, column: number, row: number, span: number, rows: number }. Coordinates start at 1 from the inline start; span is 1–12 and rows is at least 1. Use unique non-empty ids and labels, positive whole geometry, and no overlaps. Invalid supplied layouts show an error. Empty space is allowed. A move onto a matching rectangle swaps the two tiles; other collisions reflow neighbours down without overlap. Movement and resizing stop at the grid edges and a one-cell minimum.
onValueChange(value: TilingItem[]) => voidCalled on pointer release or keyboard drop, and on addition or removal. Previewing and Escape cancellation do not change the saved JSON. In controlled mode, update value from the callback. A new external value cancels the preview and takes priority; external updates do not glide.
renderTile(tile: TilingItem) => ReactNodeOn TilingEditor: optional content separate from the serialisable geometry. By default the tile's label is yours, set large in the expression italic. Content stays clickable until a drag crosses six pixels; a drag suppresses the trailing click. Inputs, editable text and descendants with data-tiling-no-drag keep their own pointer interaction. The corner and edge controls remain outside the content.
copyLayoutbooleanfalseOn TilingEditor: offers Copy layout and an expandable, selectable Layout JSON output. If clipboard access is unavailable the output opens and is selected for manual copying.
defaultHeldstringOn TilingEditor: id of a tile to start picked up, for a holding specimen. Otherwise nothing is held or moves on mount.
Keyboard / pointerinteractionTab to a tile. Enter or Space picks up and drops; arrows move one column or row, Shift + arrows resizes. The corner and edge buttons also support pickup and arrow resizing. Escape discards the entire preview, including neighbour reflow. Drag the body with pointer or touch; release commits, pointer cancellation restores. RTL mirrors horizontal keys, pointer travel and the end corner. Click a tile to select it for Remove tile. Add tile finds free space and focuses it; Remove focuses the next tile, or Add when empty. Position, size and edit results are announced politely. The short keyboard hint appears only while the sheet has focus. Neighbours and drops glide at moderato; reduced motion places them directly.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Tiling"Less is more."; Paul Rand's overrunning construction linesTiles held apart by one shared hairline, none round the outside; notes kept to the corners
Crosses tiling"the uncreative" + corner signsNo lines: a registration cross on every tile's corners
Editable tilingPaul Rand's construction sheet; "the silence that heals" corner marksA tile lifts in ink, its landing cells dotted in the accent; a growing corner and small span figures make the geometry tangible

How it moves

How it moves
ComponentArticulationWhat moves
Editable tilingGlideThe held tile follows the hand; neighbours and the drop glide at moderato with breath. Resizing snaps; its corner grows at allegro. Reduced motion places directly; mounts and external updates stay still