Canvas Design System
Main Site Tokens

Empty State

Explains why a region is blank and gives the reader the one action that fills it.

When to Use

Use when: A list, grid, or panel legitimately has nothing in it — no saved workshops yet, no search results, no notifications. Say why it's empty and what to do next.
Don't use when: The data is still loading — use Skeleton, or the reader will think their content is gone. For a failed request use Alert with the error variant; "empty" and "broken" are different messages.

Variants

There's one layout. What varies is how much of it you fill in — build up from title only to the full title, description, and action.

Title only

The minimum. Acceptable when the surrounding context already explains the emptiness.

No workshops found

<?= ws_empty_state('No workshops found') ?>

With a description

Use the description to name the cause, not to apologise.

No workshops found

Try adjusting your search or filters to find what you are looking for.

<?= ws_empty_state('No workshops found', [
    'description' => 'Try adjusting your search or filters.',
]) ?>

With an action

The strongest form, and the one to reach for by default. One action only — the empty state's job is to remove the dead end.

No workshops found

Try adjusting your search, or browse the full library.

<?= ws_empty_state('No workshops found', [
    'icon'       => 'search_off',
    'action'     => 'Browse All Workshops',
    'actionHref' => '/library/workshops/',
]) ?>

Icons

The default is inbox. Pick an icon that matches the specific emptiness — search_off for no results reads very differently from event_busy for nothing scheduled.

No saved items

Save workshops for quick access.

No notifications

You are all caught up.

No sessions yet

Start your first session.

States

StateBehavior
DefaultCentred icon, title, optional description, optional action. Static — the component itself has no interactive state.
Action hover / focusOwned by Button, which the action renders through. Its variant is controlled by actionVariant.
Action without actionHrefPassing action alone still renders a button, but with no destination. Always set both.
Accent themingThe component reads --ws-empty-accent, --ws-empty-accent-bg, and --ws-empty-accent-light. Set them on an ancestor to tint the icon per app; they fall back to the brand red.

Real-World Usage

Two different empties on the same screen. A first-run user has never created anything; a filtering user has created plenty but matched none. Same component, deliberately different copy and action.

No workshops yet

Build your first agenda in the Planner &mdash; it takes about five minutes.

Nothing matches those filters

You have 24 workshops, but none in this category.

<?php if (!$hasAnyWorkshops): ?>
    <?= ws_empty_state('No workshops yet', [
        'icon'       => 'add_circle',
        'description'=> 'Build your first agenda in the Planner.',
        'action'     => 'Create a workshop',
        'actionHref' => '/planner/',
    ]) ?>
<?php elseif (!$filtered): ?>
    <?= ws_empty_state('Nothing matches those filters', [
        'icon'          => 'filter_alt_off',
        'description'   => "You have {$total} workshops, but none in this category.",
        'action'        => 'Clear filters',
        'actionHref'    => '?',
        'actionVariant' => 'secondary',
    ]) ?>
<?php endif; ?>

Options

OptionTypeDefaultPurpose
$titlestringrequiredPositional heading. State the fact plainly: "No workshops yet".
descriptionstringnullOne sentence on why it's empty and what to do
iconstring'inbox'Material icon name
actionstringnullButton label. Lead with a verb.
actionHrefstringnullButton destination. Set it whenever you set action.
actionVariantstring'primary'Any Button variant. Use secondary when the empty state isn't the page's main event.
idstringnullElement ID
classstring''Additional CSS classes

Accessibility

ConcernBehavior
ARIANone applied — the title and description are ordinary text read in document order, which is the right behaviour for content that's present at page load.
KeyboardOnly the action is focusable, and it inherits Button's native behaviour.
Announcing a changeWorth handling yourself. When an empty state replaces results after a filter or search, screen-reader users get no notification — the region silently swaps. Put the result count in a live region so "0 results" is announced.
IconDecorative and unlabelled. The title carries the meaning, so never rely on the icon to distinguish "no results" from "no access".
ContrastTitle uses --text-dark and description --text-muted, both tested. The icon is deliberately low-contrast because it's decoration — don't promote it to the sole signal.

Tokens

TokenUsed for
--ws-empty-accent / --ws-empty-accent-bg / --ws-empty-accent-lightComponent-scoped accent trio for the icon. Override on an ancestor to theme per app.
--mainsite-primary / --mainsite-very-lightDefault accent values behind those variables
--phase-plan / --phase-facilitate / --phase-reflectPhase-scoped accent overrides (all red since Red Unification)
--text-dark / --inkTitle colour
--text-muted / --ink-mutedDescription colour
--text-h2 / --text-h3 / --text-smallTitle and description type scale
--font-heading / --font-body / --font-semiboldFraunces for the title, Inter for the description
--space-2 / --space-4 / --space-5 / --space-7 / --space-14Internal rhythm and the generous vertical padding
--radius-lgIcon container corners

CSS Classes

ClassPurpose
.ws-empty-stateCentred container with the vertical padding
.ws-empty-state__iconAccent-tinted icon holder
.ws-empty-state__titleHeading
.ws-empty-state__descDescription paragraph
.ws-empty-state__actionAction wrapper around the button

Files

FilePurpose
includes/components/helpers.phpws_empty_state() helper function
includes/components/empty-state.phpTemplate — markup and the action button call
includes/components/components.cssStyles and the accent variables (.ws-empty-state rules)