Layout
Four helpers own the space between components: a stack, a cluster, a grid and a container. Reach for them before writing a flex or grid rule of your own.
When to Use
/app/ view, the page grid (.app-page) already sets the width, so skip the container there.
| Helper | Lays out | Reach for it when |
|---|---|---|
ws_stack_start | Children one above the other | Form fields, the sections of a panel, one column of cards |
ws_cluster_start | Children side by side, wrapping | An action row, a field beside its button, chips |
ws_grid_start | Equal columns, as many as fit | Repeated cards, tiles or stats |
ws_container_start | A centred column with gutters | A page or fragment that needs a maximum width of its own |
Stack
One spacing step between each pair of children. The stack owns that space, so its direct children lose their own top and bottom margins. Children fill the column's width, except a button or a bare link, which keeps its own. Anything not displayed, such as a hidden input at the top of a form, adds no space.
<?php ws_stack_start(['gap' => '5', 'tag' => 'form', 'attrs' => ['method' => 'post']]) ?>
<?= ws_input('title', ['label' => 'Workshop title']) ?>
<?= ws_input('goal', ['label' => 'What should the group leave with?', 'hint' => 'One sentence.']) ?>
<?php ws_cluster_start() ?>
<?= ws_button('Save workshop', ['type' => 'submit']) ?>
<?= ws_button('Cancel', ['variant' => 'ghost', 'href' => '/app/']) ?>
<?php ws_cluster_end() ?>
<?php ws_stack_end() ?>| Option | Values | Default | Purpose |
|---|---|---|---|
gap | 1 2 3 4 5 6 8 10 12 | 4 | The --space-N step between children |
tag | div section ul ol form | div | The element. A list gets role="list"; a form takes method and action through attrs |
id, class, attrs | — | — | Passed to the element |
Cluster
Children side by side, wrapping onto the next line when they run out of room. Items keep their own widths.
- Energisers
- Decision making
- Retrospectives
- Remote friendly
<?php ws_cluster_start(['justify' => 'between']) ?>
<?= ws_button('Back', ['variant' => 'ghost', 'icon' => 'arrow_back']) ?>
<?= ws_button('Continue', ['icon' => 'arrow_forward', 'iconPosition' => 'right']) ?>
<?php ws_cluster_end() ?>
<?php ws_cluster_start(['align' => 'end', 'grow' => 'first']) ?>
<?= ws_input('q', ['type' => 'search', 'label' => 'Search workshops by name']) ?>
<?= ws_button('Search', ['type' => 'submit', 'variant' => 'secondary']) ?>
<?php ws_cluster_end() ?>| Option | Values | Default | Purpose |
|---|---|---|---|
gap | 1 2 3 4 5 6 8 | 3 | The --space-N step between items, across and down |
justify | start center end between | start | Where the row sits; between pushes the first and last items to the edges |
align | start center end baseline stretch | center | Cross-axis alignment. end lines a button up with a labelled field's input |
grow | none first last | none | The item that takes the row's free width, keeping --grid-min-sm before the row wraps: a search field beside its button |
tag | div ul ol | div | A list gets role="list" |
id, class, attrs | — | — | Passed to the element |
Grid
As many equal columns as fit, never narrower than a column floor. There are no breakpoints: the grid drops a column as the screen narrows and reaches one column on a phone. A short last row keeps the width of a full one, so cards always match their neighbours.
Quarterly planning offsite
Two days, 14 people
Team retrospective
90 minutes, remote, with a silent-writing round before the discussion so the quieter half of the team is heard
Design sprint kickoff
Half day, in person
Onboarding workshop
2 hours, hybrid
<?php ws_grid_start(['tag' => 'ul']) ?>
<?php foreach ($workshops as $w): ?>
<li><?= ws_card('<h3>' . htmlspecialchars($w['title']) . '</h3>') ?></li>
<?php endforeach; ?>
<?php ws_grid_end() ?>| Option | Values | Default | Purpose |
|---|---|---|---|
min | sm md lg | md | The column floor: --grid-min-sm for compact tiles and stats, --grid-min-md for cards, --grid-min-lg for wide cards |
gap | 2 3 4 5 6 8 | 4 | The --space-N step between items |
tag | div section ul ol | div | A list gets role="list"; wrap each item in an li |
id, class, attrs | — | — | Passed to the element |
Container
A centred column at a --container-* maximum width with --space-4 gutters. It sets width only; space the parts inside it with a stack.
Notification settings
This column stops at --container-xs and stays centred.
<?php ws_container_start(['width' => 'sm']) ?>
<?php ws_stack_start(['gap' => '6']) ?>
<?= ws_section_header('Notification settings', ['tag' => 'h1', 'align' => 'left']) ?>
...
<?php ws_stack_end() ?>
<?php ws_container_end() ?>| Option | Values | Default | Purpose |
|---|---|---|---|
width | xs sm md lg xl 2xl | lg | The --container-* maximum width |
gutter | bool | true | false drops the side padding, for a container inside something that already pads |
tag | div section article | div | The element |
id, class, attrs | — | — | Passed to the element |
Real-World Usage
They nest. A settings page outside the app shell: a container for the width, a stack for the rhythm, a cluster for the actions. Every end closes the most recent start of its own kind.
<?php ws_container_start(['width' => 'sm']) ?>
<?php ws_stack_start(['gap' => '6']) ?>
<?= ws_section_header('Notification settings', ['tag' => 'h1', 'align' => 'left']) ?>
<?php ws_stack_start(['tag' => 'form', 'gap' => '5', 'attrs' => ['method' => 'post']]) ?>
<?= ws_input('email', ['type' => 'email', 'label' => 'Send notifications to', 'error' => $emailError]) ?>
<?php ws_cluster_start() ?>
<?= ws_button('Save settings', ['type' => 'submit']) ?>
<?php ws_cluster_end() ?>
<?php ws_stack_end() ?>
<?php ws_stack_end() ?>
<?php ws_container_end() ?>Accessibility
| Concern | Behavior |
|---|---|
| Order | None of the helpers reorders anything: reading order and Tab order follow the source, row by row in a grid. |
| Lists | With tag ul or ol the helper sets role="list", because removing the bullets drops list semantics in Safari. A screen reader then announces how many items there are. |
| Landmarks | No helper adds a role or landmark. Use tag section only with a heading inside it, and never in place of the page's main. |
| Reflow | Clusters wrap and grids drop columns, so nothing scrolls sideways at 320px (WCAG 1.4.10). |
Tokens
| Token | Used for |
|---|---|
--space-1 to --space-12 | Every gap step |
--grid-min-sm / --grid-min-md / --grid-min-lg | Grid column floors |
--container-xs to --container-2xl | Container widths |
CSS Classes
| Class | Purpose |
|---|---|
.ws-stack, .ws-stack--gap-N | Stack and its step |
.ws-cluster, .ws-cluster--gap-N, --justify-*, --align-* | Cluster |
.ws-grid, .ws-grid--gap-N, .ws-grid--min-sm, .ws-grid--min-lg | Grid |
.ws-container, .ws-container--{width}, .ws-container--flush | Container |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | The start and end helpers |
includes/components/components.css | Styles, between LAYOUT: START and LAYOUT: END |
design-system/platform-tokens.css | The --grid-min-* column floors |