Layout
Layout in tenet-ui is composed from two primitives, Stack (one axis) and Grid (columns), plus the space scale. Margins between siblings do not exist in app code; the parent’s gap owns the rhythm. This page also settles the recurring question of when a block of content is a Card and when it is a plain section.
- Layer
- Foundation
- Rules
- 14 numbered
- Examples
- 8 do and don’t
- Updated
- Source
- layout.md
Space scale
--space-14px--space-28pxgap="condensed"--space-312px--space-416pxgap="normal"--space-524pxgap="spacious"--space-632px
Page skeleton
Sections are spacious apart, tiles and fields normal, controls in a toolbar condensed.
Breakpoints
- Any two siblings with space between them sit inside a
Stack(one axis) or aGrid(columns). App code never setsmargin,padding,display: flex, ordisplay: gridon its own wrappers to space children. gapis the same scale everywhere:none(0),condensed(--space-2, 8px),normal(--space-4, 16px),spacious(--space-5, 24px).Stackgap,GridgapandrowGap, andStackpaddingmap exactly to these values.Cardpaddinguses the same four names but itscondensedstep is--space-3(12px), because a card edge needs more inset than a gap between siblings.- The space scale is
--space-1(4),--space-2(8),--space-3(12),--space-4(16),--space-5(24),--space-6(32). Nothing is spaced by a value off this scale. - Default rhythm:
gap="condensed"between controls in a toolbar or a button row;gap="normal"between fields and between items in a list;gap="spacious"between page sections. Stackis vertical by default;direction="horizontal"is for toolbars, button rows, and label-plus-value pairs.Stack.Item growis the one child that absorbs remaining width (a search field in a toolbar).wrapis on for horizontal stacks of chips or tags.Gridis for columns.columnsis a number or a responsive object{ base, sm, md, lg };Grid.Item spanspans columns. Mobile first:baseapplies below--breakpoint-smand each key applies from its breakpoint up.- Breakpoints are
--breakpoint-sm(640px),--breakpoint-md(960px),--breakpoint-lg(1280px). Prefer the responsivecolumnsobject over media queries. A CSS@mediarule cannot readvar(), so when an app must write one it uses the same pixel value with a comment naming the token. - Container widths: page content is capped at
--breakpoint-lg(1280px) and centered. A form or a reading column is capped at--breakpoint-sm(640px), which keepsTextat 60 to 75 characters per line.Dialogwidth comes from itssize(small,medium,large), never from an override. - Card or plain section, the decision test: content is a
Cardwhen it is a discrete, self-contained object that would still make sense lifted out of the page on its own (a summary tile with one number, a settings group with its own save action, a list container, a pinned item). Content is a plain section (HeadingplusStack) when it flows with the page and only makes sense in sequence with what is above and below it (a page section, a form’s fields, prose). If removing the border would lose nothing, it is a section. - Corollaries of the test: a
Cardnever contains anotherCard; a page is never a single full-widthCardwrapping everything; summary tiles in aGridareCards; the fields of one form are aStack, not oneCardper field. Cardpadding="none"when the children own their padding (Table.Container, a list whose rows are padded). Otherwisepadding="normal"(default) orcondensedfor dense tiles.Dividermarks a boundary that means something (menu groups, an “or” between choices). Vertical rhythm comes fromgap, never from stacked dividers or empty elements.- Fields fill their column with
fullWidthinside aGrid.ItemorStack.Item. Widths are never set in pixels on a field. - Alignment uses
Stackalignandjustify; there are no wrapper<div>s added only to center or right-align a child.
Page skeleton
Section titled “Page skeleton”| Layer | Component | Gap |
|---|---|---|
| Page | <Stack gap="spacious"> | sections |
| Section | <Heading level={2}> + <Stack gap="normal"> | items |
| Tiles | <Grid columns={{ base: 1, sm: 2, lg: 4 }} gap="normal"> of Card | tiles |
| Toolbar | <Stack direction="horizontal" gap="condensed" align="center"> | controls |
| Form | <Stack gap="normal"> of FormControl | fields |
Do / Don’t
Section titled “Do / Don’t”Do
<Grid columns={{ base: 1, sm: 2, lg: 4 }} gap="normal"><Card padding="condensed">…</Card></Grid>Tiles are discrete objects, so they are cards
<Stack gap="spacious"><Heading level={2}>Recent stories</Heading><Table.Container>…</Table.Container></Stack>A page section flows with the page, so it has no card
<Stack direction="horizontal" gap="condensed" align="center"><Stack.Item grow><TextInput aria-label="Search stories" fullWidth /></Stack.Item><Button>Filters</Button></Stack>Toolbar rhythm from gap, growth from Stack.Item
<Card padding="none"><Table.Container>…</Table.Container></Card>A list container whose child owns its padding
Don’t
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>Raw flex, off-scale gap, margin between siblings
<Card><Card>…</Card></Card>Nested cards; the inner one is a section
<Card><Heading level={1}>Newsroom</Heading>…entire page…</Card>A page is not a card
<TextInput style={{ width: 320 }} />Pixel width on a field; use fullWidth in a sized Grid.Item