KPI card blocks

The metric tiles at the top of an app dashboard: a few numbers someone checks every morning, with enough context to tell whether they moved. Each block is one .svelte file built from SvCard, SvStat, SvSparkline, SvBadge, SvSegmented and SvSwitchButton, themed from the --sg-* tokens.

Installation

Add the whole group, or one block by its name (shown under each demo):

See them first with npx @svgrid/ui try blocks/kpi-cards. No project needed.

With icons

Each card has a tinted icon tile, today's value and the 7-day average. The cards are toggle buttons with aria-pressed: click one and it takes the accent border, and the SvCard panel underneath shows that metric's last seven days as a bar and a value per day.

Open the live example: KPI cards with icons block (Blocks)

npx @svgrid/ui add kpi-cards-icon

With trend

Four SvStat cards that compare each metric with the period before: the change with an up or down arrow, green when the move is good and red when it is bad, and the previous value. Churn rate and time to convert pass invert, so a drop shows green; churn moves in percentage points. The SvSegmented switch (7d, 30d, 90d) changes every number on the row.

Open the live example: KPI cards with trend block (Blocks)

npx @svgrid/ui add kpi-cards-trend

With sparklines

Live service metrics. Each card shows the current value, the average and peak over the last 30 seconds, and an SvSparkline area chart as wide as the card. With the Live SvSwitchButton on, every card gets a new point each second and drops its oldest one; turn it off and the numbers stop. The timer lives in an $effect, so it is cleared when Live goes off and when the block unmounts.

Open the live example: KPI cards with sparklines block (Blocks)

npx @svgrid/ui add kpi-cards-sparkline

Loading the numbers in SvelteKit

Each block's core takes its numbers as a metrics option and falls back to the sample array when you pass none. In a SvelteKit app, return that array from the page's server load and read it from data. Tag the load with depends so the page can ask for fresh numbers without a navigation:

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { getTrendMetrics } from '$lib/server/metrics'

export const load: PageServerLoad = async ({ depends }) => {
  depends('app:metrics')
  // [{ label, unit, invert?, now: { '7d', '30d', '90d' }, prev: { ... } }, ...]
  // in the trend block's TrendMetric shape
  return { metrics: await getTrendMetrics() }
}

In the block file, take the array as a prop and hand it to the core:

let { metrics }: { metrics?: TrendMetric[] } = $props()

const kpi = createKpiTrendCards({ metrics })

invalidate('app:metrics') reruns that load and gives data.metrics a new array. Call it on a timer, from a Refresh button, or both:

<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  import { invalidate } from '$app/navigation'
  import { SvButton } from '@svgrid/grid'
  import KpiTrend from '$lib/components/ui/kpi-cards-trend.svelte'

  let { data } = $props()
  let refreshing = $state(false)

  // Poll once a minute while the page is open; the teardown stops it on leave.
  $effect(() => {
    const t = setInterval(() => invalidate('app:metrics'), 60_000)
    return () => clearInterval(t)
  })

  async function refresh() {
    refreshing = true
    await invalidate('app:metrics')
    refreshing = false
  }
</script>

<SvButton variant="secondary" loading={refreshing} onclick={refresh}>Refresh</SvButton>

{#key data.metrics}
  <KpiTrend metrics={data.metrics} />
{/key}

A core reads its options once, when it is created, so the {#key} block creates the block again whenever the array changes. The period switch lives in the core and starts back on its default (30d) each time. To keep the visitor's choice across refreshes, add a period prop next to metrics and pass it to createKpiTrendCards({ metrics, period }), or keep it in ?period= and read it from page.url.searchParams. invalidateAll() works in place of invalidate('app:metrics') if you would rather rerun every load on the page.

For the live block, a one-second invalidate is a server round trip per tick. Create its core with live: false so the sample random walk never starts, and call tick(values) from an EventSource or websocket handler with one number per metric, in card order. Each call appends a point and drops the oldest:

const kpi = createLiveKpis({ live: false })

$effect(() => {
  const source = new EventSource('/api/metrics/stream')
  source.onmessage = (e) => kpi.tick(JSON.parse(e.data))
  return () => source.close()
})

The Live switch in the block then only toggles the sample walk, so wire it to open and close the stream instead, or remove it.

Headless

Each block's <script module> exports its core: createKpiIconCards(), createKpiTrendCards() and createLiveKpis(). The core holds the numbers, the formatting and any selection, period or live state, with no markup. Take one alone with npx @svgrid/ui add kpi-cards-icon --headless and render your own tiles; the card props carry aria-pressed and the click handler:

<script lang="ts">
  import { createKpiIconCards } from './kpi-cards-icon.svelte.js'

  let { data } = $props()
  const kpi = createKpiIconCards({ metrics: data.metrics })
</script>

{#each kpi.metrics as m (m.key)}
  <button {...kpi.cardProps(m.key)}>
    {m.label}: {kpi.fmt(m, kpi.today(m))}, 7-day avg {kpi.fmt(m, kpi.avg(m))}
  </button>
{/each}

<ol aria-label={`${kpi.current.label}, last 7 days`}>
  {#each kpi.current.days as v, i (i)}
    <li>
      <span class="bar" style:height={kpi.barHeight(v)}></span>
      {kpi.dayLabels[i]} {kpi.short(kpi.current, v)}
    </li>
  {/each}
</ol>

See also

Live examples

  • KPI cards with icons block - Metric tiles with a tinted inline-SVG icon, today's value and the 7-day average. Clicking a card selects it (aria-pressed) and the SvCard panel below shows that metric's last 7 days as one bar and value per day.
  • KPI cards with trend block - Four SvStat cards with the change vs the previous period, an up/down arrow colored by whether the move is good, and the previous value. Churn and time to convert use invert. An SvSegmented 7d/30d/90d switch changes every number.
  • KPI cards with sparklines block - Live API metrics in SvCard tiles, each with value, 30-second average and peak, and a full-width SvSparkline area chart. The Live SvSwitchButton appends a point every second; turning it off stops the interval.