StatCard

KPI card with trend-aware sentiment.

Live demo

01

Dashboard row Β· up = good

Revenue

Β£12,450
8.2% vs last week Up 8.2% vs last week

New signups

342
12.4% vs last week Up 12.4% vs last week

Conversion

4.7%
0.6pts vs last week Up 0.6pts vs last week

Active users

4271
0 no change No change no change

Lower-is-better metrics

Page load time

1.4s
12% vs last week Down 12% vs last week

Errors per hour

47
23% vs last week Down 23% vs last week

Churn rate

2.1%
0.4pts vs last month Up 0.4pts vs last month

Support tickets

89
5 vs last week Up 5 vs last week

Sizes

Small

Β£1,200
3.4% Up 3.4%

Medium (default)

Β£12,450
8.2% Up 8.2%

Large hero

Β£124,500
14.7% Up 14.7%

Composing with BadgePill

Followers Live

1840
3.1% vs last week Up 3.1% vs last week
Open issues 3 critical

47
12% vs last week Down 12% vs last week

Implementation

02
StatCard.svelte
<script lang="ts">
  import StatCard from '$lib/components/StatCard.svelte';
</script>
​
<StatCard title="Revenue" value="Β£12,450" delta={8.2} deltaSuffix="%" deltaLabel="vs last week" />
<StatCard title="Page load" value="1.4s" delta={-12} deltaSuffix="%" positiveDirection="down" />

StatCard renders a single KPI with auto-coloured trend semantics. By default a positive delta is green and a negative one is red β€” but for 'lower is better' metrics (page load time, error rate, churn) you set positiveDirection='down' and the colour logic flips. ↑ / ↓ / β€” glyphs guarantee colour is never the only signal, so colour-blind users still parse the trend correctly. Three sizes scale the whole card without re-typing styles.

Logic explainer

03

What Does It Do? (Plain English)

StatCard is a single-metric KPI card: a small heading, a big number, and an optional trend indicator that auto-colours green or red based on whether the change is good news. The clever bit is the positiveDirection prop. For most metrics β€” revenue, signups, retention β€” up is good. For others β€” page load time, error counts, churn, costs β€” down is good. Setting positiveDirection="down" flips the colour map so a falling load time reads green and a rising one reads red. Same data, correct sentiment.

Think of it as a fuel gauge that knows whether full or empty is the goal.

How It Works (Pseudo-Code)

props:
  title             = string                          // small heading
  value             = string | number                 // the big number (pre-formatted)
  delta             = optional number                 // sign drives the arrow
  deltaSuffix       = string                          // e.g. '%' or ' pts'
  deltaLabel        = string                          // 'vs last week'
  positiveDirection = 'up' | 'down'                   // default 'up'
  size              = 'sm' | 'md' | 'lg'              // default 'md'
  icon              = optional snippet

derive trend ('up' | 'down' | 'flat'):
  if delta is undefined or 0: 'flat'
  if delta > 0:                'up'
  else:                        'down'

derive sentiment ('positive' | 'negative' | 'neutral'):
  if trend == 'flat':                       'neutral'
  if trend == positiveDirection:            'positive'
  else:                                     'negative'

derive deltaDisplay = abs(delta) + deltaSuffix       // sign comes from arrow glyph
derive trendSrText  = "Up 8.2% vs last week"          // full SR sentence

render <article data-trend data-sentiment>
  <header>{icon}{title}</header>
  <div class="stat-value">{value}</div>
  if delta defined:
    <footer>
      <span class="stat-delta sentiment-{sentiment}">
        <span aria-hidden>↑ / ↓ / β€”</span>
        <span>{deltaDisplay}</span>
      </span>
      if deltaLabel: <span class="stat-delta-label">{deltaLabel}</span>
      <span class="sr-only">{trendSrText}</span>
    </footer>
</article>

The Core Concept: Trend Direction Versus Sentiment

The component separates what the number did from what that means. Most KPI components conflate them:

Bad: if (delta > 0) colour = green

That's wrong for half the metrics in a real product. Errors went up β€” that's red, not green. Page load went down β€” that's green, not red. StatCard's logic decouples direction from meaning:

trend     = sign of delta                ('up' | 'down' | 'flat')
sentiment = trend === positiveDirection  ? 'positive'   ('good news, green')
          : trend === 'flat'             ? 'neutral'    ('grey')
          : 'negative'                                  ('bad news, red')

Now the same component handles both more is better and less is better metrics from a single prop:

                            delta>0     delta<0     delta=0
Revenue (up=good)            green       red         grey
Load time (down=good)        red         green       grey
Error count (down=good)      red         green       grey
Churn rate (down=good)       red         green       grey

The arrow glyph (↑, ↓, β€”) shows the direction; the colour shows whether that's good or bad; the screen-reader-only sentence puts both together in plain English. Nothing relies on colour alone.

Accessibility: Colour Is Never The Only Signal

Three independent channels carry the trend information:

  1. The arrow glyph (↑ / ↓ / β€”) is aria-hidden but visible β€” sighted users see direction even if they can't perceive colour.
  2. The numeric delta (8.2%) is rendered visibly.
  3. The screen-reader sentence ("Up 8.2% vs last week") is in a .sr-only span so non-visual users get the full picture as one announcement.
<span class="stat-delta sentiment-{sentiment}">
  <span class="stat-arrow" aria-hidden>↑</span>
  <span>8.2%</span>
</span>
<span class="stat-delta-label">vs last week</span>
<span class="sr-only">Up 8.2% vs last week</span>

This satisfies WCAG 1.4.1 (Use of Color) by design β€” every piece of information conveyed by colour is also conveyed by glyph and text.

The card is wrapped in an <article> because each StatCard is a standalone, self-contained unit of content. The title is an <h3>, which assumes the surrounding page has <h1>/<h2> higher up in the dashboard hierarchy.

Tabular Numerals

The .stat-value has font-variant-numeric: tabular-nums. Numerals are forced to equal width so a column of stacked values aligns visually:

Without tabular-nums:        With tabular-nums:
Β£12,450                      Β£12,450
Β£8,103                       Β£ 8,103
Β£104,230                     Β£104,230
                                  ↑ everything lines up at the comma

For dashboards where many StatCards stack vertically, this is the difference between a tidy column and a jaggy one. Same trick is applied to the delta numerals.

CSS Animation Strategy

The card has a quiet hover transition:

.stat-card {
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.stat-card:hover {
  border-color: #cbd5e1;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}

Just a one-step border darkening and a soft drop shadow. The card itself isn't clickable by default β€” these are informational β€” but the affordance suggests "you can interact with this", which is the right invitation when the consumer wraps the card in a button or a link.

prefers-reduced-motion: reduce removes the transition. The hover feedback still happens (the styles still resolve) but instantaneously.

State Flow Diagram

                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚  delta provided  β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ delta > 0  β”‚ delta < 0  β”‚ delta == 0
              β–Ό            β–Ό            β–Ό
        trend='up'   trend='down'  trend='flat'
              β”‚            β”‚            β”‚
              β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚
                   β”‚                    β”‚
                   β–Ό                    β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  sentiment='neutral'
        β”‚ trend ==             β”‚       (grey, β€”)
        β”‚ positiveDirection?   β”‚
        β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
        yes   β”‚        β”‚   no
              β–Ό        β–Ό
        positive  negative
        (green ↑) (red ↓)

                 β”‚
                 β–Ό
        Render arrow glyph + abs(delta) + sentiment colour
        + sr-only "Up 8.2% vs last week" sentence

Props Reference

Prop Type Default Description
title string '' Metric label, rendered as a small uppercase header.
value string | number '' The big number. Pre-format currencies, percentages, units in the caller.
delta number undefined Trend value. Sign drives the arrow direction; magnitude is the displayed delta.
deltaSuffix string '' Suffix appended to the absolute delta β€” e.g. '%' or ' pts'.
deltaLabel string '' Footer context line β€” e.g. 'vs last week'.
positiveDirection 'up' | 'down' 'up' Which trend direction is considered "good".
size 'sm' | 'md' | 'lg' 'md' Card physical size β€” drives padding and value font.
icon Snippet undefined Leading icon snippet shown in the header.
class string '' Extra classes appended to the article.

Edge Cases

Situation Behaviour
delta is undefined The footer is omitted entirely β€” just title and value render. Use this for metrics where comparison data is not yet available.
delta is 0 Trend is 'flat', sentiment is 'neutral', the glyph is β€” and the colour is grey. SR reads "No change".
delta is negative and positiveDirection="down" Sentiment is 'positive' β€” green colour, downward arrow. (Falling load time is good news.)
delta is negative and positiveDirection="up" (default) Sentiment is 'negative' β€” red colour, downward arrow. (Falling revenue is bad news.)
value is a raw number with no formatting Displayed as-is. Pre-format in the caller ('Β£12,450', '1.4s', '42%'); StatCard never guesses unit conventions.
Multiple StatCards stacked vertically font-variant-numeric: tabular-nums keeps numerals aligned across cards.
User has prefers-reduced-motion: reduce Hover transition is removed; the same visual result happens instantly.
Wrapped in a clickable parent Works fine β€” StatCard does not capture clicks itself. The hover affordance complements the wrapping link/button.

Dependencies

  • Svelte 5.x β€” $props, $derived, snippets.
  • Zero external runtime dependencies. Pure scoped CSS.

File Structure

src/lib/components/StatCard.svelte         # component implementation
src/lib/components/StatCard.md             # this file (rendered inside ComponentPageShell)
src/lib/components/StatCard.test.ts        # vitest unit tests
src/routes/statcard/+page.svelte           # demo page

API

04
PropTypeDefaultDescription
titlestringβ€”Caption above the metric value.
valuestring | numberβ€”Primary metric β€” pass a string to keep formatting (Β£, %).
deltanumberβ€”Trend value β€” drives the up/down/flat indicator and colour.
deltaSuffixstring""Suffix appended to the delta (e.g. %, pts).
deltaLabelstringβ€”Caption next to the delta (e.g. 'vs last week').
positiveDirection"up" | "down""up"Which direction is good β€” flips the colour logic for inverted metrics.
size"sm" | "md" | "lg""md"Padding and font scale.
iconSnippetβ€”Optional icon slot rendered alongside the title.
classstring""Extra class names forwarded to the root.