Composition Examples
Screen recipes first: whole screens built from real components, each with its loading, empty and error states. Then smaller pairs of components that often sit together. Every example renders the production helpers from fixture data, and the layout classes come from recipes.css, which uses tokens only.
Screen recipes
Start here when you are building a page rather than one component. npm run ds:find -- "a page listing workshops with filters" and the enforcer's suggest_component return these for screen-level requests. Each state has its own preview address, so you can open it alone and resize the window.
Filtered workshop list
A page listing workshops (or any library items) that people narrow down with search and filters, with loading, no-match and failed-load states.
Open the default state on its own to try it at any width.
Components used
ws_section_header() ws_filter_bar() ws_skeleton() ws_alert() ws_empty_state() ws_card_start() ws_card_end() ws_duration() ws_category() ws_button() ws_stack_start() ws_stack_end() ws_grid_start() ws_grid_end() ws_cluster_start() ws_cluster_end() ws_text() PHP
The whole recipe file, design-system/recipes/filtered-workshop-list.php. Swap the fixture arrays for the caller's own records and keep the four states.
<?php
/**
* Recipe: filtered workshop list.
*
* Needs includes/components/helpers.php and includes/components/components.css,
* which styles the layout helpers. $state is
* default | loading | empty | error. The arrays are fixtures: a real page reads
* the caller's own plans (WHERE user_id = ?) and passes the chosen filters back
* as `value` and `activeChips`.
*/
$state = $state ?? 'default';
$workshops = [
['title' => 'Quarterly planning offsite', 'duration' => '3 hours', 'category' => 'Strategy', 'href' => '#plan-101'],
['title' => 'Sprint retrospective', 'duration' => '60 min', 'category' => 'Retrospective', 'href' => '#plan-102'],
['title' => 'New team kickoff', 'duration' => '90 min', 'category' => 'Team building', 'href' => '#plan-103'],
];
$filtered = $state === 'empty';
// ws_filter_bar uses each label as the select's empty "any" option, so the lists hold real values only.
$filters = [
['name' => 'category', 'label' => 'Category', 'value' => $filtered ? 'energiser' : '', 'options' => [
'strategy' => 'Strategy', 'retrospective' => 'Retrospective', 'team' => 'Team building', 'energiser' => 'Energiser',
]],
['name' => 'length', 'label' => 'Length', 'value' => '', 'options' => [
'short' => 'Under an hour', 'half' => 'Half a day', 'day' => 'A full day',
]],
];
?>
<?php ws_stack_start(['gap' => '5']) ?>
<?= ws_section_header('Your workshops', [
'tag' => 'h2',
'align' => 'left',
'id' => 'your-workshops',
'description' => 'Filter by category or length to find the one you want to run next.',
]) ?>
<?= ws_filter_bar($filters, [
'search' => ['placeholder' => 'Search your workshops', 'name' => 'q'],
'activeChips' => $filtered ? [['label' => 'Energiser', 'name' => 'category', 'value' => 'energiser']] : [],
]) ?>
<?php if ($state === 'loading'): ?>
<p class="ws-visually-hidden" role="status">Loading your workshops</p>
<?php // The placeholders sit in the same grid as the cards, so nothing moves when the cards arrive. ?>
<?php ws_grid_start() ?>
<?= ws_skeleton(['variant' => 'workshop', 'count' => 3]) ?>
<?php ws_grid_end() ?>
<?php elseif ($state === 'error'): ?>
<?= ws_alert('Your filters are kept. Try again in a moment.', [
'variant' => 'error',
'title' => 'Your workshops did not load',
'actions' => [['text' => 'Try again', 'href' => '#your-workshops']],
]) ?>
<?php elseif ($state === 'empty'): ?>
<?= ws_empty_state('No workshops match these filters', [
'description' => 'Search for a different word, or widen a filter.',
'icon' => 'search_off',
'action' => 'Show all workshops',
'actionHref' => '?',
'actionVariant' => 'secondary',
]) ?>
<?php else: ?>
<?php ws_grid_start(['tag' => 'ul']) ?>
<?php foreach ($workshops as $workshop): ?>
<li>
<?php ws_card_start(['variant' => 'content', 'bordered' => true]) ?>
<?php ws_stack_start(['gap' => '3']) ?>
<?= ws_text($workshop['title'], ['variant' => 'title']) ?>
<?php ws_cluster_start(['gap' => '2']) ?>
<?= ws_duration($workshop['duration']) ?>
<?= ws_category($workshop['category']) ?>
<?php ws_cluster_end() ?>
<?= ws_button('Open', ['variant' => 'secondary', 'size' => 'sm', 'href' => $workshop['href'],
// Three links all read "Open" out of context (a screen reader's links list), so each names its workshop.
'attrs' => ['aria-label' => 'Open ' . $workshop['title']]]) ?>
<?php ws_stack_end() ?>
<?php ws_card_end() ?>
</li>
<?php endforeach; ?>
<?php ws_grid_end() ?>
<?php endif; ?>
<?php ws_stack_end() ?>
Settings form
A settings or preferences page: a short form of saved choices with a select and text fields, a save action, a switch that applies at once, and first-time, loading and validation-error states.
Open the default state on its own to try it at any width.
Components used
ws_section_header() ws_card_start() ws_card_end() ws_skeleton() ws_alert() ws_select() ws_input() ws_toggle() ws_button() ws_stack_start() ws_stack_end() ws_cluster_start() ws_cluster_end() PHP
The whole recipe file, design-system/recipes/settings-form.php. Swap the fixture arrays for the caller's own records and keep the four states.
<?php
/**
* Recipe: settings form.
*
* Needs includes/components/helpers.php and includes/components/components.css,
* which styles the layout helpers. $state is
* default | loading | empty | error. $saved is a fixture: a real page loads the
* caller's own settings row and posts back to an endpoint that re-checks
* ownership and a CSRF token.
*/
$state = $state ?? 'default';
$saved = $state === 'empty' ? [] : [
'workshop_length' => '90',
'reminder_email' => 'facilitator@example.com',
'send_reminders' => true,
];
$errors = $state === 'error' ? ['reminder_email' => 'Enter an email address like name@example.com.'] : [];
$lengths = ['60' => '60 minutes', '90' => '90 minutes', '180' => 'Half a day', '480' => 'A full day'];
?>
<?php ws_stack_start(['gap' => '5']) ?>
<?= ws_section_header('Workshop defaults', [
'tag' => 'h2',
'align' => 'left',
'description' => 'New workshops start with these. You can change any of them per workshop.',
]) ?>
<?php if ($state === 'loading'): ?>
<p class="ws-visually-hidden" role="status">Loading your settings</p>
<?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
<?php ws_stack_start(['gap' => '5']) ?>
<?= ws_skeleton(['variant' => 'input']) ?>
<?= ws_skeleton(['variant' => 'input']) ?>
<?= ws_skeleton(['variant' => 'button']) ?>
<?php ws_stack_end() ?>
<?php ws_card_end(); ?>
<?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
<?= ws_skeleton(['variant' => 'text', 'lines' => 2]) ?>
<?php ws_card_end(); ?>
<?php else: ?>
<?php if ($errors): ?>
<?= ws_alert('Fix the highlighted field, then save again. Nothing else was lost.', [
'variant' => 'error',
'title' => 'Your settings were not saved',
]) ?>
<?php elseif (!$saved): ?>
<?= ws_alert('Nothing is saved yet. Choose your defaults and save them once.', [
'variant' => 'info',
]) ?>
<?php endif; ?>
<?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
<?php ws_stack_start(['tag' => 'form', 'gap' => '5', 'attrs' => ['method' => 'post', 'action' => '#settings']]) ?>
<?= ws_select('workshop_length', $lengths, [
'label' => 'Default length',
'value' => $saved['workshop_length'] ?? '',
'placeholder' => 'Choose a length',
]) ?>
<?= ws_input('reminder_email', [
'type' => 'email',
'label' => 'Send reminders to',
'value' => $state === 'error' ? 'facilitator@example' : ($saved['reminder_email'] ?? ''),
'placeholder' => 'name@example.com',
'hint' => 'We email you the day before each workshop.',
'error' => $errors['reminder_email'] ?? null,
'autocomplete' => 'email',
]) ?>
<?php // DESIGN.md 11.6: the primary action leads, aligned left with the fields. ?>
<?php ws_cluster_start() ?>
<?= ws_button('Save settings', ['type' => 'submit']) ?>
<?= ws_button('Cancel', ['variant' => 'ghost', 'href' => '#settings']) ?>
<?php ws_cluster_end() ?>
<?php ws_stack_end() ?>
<?php ws_card_end(); ?>
<?php // A switch must take effect when flipped, so it sits outside the form: a real page posts it on change and confirms with wsToast(). ?>
<?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
<?= ws_toggle('send_reminders', [
'label' => 'Email me a reminder',
'description' => 'Changes as soon as you flip it. Turn it off if your calendar already reminds you.',
'checked' => $saved['send_reminders'] ?? false,
]) ?>
<?php ws_card_end(); ?>
<?php endif; ?>
<?php ws_stack_end() ?>
Confirm before a destructive action
A flow that deletes or permanently removes something: the danger action, a confirmation step naming exactly what goes, an in-progress state, and a failure that says nothing was lost.
Open the empty state on its own to try it at any width.
Components used
ws_empty_state() ws_button() ws_card() ws_alert() ws_modal_footer() ws_modal() ws_stack_start() ws_stack_end() PHP
The whole recipe file, design-system/recipes/destructive-confirmation.php. Swap the fixture arrays for the caller's own records and keep the four states.
<?php
/**
* Recipe: confirm before a destructive action.
*
* Needs includes/components/helpers.php, includes/components/components.js
* (data-modal-open and data-modal-close) and includes/components/components.css,
* which styles the layout helpers. $state is default | loading | empty | error.
* $workshop is a fixture: the real delete re-checks ownership server-side
* (WHERE id = ? AND user_id = ?) and only then removes anything.
*/
$state = $state ?? 'default';
$workshop = $state === 'empty' ? null : [
'title' => 'Leadership offsite 2026',
'items' => 14,
'updated' => '28 September 2026',
];
$busy = $state === 'loading';
?>
<?php ws_stack_start(['gap' => '5']) ?>
<?php if (!$workshop): ?>
<?= ws_empty_state('No workshop selected', [
'description' => 'Choose a workshop from your list. Delete appears once one is selected.',
'icon' => 'inbox',
]) ?>
<?= ws_button('Delete workshop', ['variant' => 'danger', 'icon' => 'delete', 'disabled' => true]) ?>
<?php else: ?>
<?= ws_card(
'<h3>' . htmlspecialchars($workshop['title']) . '</h3>'
. '<p>' . (int) $workshop['items'] . ' agenda items, last edited ' . htmlspecialchars($workshop['updated']) . '.</p>'
. ws_button('Delete workshop', ['variant' => 'danger', 'icon' => 'delete', 'attrs' => ['data-modal-open' => 'confirm-delete']]),
['variant' => 'content', 'bordered' => true]
) ?>
<?php
$body = '';
if ($state === 'error') {
$body .= ws_alert('The workshop is still here. Check your connection and try again.', [
'variant' => 'error',
'title' => 'Delete did not finish',
]);
}
$body .= '<p>Delete <strong>' . htmlspecialchars($workshop['title']) . '</strong> and its '
. (int) $workshop['items'] . ' agenda items? This cannot be undone.</p>';
// ws_modal_footer forwards attrs, not loading or disabled, so the busy state sets them as attributes.
$body .= ws_modal_footer([
'cancel' => ['text' => 'Keep workshop', 'attrs' => $busy ? ['disabled' => 'disabled'] : ['data-modal-close' => 'confirm-delete']],
'primary' => [
'text' => $busy ? 'Deleting' : ($state === 'error' ? 'Try again' : 'Delete workshop'),
'variant' => 'danger',
'icon' => 'delete',
'attrs' => $busy ? ['disabled' => 'disabled', 'aria-busy' => 'true'] : [],
],
]);
echo ws_modal($body, [
'id' => 'confirm-delete',
'title' => 'Delete this workshop?',
'size' => 'sm',
'closable' => !$busy,
'backdrop' => !$busy,
]);
?>
<?php endif; ?>
<?php ws_stack_end() ?>
Component pairs
Smaller combinations that recur inside screens. Each renders the real helpers; copy the PHP beneath it.
Card + Avatar + Badge
A user profile or team member card. A category badge takes its colour from the category's own record; with none it stays neutral.
Jane Doe
Lead Facilitator
<?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
<div class="ds-recipe-person">
<?= ws_avatar('Jane Doe', ['size' => 'md']) ?>
<div class="ds-recipe-person__text">
<p class="ds-recipe-person__name">Jane Doe</p>
<p class="ds-recipe-person__role">Lead Facilitator</p>
</div>
<?= ws_status('Active', 'success') ?>
</div>
<?php ws_cluster_start(['gap' => '2']) ?>
<?= ws_category('Design Sprint') ?>
<?= ws_category('Retro') ?>
<?= ws_duration('120+ hours') ?>
<?php ws_cluster_end() ?>
<?php ws_card_end(); ?>Section Header + Feature Cards
A feature/benefits section on a landing page.
Everything you need to run
world-class workshops.
Plan the agenda, run the room and keep what happened, in one place.
Curated library
Browse workshops, exercises and icebreakers.
Agenda planner
Drag activities into a timed agenda with breaks.
Live facilitation
Run the session with timers and participant reactions.
<?php ws_stack_start(['gap' => '6']) ?>
<?= ws_section_header('Everything you need to run', [
'subtitle' => 'world-class workshops.',
'description' => 'Plan the agenda, run the room and keep what happened, in one place.',
]) ?>
<?php ws_grid_start() ?>
<?= ws_feature_card('Curated library', 'Browse workshops, exercises and icebreakers.', ['icon' => 'menu_book']) ?>
<?= ws_feature_card('Agenda planner', 'Drag activities into a timed agenda with breaks.', ['icon' => 'calendar_month']) ?>
<?= ws_feature_card('Live facilitation', 'Run the session with timers and participant reactions.', ['icon' => 'groups']) ?>
<?php ws_grid_end() ?>
<?php ws_stack_end() ?>Alert + Form Fields
A form with a validation error. The alert says the submit failed; the field's own error says why. ws_input carries its label and error wiring, so it needs no ws_form_row around it.
<?php ws_stack_start(['tag' => 'form', 'gap' => '5', 'attrs' => ['method' => 'post']]) ?>
<?= ws_alert('Fix the highlighted field, then submit again.', ['variant' => 'error', 'title' => 'Check the form']) ?>
<?= ws_input('name', ['label' => 'Name', 'required' => true, 'autocomplete' => 'name']) ?>
<?= ws_input('email', [
'type' => 'email', 'label' => 'Email', 'required' => true, 'autocomplete' => 'email',
'error' => 'Enter an email address like name@example.com.',
]) ?>
<?php ws_cluster_start() ?>
<?= ws_button('Submit', ['type' => 'submit']) ?>
<?php ws_cluster_end() ?>
<?php ws_stack_end() ?>Breadcrumb + Section Header
Detail page intro pattern.
Design Sprint
by Jake Knapp
A 5-day process for answering critical business questions through design, prototyping, and testing.
<?php ws_stack_start(['gap' => '5']) ?>
<?= ws_breadcrumb([
['label' => 'Home', 'href' => '/'],
['label' => 'Workshops', 'href' => '/library/workshops/'],
['label' => 'Design Sprint'],
]) ?>
<?= ws_section_header('Design Sprint', [
'subtitle' => 'by Jake Knapp',
'description' => 'A 5-day process for answering critical business questions.',
'align' => 'left',
]) ?>
<?php ws_cluster_start(['gap' => '2']) ?>
<?= ws_category('Strategy') ?>
<?= ws_duration('5 days') ?>
<?= ws_status('Popular', 'success') ?>
<?php ws_cluster_end() ?>
<?php ws_stack_end() ?>Empty State + Action
When a list has no results.
<?= ws_empty_state('No workshops found', [
'description' => 'Try adjusting your search or browse all workshops.',
'icon' => 'search_off',
'action' => 'Browse All Workshops',
'actionHref' => '/library/workshops/',
]) ?>Tabs + Content Panels
ws_tabs renders the tab row only. Give each panel id="{tab id}-panel" and the ws-tab-pane class; components.js moves is-active between them.
A two-hour session to agree the quarter's priorities.
Warm-up, context, three decision rounds, wrap-up.
Three notes from the last run.
<?= ws_tabs([
['id' => 'overview', 'label' => 'Overview', 'active' => true],
['id' => 'agenda', 'label' => 'Agenda'],
['id' => 'notes', 'label' => 'Notes', 'count' => 3],
], ['variant' => 'underline']) ?>
<div class="ws-tab-pane is-active" id="overview-panel" role="tabpanel" aria-label="Overview">
<?= ws_card('<p>Workshop overview content...</p>', ['variant' => 'content']) ?>
</div>
<div class="ws-tab-pane" id="agenda-panel" role="tabpanel" aria-label="Agenda">
<?= ws_card('<p>Agenda timeline...</p>', ['variant' => 'content']) ?>
</div>Stats + Checklist
Marketing social proof section. A stat's color takes a token, never a hex.
- Pre-built agendas for every session
- A library of exercises and icebreakers
- Timers and run mode for the live session
- Free to get started
<div class="ds-recipe-split">
<?= ws_stats([
['value' => '50+', 'label' => 'Workshops', 'color' => 'var(--brand-ink)'],
['value' => '120+', 'label' => 'Exercises', 'color' => 'var(--brand-ink)'],
]) ?>
<?= ws_checklist([
'Pre-built agendas for every session',
'A library of exercises and icebreakers',
'Timers and run mode for the live session',
'Free to get started',
]) ?>
</div>