Grid visual editor — reference
The CSS grid editing surface. Two synchronized surfaces edit the same grid: the inspector renders template strings as structured track rows, and the canvas makes the selected grid box directly manipulable — draggable track boundaries, gap pills, per-track chips. Underneath, a grid's templates are still plain CSS strings — everything here is a view over them, so anything CSS grid can express survives round-trips through the editor.
Try it: draw a box (B), switch Display to Grid. The inspector shows Columns/Rows as rows; the canvas shows chips above/left of the box and resize strips on every interior boundary.
User-facing tour with screenshots: the grid & subgrid tour — what each part of the chrome and inspector does.
The two surfaces
Inspector
- Track lists — one list per axis. Header: axis label, a scrubbable count field (1–100; growing appends copies of the last track, shrinking drops from the end), a code toggle (raw CSS text mode), and
+. Each track row is a scrubbable number + unit menu (fr/px/%/auto/…) plus a hover-revealed remove button (disabled, not hidden, at one track).- Unit switches are extent-preserving: with a single node selected the control converts through the track's resolved px, so the rendered layout doesn't jump — only the notation changes.
autodisables the field and shows the resolved px grayed.…(custom) flips the row to free CSS text — this is how you typeminmax(40px, 1fr)into one track. Values that could be expressed as plain numbers stay text until you switch them back; nothing is lost.
- Alignment — the 3×3 marker writes
justify-items+align-itemsin one entry; two standalone per-axis toggles carry stretch (the CSS default). Content distribution is one "Distribute" row of paired selects; a section note appears when a non-start/stretch value gates canvas dragging (see the per-axis canvas gate). - Placement — shown when every selected node's parent is a grid box. The area picker renders the shared parent's explicit tracks as a mini-grid (≤12 tracks per axis): click writes a single cell, drag paints a rect (
"2 / 5"— CSS line numbers, end-exclusive), one history entry per gesture; the header minus clears to auto flow. Spans, negative and named lines don't highlight in the picker but work through the rawgrid-column/grid-rowinputs kept below. - Item alignment with a scope toggle (Placement section) — by default the Align X / Align Y rows edit the parent grid's
justify-items/align-items(the whole grid — how plain CSS aligns things: on the container). The row-end toggle switches them to Self X / Self Y — per-itemjustify-self/align-selfoverrides with a dashed auto segment. Toggling back off clears the overrides ("Follow grid alignment", one entry). CSS grid has no per-row/column alignment property, so "align the whole second column to the bottom" is: multi-select that column's children, toggle to Self, set Y once (writes fan out; one history entry). Items that already carry overrides open in override mode, so state is never hidden. - Subgrid — when a grid box itself sits in a grid, each axis header grows a subgrid toggle. On: the axis template becomes
subgrid— the box adopts the parent's tracks for perfectly aligned nesting. Off: the axis gets one1frper track it was rendering, so the layout doesn't jump. While subgridded, the track rows are replaced by a card ("Columns come from the parent grid") with an Edit parent jump; count and+disappear (the tracks aren't this box's to edit); the code toggle stays as the raw-text escape hatch for line-name lists (subgrid [a] [b]).
Canvas
Chrome appears for the single selected grid box, or the grid parent of a single selected child — you never have to deselect your item to adjust the grid it sits in. Everything keeps a constant size on screen at any zoom.
- Boundary strips — one per interior boundary, centered in the gap, a resize cursor and an accent line revealed on hover. Dragging resizes the adjacent track pair (math below) with a badge riding the boundary (
1.39fr · 0.61fr). Boundaries whose neighbors aren't plainly numeric (minmax, fit-content, calc…) shownot-allowed— edit those in the inspector. - Gap pills — at the top/left of each gap band; dragging scrubs the axis gap (one gap per axis — all bands move together). Gap creation from zero stays in the inspector: at gap 0 the boundary strip owns the space.
- Track chips — authored sizes above each column / left of each row (
1fr,240px,mmfor minmax,fit,…for other CSS; full value in the tooltip). Hover reveals a remove ×; tracks too narrow skip their chip. A+chip past the last track adds a track inside the box's extent — adding never resizes the box: a flexible track absorbs the new one, rigid px/%/auto templates re-fit proportionally (autos pinning to px), and only a hug axis that can really grow grows — by content, as any added content would. The inspector's header+is the same operation on a different surface. - Cross-surface highlight — hovering a chip tints the track's full extent on canvas and lights the matching inspector row; hovering an inspector row tints the canvas track.
- Subgrid adopted-track chrome — a subgrid axis renders the parent tracks it spans as its own chrome: dashed lines at the adopted interior boundaries and line numbers renumbered from 1 (what placement inside the subgrid counts). Dragging an adopted boundary forwards to the parent's template — same badge, undo, and Escape behavior as dragging on the parent itself. No chips,
+, or gap pills on a subgridded axis — track structure belongs to the parent. A subgrid shrink-wrapped off the parent's track edges (self-alignment other than stretch) gets no adopted chrome rather than drifting chrome.
Interaction reference
Global rules first: every continuous gesture is exactly one undo step; Escape during a live drag cancels just the drag — the document reverts to the exact pre-drag state and the selection is kept; typed fields commit on Enter/blur and revert on Escape.
Canvas (single grid box selected, or a single child of one)
| Affordance | Trigger | Effect | History / cancel |
|---|---|---|---|
| Boundary strip (invisible until hover; accent line; resize cursor) | drag | Resizes the adjacent track pair with unit-aware math (fr redistributes, px transfers, auto pins to px); badge shows live values (1.4fr · 0.6fr) | One step; Escape restores the exact template string |
Boundary strip, not-allowed cursor | hover | Tooltip: numeric tracks resize on canvas — minmax/fit-content/calc neighbors edit in the panel | — |
| Gap pill (at the top/left of each gap) | drag | Scrubs the axis gap (one gap per axis — all bands move) | One step; Escape restores |
| Track chip (size pill above a column / left of a row) | hover | Tints the track on canvas and lights its inspector row | — |
Track chip × (revealed on chip hover) | click | Removes that track (hidden at one track; chips also hide on very narrow tracks) | One step |
+ chip (past the last column / below the last row) | click | Adds a track without resizing the box: a flexible track absorbs the squeeze, rigid px/%/auto templates re-fit proportionally (autos pin to px), and only a hug axis that really grows grows — by content | One step (template only) |
| — | select a child of a grid | The parent's chrome appears, but boundary strips shrink into the gaps (none at gap 0) so children stay clickable; the grid box itself gets full-height strips | — |
| Subgrid boundary strip (on a subgrid axis of the selected box) | drag | Resizes the parent grid's adjacent track pair — same math, badge, and coalescing as dragging on the parent itself | One step on the parent's template; Escape restores its exact string |
| Subgrid line numbers (accent badges, renumbered from 1) | hover | Title explains: placement inside this box counts from here | — |
Canvas chrome appears per axis only when the template parses, no implicit tracks exist, and that axis's Distribute is start/stretch — a gated axis keeps passive guides and the inspector notes why (see the per-axis canvas gate).
Inspector — track lists (Columns / Rows)
| Affordance | Trigger | Effect | History |
|---|---|---|---|
| Count field | type + Enter · ↑/↓ (Shift ×10) · drag to scrub | Sets track count: grows by duplicating the last track, shrinks from the end (1–100) | Typed/stepped: one step each; scrub: one step total |
| Track row number | drag to scrub (Shift ×10, Alt ×0.1) · ↑/↓ in the field | Scrubs the track's value in its own unit (fr steps by 0.1) | Scrub: one step total |
| Unit menu (fr / px / % / auto / …) | select | Converts through the track's resolved size so the layout doesn't jump (single selection; defaults otherwise). auto shows resolved px grayed; … flips the row to free CSS text (minmax, calc…) | One step (no-op if identical) |
Row − (revealed on row hover) | click | Removes the track (disabled at one track — visible, not hidden) | One step |
+ (header) | click | Adds a track without resizing the box — same behavior as the canvas + chip | One step |
Code toggle (header, <> icon) | click | Swaps the rows for the raw grid-template-* text input (full syntax escape hatch); forced automatically for auto-fill/subgrid/mixed selections | UI-only |
| Subgrid toggle (header; only when this grid box's parent is a grid) | click | On: axis adopts the parent's tracks (subgrid) — the rows become a card with an Edit parent jump; off: restores one 1fr per currently-resolved track | One step each way |
| Edit parent (on the subgrid card) | click | Selects the parent grid — the inspector flips to the tracks this axis adopts | Selection only |
| Track row | hover | Tints that track on canvas (and vice versa) | — |
Inspector — alignment & spacing (grid box selected)
| Affordance | Trigger | Effect | History |
|---|---|---|---|
| 3×3 marker | click a dot | Writes justify-items + align-items together (exits stretch on both axes) | One step |
| Stretch toggles (per axis, next to the marker) | click | That axis stretches (marker centers, bars fill); click again → start | One step |
| Distribute selects (X / Y) | select | justify-content / align-content; center/end/space-* values gate canvas dragging for that axis and show a note saying so | One step |
| Gap fields | type · scrub | Axis gaps (same values the canvas pills scrub) | Scrub: one step total |
Inspector — Placement (child of a grid selected)
| Affordance | Trigger | Effect | History |
|---|---|---|---|
| Area picker (parent's tracks in miniature) | click a cell | Places the item there (grid-column/row line numbers) | One step |
| Area picker | drag across cells | Spans the painted rect ("2 / 5" style) | One step per gesture |
Header − | click | Clears placement — back to auto flow (disabled when nothing is placed) | One step |
| Align X / Y rows (default scope) | click a segment | Edits the parent grid's items alignment — the whole grid, like plain CSS; same property as the grid's own 3×3 | One step (writes the parent) |
| Scope toggle (item-in-cell icon, row end) | click | Switches the rows to Self X / Y — per-item justify-self/align-self with a dashed auto segment. Items that already carry overrides open in this mode | UI-only |
| Scope toggle (lit) | click | Clears the overrides — the item follows the grid again | One step ("Follow grid alignment") |
| Self X / Y segments | click | Per-item override for that axis; auto = inherit. Multi-select fans out (per-column workflow: select the column's items, toggle, set once) | One step |
| Col / Row text fields | type + Enter | Raw grid-line syntax — spans, negative and named lines (span 2, 1 / -1) | One step |
Everything the inspector and canvas both express (gaps, track sizes, items alignment) is the same underlying value — editing in one place moves the other live, including mid-drag.
How templates are treated
A grid's grid-template-columns/rows are stored as real CSS strings, and the editor is a view over them:
- Documents round-trip byte-identical until you actually edit.
- **Integer
repeat(n, …)expands to n tracks when displayed.* The abstraction is dropped by the first structural edit* (the saved form is the expanded list); until then the stored string keeps itsrepeat(). - Numbers save with ≤3 decimals, trailing zeros trimmed; unitless
0reads as0px. - Exotic values (
calc(),clamp(),em…) are held verbatim, so they survive edits to other tracks untouched.
Degradation ladder (each level keeps every lower level reachable):
| Template | Inspector | Canvas |
|---|---|---|
| fr/px/%/auto tracks | numeric rows, scrub + unit menu | full: drag, chips, add/remove |
| minmax/fit-content/other track | that row is a text field | chip says mm/fit/…; its boundaries don't drag |
| subgrid | labeled card ("tracks come from the parent grid") with an Edit parent jump; count/+ hidden; raw text behind the code toggle | adopted-track chrome: dashed lines + subgrid-relative numbers; boundary drags edit the parent's template (absent when the parent is gated or the box is shrink-wrapped off the track grid) |
auto-fill/auto-fit, none, unbalanced | whole axis is a text input + "no fixed track count" note; count field disabled | axis chrome absent |
| mixed multi-select | text input with Mixed placeholder (writes fan out) | absent (single-target only) |
repeat(n>100, …) | text fallback (expansion would be useless as UI) | absent |
Nothing ever becomes uneditable — it just degrades to CSS text.
Boundary drag math
Dragging a boundary resizes only the adjacent track pair; other tracks stay visually pinned. The math is unit-aware:
- fr / fr — the pair's total fr is conserved and redistributed by resolved proportion.
- px / px, % / % (and mixes of absolute units) — the drag transfers space between them, clamping at 0 with the residual going to the other side so the sum holds. Percent writes back relative to the box's content size.
- px / fr (either order) — only the absolute side changes; the fr side reflows through free space, which keeps other fr tracks' ratios.
- auto — converts to px at its current size the moment you drag it; the track stays px afterwards.
The whole drag measures against its starting state, so the values you see in the badge never feed back into themselves mid-gesture.
The per-axis canvas gate
Browsers expose resolved track sizes, never positions — geometry can only assume tracks start at the content edge, which is true for justify/align-content: start | stretch and wrong under center, end, and space-*. Rather than draw drifting chrome, each axis independently enables canvas editing only when:
- its template parses,
- no implicit tracks exist (nothing has overflowed the explicit grid), and
- its content distribution is start or stretch.
A gated axis keeps its passive guides, and the inspector shows "Distributed tracks resize here, not on canvas" right under the Distribute row — the setting that causes the gate explains it.
Escape semantics
Escape during a live drag cancels just the drag — the document restores to the exact pre-drag state (including an unexpanded repeat() string) and the selection is kept. Escape clears the selection only when nothing is in flight.
Known limits
- No track drag-to-reorder or between-track canvas insertion (the inspector covers insertion;
+appends). - The area picker doesn't visualize spans, negative or named lines (the raw inputs do the work); implicit tracks aren't shown.
- No canvas editing under
center/end/space-*distribution (needs real track positions, which browsers don't expose). - Subgrid adopted geometry assumes the parent's gap and no subgrid padding: a subgrid's own gap override shifts item edges within the adopted tracks, which the dashed lines don't reflect. Shrink-wrapped subgrids get no adopted chrome by design.
- Named lines are preserved through every edit but have no dedicated UI (
subgrid [a] [b]line-name lists are raw-text-only). - Per-gap widths can't exist (one gap per axis) — gap pills all move together by design.
- Self-alignment is grid-only for now;
align-selffor flex children is a separate (smaller) feature since flex ignoresjustify-self.