Empty State
Explains why a region is blank and gives the reader the one action that fills it.
When to Use
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' => 'Clear filters',
'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. |
| Announcing a change | Worth 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. |
| 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-muted, 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. |
--mainsite-primary / --mainsite-very-light | Default accent values behind those variables |
--phase-plan / --phase-facilitate / --phase-reflect | Phase-scoped accent overrides (all red since Red Unification) |
--text-dark / --ink | Title colour |
--text-muted / --ink-muted | 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) |