Welcome to Vanilla Breeze
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
This bell pulls live notifications from /go/notify/messages — the same contract documented at /docs/concepts/service-contracts/. Static articles like this one are the no-JS / no-backend fallback.
Single KPI tile for dashboards and analytics — title, headline value, optional change indicator, sparkline (composed with chart-wc), description, and icon. Wrap in an anchor for drill-down.
The <score-card> component is the atom of dashboards. It packages a metric tile — title, headline number, optional change indicator, sparkline, description, and icon — into a single composable element that degrades gracefully without JS or CSS.
Score-card is the enhanced form of the semantic stat figure documented at /docs/patterns/data/stats/. Use the pattern when you need a no-JS recipe; use the component when you want consistent layout, theme-driven trend coloring, drill-down affordance, and skeleton loading.
Minimal use only requires title and value slots. Add change, icon, and a tone attribute to grow the tile into a full KPI card.
<score-card> <span slot="title">Orders</span> <data slot="value" value="1429">1,429</data></score-card>
<score-card trend="up"> <span slot="title">Total Users</span> <data slot="value" value="24521">24,521</data> <small slot="change"> <icon-wc name="trending-up" size="sm"></icon-wc> <data value="0.125">+12.5%</data> vs last month </small></score-card>
<score-card trend="up" tone="success"> <span slot="title">Revenue</span> <data slot="value" value="48352">$48,352</data> <small slot="change"><data value="0.082">+8.2%</data> from last month</small> <icon-wc slot="icon" name="dollar-sign" size="md"></icon-wc></score-card>
The sparkline slot accepts any sized element. The slot reserves a fixed height via --score-card-sparkline-height (default 40px) and clips overflow, so the chosen renderer is sized predictably regardless of internal content.
data-size="sparkline" (recommended)For data-driven trend tiles, compose with <chart-wc> in sparkline mode. The chart strips axes, gridlines, labels, title, legend, and tooltip and fits the slot's reserved height (default 40px). Same component, same data API, no extra wrapper — and the underlying <table> data source still ships an accessible fallback.
<score-card trend="up" tone="success"> <span slot="title">Revenue</span> <data slot="value" value="48352">$48,352</data> <small slot="change"><data value="0.082">+8.2%</data> vs last month</small> <chart-wc slot="sparkline" data-type="line" data-size="sparkline" data-values='[{"name":"r","values":{"1":42000,"2":43500,"3":44100,"4":45800,"5":47200,"6":48352}}]'></chart-wc> <icon-wc slot="icon" name="dollar-sign" size="md"></icon-wc></score-card>
Style the trend by overriding --chart-series-1 per trend value — the sparkline shape inherits the colour automatically:
score-card[trend="up"] chart-wc[data-size="sparkline"] { --chart-series-1: var(--color-success); }score-card[trend="down"] chart-wc[data-size="sparkline"] { --chart-series-1: var(--color-error); }score-card[trend="flat"] chart-wc[data-size="sparkline"] { --chart-series-1: var(--color-text-muted); }
If you don't want to ship the optional charts bundle just for tiles, an inline <svg> works the same way. Style with currentColor so it inherits the trend tone via the parent score-card.
<score-card trend="up" tone="success"> <span slot="title">Revenue</span> <data slot="value" value="48352">$48,352</data> <small slot="change"><data value="0.082">+8.2%</data> vs last month</small> <svg slot="sparkline" class="spark" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"> <path class="line" d="M 0 22 L 20 19 L 40 17 L 60 12 L 80 8 L 100 4"/> <circle class="endpoint" cx="100" cy="4" r="2"/> </svg> <icon-wc slot="icon" name="dollar-sign" size="md"></icon-wc></score-card>
.spark { display: block; inline-size: 100%; block-size: 100%; }.spark .line { fill: none; stroke: currentColor; stroke-width: 2; vector-effect: non-scaling-stroke; }.spark .area { fill: currentColor; opacity: 0.18; }.spark .endpoint { fill: currentColor; }score-card[trend="up"] [slot="sparkline"] { color: var(--color-success); }score-card[trend="down"] [slot="sparkline"] { color: var(--color-error); }score-card[trend="flat"] [slot="sparkline"] { color: var(--color-text-muted); }
Drop the data-size="sparkline" attribute and bump the slot height to render a full chart with axes, tooltip, and legend.
<score-card trend="up" tone="success" style="--score-card-sparkline-height: 160px;"> <span slot="title">Revenue (90-day)</span> <data slot="value" value="186420">$186,420</data> <small slot="change"><data value="0.142">+14.2%</data> vs prior period</small> <chart-wc slot="sparkline" data-type="area" data-tooltip data-values='[{"name":"Revenue","values":{"M1":140000,"M2":148000,"M3":152000,"M4":162000,"M5":174000,"M6":186420}}]'></chart-wc></score-card>
To make a tile clickable, wrap it in an anchor. The wrapping <a> receives keyboard focus and click — no synthetic key handling, no role override, no tabindex juggling. The whole tile becomes a single accessible link.
When score-card upgrades, it walks ancestors looking for the nearest <a href>. If found, it sets the :state(interactive) hook so component CSS can apply hover and active affordances.
<a href="/dashboard/users"> <score-card trend="up" tone="info"> <span slot="title">Active Users</span> <data slot="value" value="2350">2,350</data> <small slot="change"><data value="0.18">+18%</data> vs last month</small> <icon-wc slot="icon" name="users" size="md"></icon-wc> </score-card></a>
Shadow CSS can't reach the wrapping anchor, so add this partner light-DOM rule once in your stylesheet to apply hover and focus affordances to the tile:
a:has(score-card) { display: block; text-decoration: none; color: inherit; border-radius: var(--radius-m);}a:has(score-card):hover score-card { transform: translateY(-2px); border-color: var(--color-interactive);}a:has(score-card):focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 2px;}
Note: the :state(interactive) hook is set once on upgrade. If you move a score-card into or out of an anchor at runtime, the state won't re-sync — wrap or unwrap before the element connects.
Set the loading attribute while data is in flight. Slotted content stays in the DOM so it doesn't reflow on completion; the value, change, and sparkline regions are visually replaced by a shimmer. The animation respects prefers-reduced-motion and falls back to a flat muted background.
<score-card loading tone="success"> <span slot="title">Revenue</span> <data slot="value" value="0">$000</data> <small slot="change">000</small> <icon-wc slot="icon" name="dollar-sign" size="md"></icon-wc></score-card>
const card = document.querySelector('#revenue-card');card.toggleAttribute('loading', true);const data = await fetch('/api/revenue').then(r => r.json());card.querySelector('[slot="value"]').textContent = data.formatted;card.querySelector('[slot="value"]').setAttribute('value', data.raw);card.toggleAttribute('loading', false);
| Slot | Purpose | Required |
|---|---|---|
title | Metric label | yes |
value | Headline number — use <data value="…"> for machine-readable values | yes |
change | Delta indicator — recommend nested <data> for the change value | no |
sparkline | Trend visualisation — compose with <chart-wc> or any sized element | no |
description | Supporting context line below the metric | no |
icon | Brand or category icon (recommend <icon-wc>) | no |
Per VB convention, content goes in slots and state goes in attributes. The component is intentionally unopinionated about what fills each slot — pass an SVG, canvas, or chart-wc into the sparkline slot; pass any inline element into value when <data> doesn't fit.
| Attribute | Values | Default | Description |
|---|---|---|---|
trend |
up, down, flat |
— | Drives change-indicator color and exposes :state(trend-up|trend-down|trend-flat) |
tone |
default, success, warning, error, info |
default |
Optional accent color applied to the icon slot |
layout |
stack, cluster, compact |
stack |
Grid template variant: vertical stack, icon-cluster row, or dense compact |
loading |
boolean | absent | Skeleton placeholder state via :state(loading) |
Score-card exposes CustomStateSet entries for CSS targeting via :state():
| State | When |
|---|---|
:state(trend-up) | trend="up" |
:state(trend-down) | trend="down" |
:state(trend-flat) | trend="flat" |
:state(loading) | loading attribute present |
:state(interactive) | Element is wrapped in an <a href> ancestor |
| Token | Default | Purpose |
|---|---|---|
--score-card-padding | var(--size-l) | Tile internal padding |
--score-card-radius | var(--radius-m) | Corner radius |
--score-card-gap | var(--size-s) | Gap between rows |
--score-card-value-size | var(--font-size-3xl) | Headline number size |
--score-card-value-weight | var(--font-weight-bold) | Headline number weight |
--score-card-sparkline-height | 40px | Reserved height for sparkline slot |
--score-card-surface | var(--color-surface) | Tile background |
--score-card-border | 1px solid var(--color-border-subtle) | Tile border |
--score-card-tone-accent | resolved from tone | Accent color (icon, optional border) |
Before upgrade — and when JS is unavailable — score-card renders its slotted children inline. The recommended fallback structure is the same semantic figure pattern documented at /docs/patterns/data/stats/, so consumers get a fully readable metric without the tile chrome:
<score-card trend="up"> <figure> <figcaption slot="title">Total Users</figcaption> <data slot="value" value="24521">24,521</data> <small slot="change" data-trend="up"> <data value="0.125">+12.5%</data> vs last month </small> </figure></score-card>
<figure>, <figcaption>, <data>, and <small> structure.+ or − sign and a direction word in the copy.prefers-reduced-motion.Semantic figure recipe — score-card's no-JS fallback
Recommended sparkline composition partner
Machine-readable values for value and change slots
Full dashboard layouts using score-card