Good spacing makes a screen easy to read: things that belong together sit close, and groups are set apart. So every gap and every padding comes from one short scale, every control comes in the same three heights, and the components adapt to the room they are given.
There is no grid component. A layout is put together from stacks, boxes, cards and a simple grid in your own CSS.
--s-14px
--s-28px
--s-312px
--s-416px
--s-524px
--s-632px
--s-748px
--s-864px
the scale
Every gap and padding is one of eight steps: 4, 8, 12, 16, 24, 32, 48 or 64 pixels. There is nothing in between, no 10 and no 20. A short scale keeps spacing the same from screen to screen, and it makes the right choice an easy one.
step
where the components use it
4
The smallest gaps inside a part; above and below the cells of a dense table and the text of a tooltip
8
Between the parts of a control (an icon and its label, a field's label, a control and its hint); between buttons in a row; above and below the cells of a table
12
Inside a medium field; above and below a card's header and footer; inside a banner; left and right in table cells
16
Inside a medium button; the body of a card or a dialog; inside a tile; the usual gap of a stack
24
Inside a large button; under the portal's title bar; between the toasts and the bottom of the screen
32
Between the portal's groups; around an empty state
48
The room a dialog leaves: it is never taller than the screen minus 48
64
Above and below a large empty state
The space between components, and around them, is always a step of the scale. Inside small parts, a few components nudge things by a pixel or a few, to centre a symbol or a label; the specs list them. Those nudges belong to the components: keep your own layout on the scale.
Space shows structure. Things that belong together sit closer than the groups they form: 8 inside a field, 16 between fields, 24 or more between sections.
control heights
Every control comes in the same three heights, so controls in a row line up.
size
height
where
Small
28
Toolbars, table rows and dense panels. A toolbar's actions are always small
Medium
34
The usual size: forms, the filter bar
Large
42
A single call to action on its own, a sign-in form
warning: degradedone disk is slow; reads still work
1.2 tb
zone a
yesterday, yesterday
storage-03storage-03.example.com
healthy
96 gb
zone b
today, today
dense · cells 4px 8px
volume
state
size
zone
last checked
storage-01storage-01.example.com
healthy
412 gb
zone a
today, today
storage-02storage-02.example.com
warning: degradedone disk is slow; reads still work
1.2 tb
zone a
yesterday, yesterday
storage-03storage-03.example.com
healthy
96 gb
zone b
today, today
Use the dense table for long lists that people scan and compare with a pointer, such as an inventory or a log.
Keep the usual density where rows carry a second line, such as a status with its reason, and where people use touch.
Use one density per view. A dense table under filters of the usual size is fine; two tables of different density side by side are not.
responsive behaviour
Three components change their layout according to their own width, not the screen's. A table in a narrow side panel on a wide screen behaves just like one on a phone, so a component works wherever it is placed.
The facets fold behind a “filters” button that says how many are set, “filters · 2”. Opened, they stack under the search, with “clear all” at their end
Every action except the emphasized one moves into the “more actions” menu
Move the slider: each component measures itself and changes on its own.
960px
5
5 volumes
volume
state
size
zone
last checked
storage-01storage-01.example.com
healthy
412 gb
zone a
today, today
storage-02storage-02.example.com
warning: degradedone disk is slow; reads still work
1.2 tb
zone a
yesterday, yesterday
storage-03storage-03.example.com
healthy
96 gb
zone b
today, today
db-0db-0.example.com
error: offlinenot answering since 12 oct
240 gb
zone b
5d ago, 5 days ago
worker-2worker-2.example.com
healthy
64 gb
zone c
2d ago, 2 days ago
The width that counts is the room inside the component, so its own padding and border count against it.
Until a component has measured itself, and wherever it can't measure, such as on a page rendered on the server or in automated tests, it shows its wide layout.
The filter bar's and the toolbar's widths can be changed; the data table's are fixed. There, a column's rank decides what goes first, and the column that names the row never pops in.
Everything else adapts by itself. The rows of the filter bar, the toolbar, a worklist's header, the portal's title bar and a banner wrap when they run out of room. filter tabs scroll sideways, with a fade on the edge where more are hidden. A dialog has a maximum width and keeps a margin from the screen's edges, and the toasts are never wider than the screen.
The components don't look at the screen's width at all, and use no breakpoints. The only setting of the device they follow is reduced motion.
Every screen works at 390 pixels wide, a phone held upright, and at 320 pixels, which is what a 1280-pixel window becomes at 400% zoom. The components get there on their own; a screen gets there when its own layout does too.
One column. When there isn't room for two columns, stack them. A grid that fits as many columns as there is room for falls back to a single column by itself; the specs have the rule.
Rank the columns. Give every data table column after the first an importance, so the table keeps its frame and the lesser values pop in under the row's name.
No fixed widths. A width on a panel or a field becomes a maximum width, so nothing grows wider than its container.
The same actions. A toolbar folds its actions into its menu, and the filter bar its facets behind one button. Nothing is left out at phone width, and nothing works only with hover.
Test it. At 390 pixels and at 400% zoom, in the browser's responsive mode, with the keyboard as well as with touch.
grid and layout
There is no grid component, no column system and no breakpoints. A layout is put together from a few parts:
Stack: one direction, with a gap from the scale. It does most of the work.
Box: padding from the scale, and a canvas background.
A grid in your app's CSS, for rows and columns, with gaps from the scale.
The tile group is the one component with a grid of its own: as many columns as fit, or a set number of columns.
Storage · 3 volumes
storage-01
412 GB used, checked today.
storage-02
1.2 TB used, one disk slow.
db-0
240 GB used, offline since 12 Oct.
accessibility
Even the small controls are taller than the smallest target allowed, 24 pixels. The checkbox's small box and the switch count their label as the target; boxes without a label in a list need 24 pixels from one to the next.
At 400% zoom, a 1280-pixel window is 320 pixels wide. The components that respond to their own width then behave as they would in a narrow panel: only the most important columns stay, the facets fold, and the actions move into the menu.
A column that pops in is still read out: its label and value are text under the row's first cell, not hidden.
In your own layouts, keep the order on the screen the same as the order in the page's code, without reordering anything with CSS. Then what people see, what they hear and the order of the tab stops all agree.
do and don't
do
Controls in one row share a size, so their edges line up.
don't
A medium field next to a small button: the edges don't line up.
do
volume
state
size
zone
last checked
storage-01
healthy
412 gb
zone a
17 oct 2026
storage-02
degraded
1.2 tb
zone a
16 oct 2026
db-0
offline
240 gb
zone b
12 oct 2026
Columns ranked by importance: in a narrow frame, the lesser ones pop in under the volume's name.
don't
volume
state
size
zone
last checked
storage-01
healthy
412 gb
zone a
17 oct 2026
storage-02
degraded
1.2 tb
zone a
16 oct 2026
db-0
offline
240 gb
zone b
12 oct 2026
The same narrow frame without a ranking: every column stays, the values wrap, and the table can run past its frame.
do
Back up
Notify me about
Closer inside a group than between groups, so the groups read as groups.
Eight steps, --s-1 to --s-8, from tokens/tokens.json.
token
px
--s-1
4
--s-2
8
--s-3
12
--s-4
16
--s-5
24
--s-6
32
--s-7
48
--s-8
64
The nudges inside small parts: 2px in the switch track and between a filter tab's count and its label; 3px to sit a glyph on the first line of text; 6px between a status glyph and its text, around a tooltip, and inside inline code and kbd.
control heights
size
in code
height
label
padding: button / field
Small
sm
28px
12px (button), 13px (field)
12px / 8px
Medium
md
34px
13px
16px / 12px
Large
lg
42px
14px
24px / 16px
Button and Input take size and inputSize, Select selectSize, IconButton size (a square: 28, 34 or 42px). MultiSelect has sm and md only.
The checkbox box is 16px; the switch track is 20px high (md) or 16px (sm).
Outside the three heights: action menu items are at least 32px high, the portal's search field is 32px, and a modal's close button 26px.
Textarea: rows, 4 by default.
density
component
in code
default
dense
Data table
dense
Cells 8px 12px
Cells 4px 8px
Table
dense
Headers 8px 12px, cells 12px
4px 8px
Portal
density="compact"
24px under the title bar, 32px between groups
16px, 24px
Tile
size="sm"
16px padding, 12px gap
12px padding, 8px gap
Controls
sm
34px (md)
28px
responsive
component
at its own width
what changes
Data table
720px and below
Columns with importance: 'low' pop in
480px and below
importance: 'medium' columns pop in too; the cells' side padding drops to 8px; a long group label wraps
Filter bar
Below 640px (collapseBelow)
The facets fold behind a “filters · n” button
Toolbar
Below 560px (collapseBelow)
Every action but the primary one moves into the “more actions” menu
Measured with a ResizeObserver: the element's content box, rounded to the pixel. The toolbar has 12px of padding and a 1px border on each side, so its actions move while its outer edge is still under 586px; the data table's 1px border puts its steps at 722 and 482px outside.
Without a measurement, and where ResizeObserver doesn't exist (server rendering, jsdom tests), the wide layout shows.
collapseBelow moves the filter bar's and the toolbar's threshold. The data table's 720 and 480 are fixed; the first column, or the one marked rowHeader, never pops in.
The library has no viewport media queries; its only media queries are for prefers-reduced-motion.
CSS only: a modal is at most 480px wide (width) and 16px from the edges; the toaster is at most the viewport's width minus 32px.
Tile group: repeat(auto-fill, minmax(14rem, 1fr)) by default (minTileWidth), or a fixed columns count, with gaps of 4, 8, 16 (default) or 24px.
a grid that falls to one column
/* columns at least 12rem wide, as many as fit; one column on a narrow screen. */.volumes { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); gap: var(--s-4);}
how to apply it
CSS.var(--s-1) … var(--s-8), from tokens.css.
Box and stack.p, px, py and gap take the step in px: 0, 4, 8, 12, 16, 24, 32, 48 or 64. TypeScript rejects any other number; at run time an off-scale number resolves to 0.
JavaScript. From @nalet/design-system, space is the helper behind those props: space(16) returns 'var(--s-4)'. The values themselves are tokens.space (tokens.space['s-4'] is '16px'), or space from @nalet/design-system/tokens.
Tailwind. The preset sets the spacing keys 1–8 (and s-1–s-8) to the scale, so p-5 is 24px here, not Tailwind's 20px, and gap-8 is 64px. Tailwind's other spacing keys (0.5, 10, 12 …) are still there and off the scale: don't use them.
Order. Keep the DOM order the visual order: no order, no reversed flex directions.