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.

A live, editable Svelte 5 component example from the SvGrid gallery (Blocks). Read the SvGrid UI component docs for the full API.

About this example

Selectable KPI cards for a Svelte 5 dashboard, each with a tinted inline-SVG icon, today's value and the 7-day average. The cards are toggle buttons with aria-pressed; clicking one highlights it and the SvCard panel below shows that metric's last seven days as one labelled bar per day.

KPI cards with icons block - dashboard metric tiles, each with a tinted inline-SVG icon, today's value and the 7-day average. The cards are toggle buttons (aria-pressed): clicking one selects it and the SvCard panel below shows that metric's last 7 days, one labelled bar per day. Pure UI-kit, --sg-* themed.

Headless: createKpiIconCards() below owns the metrics, the selected card, the week's peak and the number formatting with no markup. The component under it is one styled render of that core; import createKpiIconCards from this file (or npx @svgrid/ui add kpi-cards-icon --headless for the core alone) to draw your own.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

How does selecting a card work?

Each card is a native button with aria-pressed bound to whether its key equals the selected state. Clicking sets selected, and the panel reads the current metric through a $derived lookup, so the title, bars and values all switch together.

How are the bars drawn?

With plain spans in a seven-column CSS grid. Each bar's height is its value as a percentage of the week's peak, and today's bar is drawn darker. The values under the bars are real text, so screen readers get the numbers.

What changes on a phone?

The four cards become a two-by-two grid below 900 px. Below 560 px the bars get shorter, the gaps tighter, and revenue values use a short form such as $11.8k so seven columns fit in 375 px.

Related documentation

Source code (556-block-kpi-cards-icon.svelte)

<script lang="ts" module>
  /**
   * KPI cards with icons block - dashboard metric tiles, each with a tinted
   * inline-SVG icon, today's value and the 7-day average. The cards are
   * toggle buttons (aria-pressed): clicking one selects it and the SvCard
   * panel below shows that metric's last 7 days, one labelled bar per day.
   * Pure UI-kit, --sg-* themed.
   *
   * Headless: createKpiIconCards() below owns the metrics, the selected card,
   * the week's peak and the number formatting with no markup. The component
   * under it is one styled render of that core; import createKpiIconCards
   * from this file (or `npx @svgrid/ui add kpi-cards-icon --headless` for the
   * core alone) to draw your own.
   */
  export type IconMetric = {
    key: string; label: string
    unit: 'usd' | 'count'
    /** Oldest first; the last value is today. */
    days: number[]
    /** Icon strokes on a 24 x 24 box. */
    icon: string[]
  }
  export type KpiIconOptions = {
    metrics?: IconMetric[]
    /** One label per entry in `days`; Oct 9 to Oct 15 by default. */
    dayLabels?: string[]
    selected?: string
  }

  const SAMPLE_DAYS = ['Oct 9', 'Oct 10', 'Oct 11', 'Oct 12', 'Oct 13', 'Oct 14', 'Oct 15']
  const SAMPLE_METRICS: IconMetric[] = [
    { key: 'revenue', label: 'Revenue', unit: 'usd', days: [11840, 12310, 9870, 13420, 12960, 14110, 12480],
      icon: ['M12 3v18', 'M16.5 7.5c0-1.7-2-3-4.5-3s-4.5 1.3-4.5 3 2 2.6 4.5 3 4.5 1.3 4.5 3-2 3-4.5 3-4.5-1.3-4.5-3'] },
    { key: 'signups', label: 'New signups', unit: 'count', days: [184, 201, 176, 229, 240, 198, 212],
      icon: ['M5.5 8a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0', 'M2.5 20c.8-3.6 3.4-5.5 6.5-5.5s5.7 1.9 6.5 5.5', 'M18.5 8v6M15.5 11h6'] },
    { key: 'projects', label: 'Active projects', unit: 'count', days: [1204, 1211, 1219, 1226, 1230, 1241, 1248],
      icon: ['M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'] },
    { key: 'tickets', label: 'Support tickets', unit: 'count', days: [38, 42, 51, 36, 33, 29, 31],
      icon: ['M4 5h16v11H9l-5 4z', 'M8 9.5h8M8 12.5h5'] },
  ]

  const fmt = (m: IconMetric, n: number) =>
    m.unit === 'usd' ? '$' + n.toLocaleString('en-US') : n.toLocaleString('en-US')
  /** Short form for the narrow day columns: $11.8k. */
  const short = (m: IconMetric, n: number) =>
    m.unit === 'usd' && n >= 1000 ? `$${(n / 1000).toFixed(1)}k` : fmt(m, n)
  const avg = (m: IconMetric) => Math.round(m.days.reduce((a, b) => a + b, 0) / m.days.length)

  export function createKpiIconCards(opts: KpiIconOptions = {}) {
    const metrics = opts.metrics ?? SAMPLE_METRICS
    const dayLabels = opts.dayLabels ?? SAMPLE_DAYS

    let selected = $state(opts.selected ?? metrics[0]?.key ?? '')
    const current = $derived(metrics.find((m) => m.key === selected) ?? metrics[0]!)
    const peak = $derived(Math.max(...current.days))

    return {
      metrics,
      dayLabels,
      get selected() { return selected },
      set selected(key: string) { selected = key },
      /** The selected metric. */
      get current() { return current },
      fmt,
      short,
      avg,
      today: (m: IconMetric) => m.days[m.days.length - 1]!,
      /** Bar height for a value, as a percentage of the selected week's peak. */
      barHeight: (v: number) => `${Math.round((v / peak) * 100)}%`,
      /** Spread on each card <button>. */
      cardProps: (key: string) => ({
        type: 'button' as const,
        'aria-pressed': selected === key,
        onclick: () => { selected = key },
      }),
    }
  }
</script>

<script lang="ts">
  import { SvCard } from '@svgrid/grid'

  const kpi = createKpiIconCards()
</script>

<section class="sv-kc-icon" aria-labelledby="kpi-icon-title">
  <header class="sv-kc-icon-head">
    <h2 id="kpi-icon-title">Today</h2>
    <p class="sv-kc-icon-sub">Fieldline, October 15. Pick a metric to see its week.</p>
  </header>

  <div class="sv-kc-icon-cards">
    {#each kpi.metrics as m (m.key)}
      <button class="sv-kc-icon-metric" {...kpi.cardProps(m.key)}>
        <span class="sv-kc-icon-tile" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
            {#each m.icon as d (d)}<path {d} />{/each}
          </svg>
        </span>
        <span class="sv-kc-icon-label">{m.label}</span>
        <span class="sv-kc-icon-value">{fmt(m, kpi.today(m))}</span>
        <span class="sv-kc-icon-caption">7-day avg {fmt(m, avg(m))}</span>
      </button>
    {/each}
  </div>

  <div class="sv-kc-icon-panel" aria-live="polite">
    <SvCard title={`${kpi.current.label}, last 7 days`} subtitle="October 9 to 15">
      <ol class="sv-kc-icon-days">
        {#each kpi.current.days as v, i (kpi.dayLabels[i])}
          <li>
            <span class="sv-kc-icon-bar" class:sv-kc-icon-is-today={i === kpi.current.days.length - 1}
              style:height={kpi.barHeight(v)} aria-hidden="true"></span>
            <span class="sv-kc-icon-day">{kpi.dayLabels[i]}</span>
            <span class="sv-kc-icon-num" title={fmt(kpi.current, v)}>{short(kpi.current, v)}</span>
          </li>
        {/each}
      </ol>
    </SvCard>
  </div>
</section>

<style>
  .sv-kc-icon {
    width: 100%; box-sizing: border-box; min-height: 480px; padding: 28px 32px;
    background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
    border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius-lg, 12px);
    font-family: var(--sg-font, inherit);
  }
  .sv-kc-icon-head { margin-bottom: 18px; }
  h2 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -.01em; }
  .sv-kc-icon-sub { margin: 4px 0 0; font-size: 13.5px; color: var(--sg-muted, #64748b); }

  .sv-kc-icon-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
  .sv-kc-icon-metric {
    display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0;
    padding: 16px; text-align: left; font: inherit; color: inherit; cursor: pointer;
    background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
    border-radius: var(--sg-radius-lg, 12px); transition: border-color .15s, box-shadow .15s;
  }
  .sv-kc-icon-metric:hover { border-color: color-mix(in srgb, var(--sg-fg, #0f172a) 25%, var(--sg-border, #e2e8f0)); }
  .sv-kc-icon-metric:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 2px; }
  .sv-kc-icon-metric[aria-pressed='true'] {
    border-color: var(--sg-accent, #2563eb);
    box-shadow: 0 0 0 1px var(--sg-accent, #2563eb);
  }
  .sv-kc-icon-tile {
    display: inline-flex; align-items: center; justify-content: center;
    width: 36px; height: 36px; margin-bottom: 8px; border-radius: var(--sg-radius, 8px);
    color: var(--sg-muted, #64748b);
    background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, var(--sg-bg, #fff));
  }
  .sv-kc-icon-tile svg { display: block; }
  .sv-kc-icon-metric[aria-pressed='true'] .sv-kc-icon-tile {
    color: var(--sg-accent, #2563eb);
    background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent);
  }
  .sv-kc-icon-label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
  .sv-kc-icon-value { font-size: 24px; font-weight: 700; line-height: 1.15; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
  .sv-kc-icon-caption { font-size: 12px; color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }

  .sv-kc-icon-panel { margin-top: 16px; }
  .sv-kc-icon-days {
    list-style: none; margin: 0; padding: 0;
    display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 10px;
  }
  /* Fixed-height bar row on top, so every day's label lines up. */
  .sv-kc-icon-days li { display: grid; grid-template-rows: 88px auto auto; gap: 2px; min-width: 0; }
  .sv-kc-icon-bar {
    align-self: end; width: 100%; max-width: 44px;
    border-radius: 4px 4px 0 0; transition: height .2s ease;
    background: color-mix(in srgb, var(--sg-fg, #0f172a) 22%, transparent);
  }
  .sv-kc-icon-bar.sv-kc-icon-is-today { background: color-mix(in srgb, var(--sg-fg, #0f172a) 55%, transparent); }
  .sv-kc-icon-day { margin-top: 8px; font-size: 11.5px; color: var(--sg-muted, #64748b); }
  .sv-kc-icon-num { font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }

  @media (max-width: 900px) {
    .sv-kc-icon-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  @media (max-width: 560px) {
    .sv-kc-icon { padding: 20px 16px; }
    .sv-kc-icon-cards { gap: 10px; }
    .sv-kc-icon-metric { padding: 14px 12px; }
    .sv-kc-icon-value { font-size: 20px; }
    .sv-kc-icon-days { gap: 4px; }
    .sv-kc-icon-days li { grid-template-rows: 64px auto auto; }
    .sv-kc-icon-day, .sv-kc-icon-num { font-size: 11px; }
  }
</style>

View this example on GitHub

More Blocks examples

  • Login block - A two-column authentication page: a branded gradient panel beside a sign-in SvCard with social SvButtons, SvTextInput + SvPasswordInput (reveal), remember-me SvCheckBox, an "or" SvDivider and forgot-password link. Drops into any route as-is.
  • Sign-up block - A centered account-creation card: two-up name fields, work email, a password with the live strength meter, a plan SvSegmented switch, terms SvCheckBox and a benefits rail. Submit fires a promise toast. Pure UI-kit.
  • Two-factor / OTP block - The verification step of a sign-in flow: SvOtpInput (6 digits, auto-advance + onComplete), a resend cooldown timer, and the waiting -> verifying -> verified states. Enter 000000 to see the error path.
  • Analytics dashboard block - A full overview page: a range SvSegmented + actions header, a KPI grid (SvStat + SvSparkline), a revenue bar panel, a recent-activity SvTimeline and a top-products list with SvProgress bars. No grid dependency.
  • App shell block - A complete application layout: a SvNavPane sidebar with sections + badges, a top bar with SvBreadcrumb, search and an account SvMenu, and a routed content area. Nav swaps the page; the toggle collapses the rail to an icon rail.