Canvas Design System
Main Site Tokens

Stat

Puts a single number and its label at display size, so the figure lands before the explanation.

When to Use

Use when: A number is the point — hero proof figures, dashboard KPIs, a session's headline metrics. Use ws_stats() for a row of two to four related figures.
Don't use when: The number is incidental metadata on a card — that's Badge. For a proportion of a whole use Progress, and for more than four figures use a table.

Variants

Single stat

ws_stat() renders one figure. Value and label are both positional and both required.

50+ Workshops
120+ Exercises
1,000+ Facilitators
<?= ws_stat('50+', 'Workshops') ?>
<?= ws_stat('120+', 'Exercises', ['color' => 'var(--phase-learn)']) ?>
<?= ws_stat('1,000+', 'Facilitators', ['icon' => 'groups']) ?>

Stat group

ws_stats() lays out a row with dividers between items. It takes the same per-item keys as the single helper.

50+ Workshops
120+ Exercises
80+ Icebreakers
1,000+ Facilitators
<?= ws_stats([
    ['value' => '50+',    'label' => 'Workshops'],
    ['value' => '120+',   'label' => 'Exercises'],
    ['value' => '80+',    'label' => 'Icebreakers'],
    ['value' => '1,000+', 'label' => 'Facilitators'],
]) ?>

With colour

Colour applies to the value only. Pass a token reference rather than a literal hex so the figures follow the palette.

50+ Workshops
120+ Exercises
80+ Icebreakers
<?= ws_stats([
    ['value' => '50+', 'label' => 'Workshops', 'color' => 'var(--phase-learn)'],
]) ?>

With icons

Apply icons to the whole row or to none of it — a partly-iconed group reads as broken.

50+ Workshops
120+ Exercises
80+ Icebreakers
<?= ws_stats([
    ['value' => '50+', 'label' => 'Workshops', 'icon' => 'school'],
]) ?>

States

StateBehavior
DefaultThe only state. Stats are static text with no hover, focus, or interactive treatment.
ZeroRenders "0" at full display size — visually as loud as a large number. When zero means "nothing yet", Empty State communicates better.
Long valuesNo truncation or auto-fitting. A long value widens the item and can unbalance a group, so pre-format big numbers ("1.2k", not "1,247").
Single item in a groupws_stats() with one item renders without dividers — safe, but ws_stat() is the clearer call.

Real-World Usage

The hero proof row on a landing page: counts pulled live from the library so the marketing figures can't drift from the database.

120+ Exercises
80+ Icebreakers
50+ Workshop templates
<?= ws_stats([
    ['value' => $counts['exercises'] . '+',   'label' => 'Exercises'],
    ['value' => $counts['icebreakers'] . '+', 'label' => 'Icebreakers'],
    ['value' => $counts['workshops'] . '+',   'label' => 'Workshop templates'],
]) ?>

Hero takes a stats option in the same shape, so inside a hero you don't call this component directly.

Options

ws_stat()

OptionTypeDefaultPurpose
$valuestring''Positional. The figure, pre-formatted — "50+", "1,000+", "98%".
$labelstring''Positional. What the figure counts. Plural nouns read best.
colorstringnullValue colour. Accepts a hex or a CSS variable — prefer var(--phase-learn).
iconstringnullMaterial icon above the value
idstringnullElement ID
classstring''Additional CSS classes

ws_stats()

OptionTypeDefaultPurpose
$statsarrayrequiredPositional array of items, each accepting value, label, color, and icon
idstringnullContainer ID
classstring''Additional CSS classes on the container

Accessibility

ConcernBehavior
ARIANone applied. Value and label are adjacent text read in order — "50 plus, Workshops" — which is the correct reading without any extra wiring.
Reading orderThe value comes before the label in the DOM as well as visually, so the announcement matches what's on screen.
KeyboardNot focusable. Stats are output, not controls.
AbbreviationsA value like "1.2k" is read literally and may not be understood. Spell it out in the label when precision matters, or pass the full number.
Not a headingThe value renders large but isn't a heading element, so it won't pollute the page outline — deliberate. Don't wrap it in one to get the size.
ContrastValues default to --text-dark and labels to --text-muted. A custom color bypasses that check — verify it against the surface.

Tokens

TokenUsed for
--text-darkDefault value colour
--text-mutedLabel colour
--gray-300Divider between items in a group
--text-h2 / --text-h3Value display size
--text-captionLabel size
--font-headingFraunces on the value
--font-body / --font-boldInter on the label, and the value's weight
--space-1 / --space-4 / --space-6Value-to-label gap and the spacing between group items

CSS Classes

ClassPurpose
.ws-statSingle stat wrapper
.ws-stat__valueThe figure — display size, heading face
.ws-stat__labelThe caption beneath
.ws-stat__iconOptional icon above the value
.ws-statsGroup container (flex row)
.ws-stats__dividerVertical rule between group items

Files

FilePurpose
includes/components/helpers.phpws_stat() and ws_stats() helper functions
includes/components/stat.phpSingle stat template
includes/components/stats.phpGroup template — loops items and inserts dividers
includes/components/components.cssStyles (.ws-stat and .ws-stats rules)