Empty State
Explains why a region is blank and gives the reader the one action that fills it.
When to Use
secondary, linking to the list's own address without the search (DESIGN.md 11.6 and 13). Never "Clear" or "Reset".
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.
<?= 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.
States
| State | Behavior |
|---|---|
| Default | Centred icon, title, optional description, optional action. Static — the component itself has no interactive state. |
| Action hover / focus | Owned by Button, which the action renders through. Its variant is controlled by actionVariant. |
Action without actionHref | Passing action alone still renders a button, but with no destination. Always set both. |
| Accent theming | The 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 — it takes about five minutes.
<?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' => 'Show all workshops',
'actionHref' => '?',
'actionVariant' => 'secondary',
]) ?>
<?php endif; ?>Options
| Option | Type | Default | Purpose |
|---|---|---|---|
$title | string | required | Positional heading. State the fact plainly: "No workshops yet". |
description | string | null | One sentence on why it's empty and what to do |
icon | string | 'inbox' | Material icon name |
action | string | null | Button label. Lead with a verb. |
actionHref | string | null | Button destination. Set it whenever you set action. |
actionVariant | string | 'primary' | Any Button variant. Use secondary when the empty state isn't the page's main event. |
id | string | null | Element ID |
class | string | '' | Additional CSS classes |
Accessibility
| Concern | Behavior |
|---|---|
| ARIA | None 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. |
| Keyboard | Only the action is focusable, and it inherits Button's native behaviour. |
| Heading | The title is always an <h3>, with no option to change it. Put the empty state under the section's <h2>, where the results' own card titles would also be <h3>. |
| Announcing a change | It depends on how the results changed. After a search or filter that reloads the page (ws_filter_bar does), the new page is read in order, so show the count as a visible ws_text caption line above the results and the empty state needs nothing more. Only when a script swaps results in place without a reload is the swap silent: then give that count line live, so "0 workshops" is announced. |
| Icon | Decorative and unlabelled. The title carries the meaning, so never rely on the icon to distinguish "no results" from "no access". |
| Contrast | Title uses --text-dark and description --text-default, both tested. The icon is deliberately low-contrast because it's decoration — don't promote it to the sole signal. |
Tokens
| Token | Used for |
|---|---|
--ws-empty-accent / --ws-empty-accent-bg / --ws-empty-accent-light | Component-scoped accent trio for the icon. Override on an ancestor to theme per app. |
--brand / --brand-tint | Default accent values behind those variables |
--phase-facilitate / --phase-reflect | Phase-scoped accent overrides (all red since Red Unification) |
--text-dark / --ink | Title colour |
--text-default | Description colour |
--text-h2 / --text-h3 / --text-small | Title and description type scale |
--font-heading / --font-body / --font-semibold | Fraunces for the title, Inter for the description |
--space-2 / --space-4 / --space-5 / --space-7 / --space-14 | Internal rhythm and the generous vertical padding |
--radius-lg | Icon container corners |
CSS Classes
| Class | Purpose |
|---|---|
.ws-empty-state | Centred container with the vertical padding |
.ws-empty-state__icon | Accent-tinted icon holder |
.ws-empty-state__title | Heading |
.ws-empty-state__desc | Description paragraph |
.ws-empty-state__action | Action wrapper around the button |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_empty_state() helper function |
includes/components/empty-state.php | Template — markup and the action button call |
includes/components/components.css | Styles and the accent variables (.ws-empty-state rules) |