skip to content
menu
foundations · layout

layout

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
sm · 28px
md · 34px
lg · 42px
  • Buttons, text fields, selects and icon buttons come in all three heights; an icon button is a square. The multi select comes in small and medium only.
  • A checkbox's box and a switch's track are smaller than the controls, but their whole label is the target.
  • A few things sit outside the three heights: the items of an action menu, the portal's search field and a dialog's close button.
  • A text area has no fixed height. Its number of rows sets it, four unless you choose another.

density

Density is how tightly a component is packed. It is set on each component that has it; there is no switch for a whole page.

component what gets tighter
Data table The space inside its cells
Table The space inside its headers and cells
Home page, compact The space under the title bar and between the groups
Tile, small Its padding, and the gap inside it
Controls, small Their height: 28 instead of 34
default · cells 8px 12px
volumestatesizezonelast checked
storage-01storage-01.example.comhealthy412 gbzone a, today
storage-02storage-02.example.comwarning: degradedone disk is slow; reads still work1.2 tbzone a, yesterday
storage-03storage-03.example.comhealthy96 gbzone b, today
dense · cells 4px 8px
volumestatesizezonelast checked
storage-01storage-01.example.comhealthy412 gbzone a, today
storage-02storage-02.example.comwarning: degradedone disk is slow; reads still work1.2 tbzone a, yesterday
storage-03storage-03.example.comhealthy96 gbzone b, 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.

component when it gets narrow what changes
Data table Narrow The columns ranked low pop in: they leave the header row and show under each row's first column, each with its label
Narrower still The columns ranked medium pop in too, the cells get less space at their sides, and a long group name wraps instead of being cut short
Filter bar Narrow 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
Toolbar Narrow 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
volumestatesizezonelast checked
storage-01storage-01.example.comhealthy412 gbzone a, today
storage-02storage-02.example.comwarning: degradedone disk is slow; reads still work1.2 tbzone a, yesterday
storage-03storage-03.example.comhealthy96 gbzone b, today
db-0db-0.example.comerror: offlinenot answering since 12 oct240 gbzone b, 5 days ago
worker-2worker-2.example.comhealthy64 gbzone c, 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.

The exact widths are in the specs.

phone width

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.
  • Card: a group with a title and the usual padding.
  • 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
volumestatesizezonelast checked
storage-01healthy412 gbzone a17 oct 2026
storage-02degraded1.2 tbzone a16 oct 2026
db-0offline240 gbzone b12 oct 2026
Columns ranked by importance: in a narrow frame, the lesser ones pop in under the volume's name.
don't
volumestatesizezonelast checked
storage-01healthy412 gbzone a17 oct 2026
storage-02degraded1.2 tbzone a16 oct 2026
db-0offline240 gbzone b12 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.
don't
Back upNotify me about
One gap for everything: where does a group end?
  • Stack: gaps from the scale, in one direction.
  • Box: padding from the scale.
  • Data table: column importance, pop-in and density.
  • Worklist: a whole page built from these parts.
  • Shape: the frames these spaces sit in.

specs

the scale

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.