Canvas Design System
Main Site Tokens

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

Use when: Components sit beside or above one another and the space between them should be a token step: the fields and actions of a form, a row of buttons, a grid of workshop cards, a settings column that should not run the full width of the screen.
Don't use when: The space is inside one component, which already spaces its own parts. Inside an /app/ view, the page grid (.app-page) already sets the width, so skip the container there.
HelperLays outReach for it when
ws_stack_startChildren one above the otherForm fields, the sections of a panel, one column of cards
ws_cluster_startChildren side by side, wrappingAn action row, a field beside its button, chips
ws_grid_startEqual columns, as many as fitRepeated cards, tiles or stats
ws_container_startA centred column with guttersA 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.

One sentence. The planner uses it to pick exercises.

Cancel
<?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() ?>
OptionValuesDefaultPurpose
gap1 2 3 4 5 6 8 10 124The --space-N step between children
tagdiv section ul ol formdivThe 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() ?>
OptionValuesDefaultPurpose
gap1 2 3 4 5 6 83The --space-N step between items, across and down
justifystart center end betweenstartWhere the row sits; between pushes the first and last items to the edges
alignstart center end baseline stretchcenterCross-axis alignment. end lines a button up with a labelled field's input
grownone first lastnoneThe item that takes the row's free width, keeping --grid-min-sm before the row wraps: a search field beside its button
tagdiv ul oldivA 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() ?>
OptionValuesDefaultPurpose
minsm md lgmdThe column floor: --grid-min-sm for compact tiles and stats, --grid-min-md for cards, --grid-min-lg for wide cards
gap2 3 4 5 6 84The --space-N step between items
tagdiv section ul oldivA 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() ?>
OptionValuesDefaultPurpose
widthxs sm md lg xl 2xllgThe --container-* maximum width
gutterbooltruefalse drops the side padding, for a container inside something that already pads
tagdiv section articledivThe 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

ConcernBehavior
OrderNone of the helpers reorders anything: reading order and Tab order follow the source, row by row in a grid.
ListsWith 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.
LandmarksNo helper adds a role or landmark. Use tag section only with a heading inside it, and never in place of the page's main.
ReflowClusters wrap and grids drop columns, so nothing scrolls sideways at 320px (WCAG 1.4.10).

Tokens

TokenUsed for
--space-1 to --space-12Every gap step
--grid-min-sm / --grid-min-md / --grid-min-lgGrid column floors
--container-xs to --container-2xlContainer widths

CSS Classes

ClassPurpose
.ws-stack, .ws-stack--gap-NStack 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-lgGrid
.ws-container, .ws-container--{width}, .ws-container--flushContainer

Files

FilePurpose
includes/components/helpers.phpThe start and end helpers
includes/components/components.cssStyles, between LAYOUT: START and LAYOUT: END
design-system/platform-tokens.cssThe --grid-min-* column floors