Canvas Design System
Main Site Tokens

Card

Groups related content into one bounded block, so a list of things reads as a list of things.

When to Use

Use when: You're presenting repeated items of the same kind (exercises, workshops, saved plans), or grouping a self-contained block such as a stat summary or a set of form fields. Cards create the boundary that makes a grid scannable.
Don't use when: The content is a single run of prose — that's a paragraph. For a full-width message use Alert; for a pre-composed library item use Listing Card. Don't nest a card inside a card.

Variants

Padding variants

The four variants differ only in internal padding. Pick by density, not importance.

Content

Standard padding. The default for listings and most grids.

Feature

Roomier. For a small number of prominent cards.

Compact

Tighter. For dense UI such as sidebars and rails.

<?php ws_card_start(['variant' => 'content']); ?>
    Content here...
<?php ws_card_end(); ?>

The fourth, flush, removes padding entirely so the header/body/footer slots can own their own spacing — see Real-World Usage.

Modifiers

Modifiers stack with any variant. elevated and bordered are mutually exclusive in practice — elevation drops the border.

Hoverable

Lifts on hover. Only use when the card actually does something.

Elevated

Pre-raised with a shadow, no border.

Bordered

Heavier border for emphasis.

<?php ws_card_start(['variant' => 'content', 'hoverable' => true]); ?>
    Hoverable card...
<?php ws_card_end(); ?>

States

StateBehavior
DefaultSurface fill, 1px border, standard radius. No interaction affordance.
HoverOnly with hoverable: cursor: pointer, a translate lift, and a shadow step over --transition-base.
FocusOnly when href is set — the card becomes an <a> and gets the native focus ring. A hoverable <div> has no focus state, which is why a click handler on a plain card is a bug.
ElevatedStatic --shadow-md, escalating to --shadow-lg when also hoverable.
DisabledNot supported. There's no disabled treatment — omit the card, or render it without the link.
warning hoverable changes the cursor to a pointer. If nothing happens on click, that's a false affordance — use it only alongside href or a genuine action.

Real-World Usage

Library listing grid

The exercises listing composes a card from icon, title, duration badge, description, category badge, and row actions. Category colours come from the database per record — that's data, not decoration, which is why they aren't tokens.

Crazy 8s Sketching

30 min

Rapid sketching exercise where participants fold paper into 8 panels and sketch one idea per panel in one minute each.

Stakeholder Mapping

45 min

Identify and map key stakeholders by influence and interest to align engagement strategies throughout the project.

Affinity Mapping

60 min

Organize research findings and ideas into meaningful clusters to reveal themes and patterns across qualitative data.

<?php ws_card_start(['variant' => 'content', 'hoverable' => true, 'href' => $url]); ?>
    <div class="listing-card-header">
        <div class="listing-card-icon" style="background: <?= $cat['color'] ?>1A;">
            <?= icon($cat['icon'], 'icon') ?>
        </div>
        <div>
            <h3 class="listing-card-title"><?= $ex['name'] ?></h3>
            <div class="listing-card-meta"><?= ws_duration($ex['duration'] . ' min') ?></div>
        </div>
    </div>
    <p class="listing-card-description"><?= $ex['overview'] ?></p>
    <div class="listing-card-footer">
        <?= ws_category($cat['name'], $cat['color']) ?>
    </div>
<?php ws_card_end(); ?>

Flush with header, body, and footer

The slot API is only reachable by including the template directly — ws_card_start() doesn't accept header, slot, or footer. Use it when the card needs internal dividers.

auto_awesomeSession Summary

Your design sprint covered 5 exercises across 3 hours with 8 participants. Overall engagement was high.

$card = [
    'variant' => 'flush',
    'header'  => '<strong>Session Summary</strong>',
    'slot'    => '<p>Body content...</p>',
    'footer'  => ws_button('View Report', ['variant' => 'primary', 'size' => 'sm']),
];
include 'includes/components/card.php';

Options

There are two APIs. ws_card_start() / ws_card_end() wrap arbitrary markup; ws_card($content, $options) is the same thing for a string. Including the template directly unlocks the slot options in the second table.

Helper options

OptionTypeDefaultPurpose
variantstring'content'content | feature | compact | flush
hoverableboolfalsePointer cursor and hover lift
elevatedboolfalseStatic shadow, no border
borderedboolfalseHeavier border
hrefstringnullRenders an <a> instead of a <div>. The accessible way to make a card clickable.
tagstringdiv (or a with href)Override the wrapper element
idstringnullElement ID
classstring''Additional CSS classes
attrsarray[]Extra HTML attributes as key/value pairs

Template-only options

OptionTypeDefaultPurpose
headerstringnullTop section HTML, rendered into .ws-card__header
slotstring''Body HTML, rendered into .ws-card__body
footerstringnullBottom section HTML, rendered into .ws-card__footer
dataarray[]['id' => '123'] becomes data-id="123"
warning All three slots render as raw HTML. Escape any user-supplied content before it goes in.

Accessibility

ConcernBehavior
SemanticsA card is a plain <div> with no role — correct, because it's a visual grouping. The meaning comes from the heading inside it.
Clickable cardsThe one thing to get right. Use href so the card renders as an <a>: focusable, keyboard-activatable, and announced as a link. A JS click handler on a hoverable <div> is invisible to keyboard and screen-reader users.
Nested actionsButtons inside a linked card create nested interactive elements, which is invalid and traps clicks. Either make the card a link with no inner buttons, or keep the card inert and let the inner controls act.
HeadingsGive each card a real heading at the right level for the page outline. A grid of cards whose titles are <div>s is unnavigable by heading.
Icon-only actionsRow action buttons need aria-label — and it should name the item, not just the verb: "Favourite Crazy 8s", not "Favourite". A grid of identical "Share" buttons is useless out of context.
ContrastThe border against the surface is deliberately low-contrast because it's decorative. Don't rely on it alone to separate cards — spacing does that work.

Tokens

TokenUsed for
--card-bgSurface fill
--card-borderBorder colour
--card-radiusCorner radius
--shadow-mdElevated variant, and the hover lift
--shadow-lgHover on an already-elevated card
--gray-100Internal dividers between the flush slots
--transition-baseHover lift and shadow transition

CSS Classes

ClassPurpose
.ws-cardBase surface, border, and radius
.ws-card--contentStandard padding
.ws-card--featureLarger padding
.ws-card--compactTighter padding
.ws-card--flushNo padding — for the slot API
.ws-card--hoverablePointer cursor and hover lift
.ws-card--elevatedStatic shadow, border removed
.ws-card--borderedHeavier border
.ws-card__headerHeader slot
.ws-card__bodyBody slot
.ws-card__footerFooter slot

The listing pattern layers its own classes on top — .listing-card-header, -icon, -title, -meta, -description, -footer — defined in css/listing-redesign.css, not in the component library.

Files

FilePurpose
includes/components/helpers.phpws_card_start(), ws_card_end(), ws_card() — note these build markup inline rather than including the template
includes/components/card.phpTemplate with the header/body/footer slots
includes/components/components.cssStyles (.ws-card rules)
includes/partials/listing-card.phpThe composed listing card used by the library pages
css/listing-redesign.css.listing-card styles