Card
Groups related content into one bounded block, so a list of things reads as a list of things.
When to Use
Variants
Padding variants
The four variants differ only in internal padding. Pick by density, not importance.
Standard padding. The default for listings and most grids.
Roomier. For a small number of prominent cards.
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.
Lifts on hover. Only use when the card actually does something.
Pre-raised with a shadow, no border.
Heavier border for emphasis.
<?php ws_card_start(['variant' => 'content', 'hoverable' => true]); ?>
Hoverable card...
<?php ws_card_end(); ?>States
| State | Behavior |
|---|---|
| Default | Surface fill, 1px border, standard radius. No interaction affordance. |
| Hover | Only with hoverable: cursor: pointer, a translate lift, and a shadow step over --transition-base. |
| Focus | Only 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. |
| Elevated | Static --shadow-md, escalating to --shadow-lg when also hoverable. |
| Disabled | Not supported. There's no disabled treatment — omit the card, or render it without the link. |
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
Rapid sketching exercise where participants fold paper into 8 panels and sketch one idea per panel in one minute each.
Stakeholder Mapping
Identify and map key stakeholders by influence and interest to align engagement strategies throughout the project.
Affinity Mapping
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.
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
| Option | Type | Default | Purpose |
|---|---|---|---|
variant | string | 'content' | content | feature | compact | flush |
hoverable | bool | false | Pointer cursor and hover lift |
elevated | bool | false | Static shadow, no border |
bordered | bool | false | Heavier border |
href | string | null | Renders an <a> instead of a <div>. The accessible way to make a card clickable. |
tag | string | div (or a with href) | Override the wrapper element |
id | string | null | Element ID |
class | string | '' | Additional CSS classes |
attrs | array | [] | Extra HTML attributes as key/value pairs |
Template-only options
| Option | Type | Default | Purpose |
|---|---|---|---|
header | string | null | Top section HTML, rendered into .ws-card__header |
slot | string | '' | Body HTML, rendered into .ws-card__body |
footer | string | null | Bottom section HTML, rendered into .ws-card__footer |
data | array | [] | ['id' => '123'] becomes data-id="123" |
Accessibility
| Concern | Behavior |
|---|---|
| Semantics | A card is a plain <div> with no role — correct, because it's a visual grouping. The meaning comes from the heading inside it. |
| Clickable cards | The 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 actions | Buttons 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. |
| Headings | Give 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 actions | Row 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. |
| Contrast | The 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
| Token | Used for |
|---|---|
--card-bg | Surface fill |
--card-border | Border colour |
--card-radius | Corner radius |
--shadow-md | Elevated variant, and the hover lift |
--shadow-lg | Hover on an already-elevated card |
--gray-100 | Internal dividers between the flush slots |
--transition-base | Hover lift and shadow transition |
CSS Classes
| Class | Purpose |
|---|---|
.ws-card | Base surface, border, and radius |
.ws-card--content | Standard padding |
.ws-card--feature | Larger padding |
.ws-card--compact | Tighter padding |
.ws-card--flush | No padding — for the slot API |
.ws-card--hoverable | Pointer cursor and hover lift |
.ws-card--elevated | Static shadow, border removed |
.ws-card--bordered | Heavier border |
.ws-card__header | Header slot |
.ws-card__body | Body slot |
.ws-card__footer | Footer 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
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_card_start(), ws_card_end(), ws_card() — note these build markup inline rather than including the template |
includes/components/card.php | Template with the header/body/footer slots |
includes/components/components.css | Styles (.ws-card rules) |
includes/partials/listing-card.php | The composed listing card used by the library pages |
css/listing-redesign.css | .listing-card styles |