Escape

KPI scorecard template with sparklines

A number on a scorecard means little without the months before it. 79.4% proofs approved first time is good news if it was 71% a year ago, and bad news if it was 85%. So each measure here gets a sparkline of its last twelve months beside its target. Then come this month’s figure and the change against last month. One function draws every sparkline, called once per row. This is a ready-made quario scorecard — copy it, point it at your metrics, and render to PDF or HTML.

The template

The whole report is a definition — data you can version and review. This is the exact file the preview below renders:

kpi-scorecard.report.json
{
  "data": "$.scorecard.metrics[*]",
  "style": { "family": "sans", "size": 10 },
  "header": [
    {
      "type": "split",
      "slots": [
        { "type": "text", "value": "Operations scorecard", "width": 60,
          "style": { "family": "serif", "size": 19, "bold": true, "color": "#1e1f22" } },
        { "type": "text", "value": "{{ $.input.scorecard.month }}",
          "style": { "size": 8, "bold": false, "color": "#989a97", "align": "right" } }
      ]
    },
    {
      "type": "split",
      "slots": [
        { "type": "text", "value": "{{ $.input.scorecard.company }}", "width": 60,
          "style": { "size": 8, "bold": false, "color": "#989a97" } },
        { "type": "text", "value": [{ "value": "Prepared " }, { "value": "{{ $.input.scorecard.prepared }}" }],
          "style": { "size": 8, "bold": false, "color": "#989a97", "align": "right", "format": "date" } }
      ]
    },
    {
      "type": "text",
      "value": "Six measures of how the studio ran this month, each beside its last twelve months and its target. The grey line is the target, and the dot is this month.",
      "style": { "size": 9, "bold": false, "color": "#5e6062", "spaceBefore": 24, "spaceAfter": 22 }
    },
    {
      "type": "split",
      "style": { "family": "mono", "size": 7, "bold": false, "uppercase": true, "color": "#989a97",
        "paddingBottom": 4, "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" },
      "slots": [
        { "type": "text", "value": "Measure", "width": 32 },
        { "type": "text", "value": "{{ $.input.scorecard.span }}", "width": 34 },
        { "type": "text", "value": "{{ $.input.scorecard.month }}", "width": 16, "style": { "align": "right" } },
        { "type": "text", "value": "Against {{ $.input.scorecard.previousMonth }}", "style": { "align": "right" } }
      ]
    }
  ],
  "detail": [
    {
      "type": "split",
      "style": { "paddingTop": 14, "paddingBottom": 14,
        "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#e3e8eb" },
      "slots": [
        { "type": "text", "width": 32, "style": { "valign": "middle" },
          "value": [{ "value": "{{ @.name }}", "style": { "size": 11, "bold": true, "color": "#1e1f22" } }, { "value": "\n{{ @.note }}", "style": { "size": 8, "color": "#989a97" } }] },
        { "type": "image", "source": "=spark(@.series, @.target)", "fit": "width", "alt": "{{ @.name }}, last twelve months", "width": 34,
          "style": { "valign": "middle" } },
        { "type": "text", "value": "{{ @.current }}", "width": 16,
          "style": { "family": "serif", "size": 16, "color": "#1e1f22", "align": "right", "valign": "middle", "format": { "kind": "percent", "digits": 1 } } },
        { "type": "text", "style": { "size": 8, "color": "#5e6062", "align": "right", "valign": "middle" },
          "value": [{ "value": "{{ round((@.current - @.previous) * 100, 1) }}", "style": { "format": { "kind": "number", "digits": 1 } } }, { "value": " pts\nTarget " }, { "value": "{{ @.target }}", "style": { "format": { "kind": "percent", "digits": 1 } } }] }
      ]
    }
  ],
  "footer": [
    { "type": "text", "value": "A change is in percentage points, this month's rate less last month's. Source: job tickets and the press log.",
      "style": { "size": 8, "color": "#989a97", "spaceBefore": 14 } }
  ],
  "page": {
    "footer": [
      {
        "type": "split",
        "style": { "paddingTop": 10,
          "borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
        "slots": [
          { "type": "text", "value": "{{ $.input.scorecard.company }} · internal", "width": 70,
            "style": { "size": 8, "color": "#989a97" } },
          { "type": "text", "value": "Page {{ page.number }} of {{ page.total }}",
            "style": { "size": 8, "color": "#989a97", "align": "right" } }
        ]
      }
    ]
  }
}

detail is an array of items rather than a table, because a table cell can’t hold an image and every row here has one. Each measure renders the one split: its name and note, the sparkline, this month, and the change. The sparkline’s source is =spark(@.series, @.target), so @ is that row’s measure and the function draws it.

The name and the note are one cell written as two styled runs, with the line break inside the second. The change is round((@.current - @.previous) * 100, 1) under the number kind, in percentage points. A rate that moves from 64.8% to 77.1% has risen 12.3 points, not 12.3%.

The sparkline

d3’s scales place twelve months across the width, and d3-shape’s line() draws through them. The grey line is the target, and the dot is this month. The scale takes in the target as well as the months, so a target the year never reached still sits on the plot:

spark.ts
import { scaleLinear } from "d3-scale";
import { line } from "d3-shape";

/** Twelve months of one metric as a sparkline beside its target, returned as the SVG's bytes. */
export function spark(series: number[], target: number): Uint8Array {
  const width = 260;
  const height = 80;
  const pad = 6;

  const values = [...series, target];
  const x = scaleLinear([0, series.length - 1], [pad, width - pad]);
  const y = scaleLinear(
    [Math.min(...values), Math.max(...values)],
    [height - pad, pad],
  ).nice();

  const path = line<number>(
    (_, i) => x(i),
    (d) => y(d),
  )(series);
  const last = series.length - 1;

  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
  <line x1="${pad}" x2="${width - pad}" y1="${y(target)}" y2="${y(target)}" stroke="#c4c8ca" stroke-width="1.5" />
  <path d="${path}" fill="none" stroke="#2e708f" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
  <circle cx="${x(last)}" cy="${y(series[last])}" r="4" fill="#2e708f" stroke="#ffffff" stroke-width="2" />
</svg>`;
  return new TextEncoder().encode(svg);
}

The target is a solid line in a lighter grey rather than a dashed one. The PDF target draws a stroke’s colour and width but not a dash pattern. A scorecard that reads one way on screen and another on paper is worse than either.

The data

Each measure carries its rates as fractions — 0.958 for 95.8% — so the percent kind presents them and a workbook would hold them as numbers:

kpi-scorecard.data.json
{
  "scorecard": {
    "company": "Quire Press Ltd.",
    "month": "September 2026",
    "previousMonth": "August",
    "prepared": "2026-10-03",
    "span": "October 2025 to September 2026",
    "metrics": [
      {"name": "On-time delivery", "note": "Orders shipped by the promised date", "target": 0.95, "previous": 0.932, "current": 0.958,
        "series": [0.912, 0.905, 0.921, 0.934, 0.928, 0.941, 0.937, 0.946, 0.951, 0.944, 0.932, 0.958]},
      {"name": "First-pass proof approval", "note": "Proofs approved without a second round", "target": 0.8, "previous": 0.781, "current": 0.794,
        "series": [0.712, 0.734, 0.729, 0.748, 0.755, 0.762, 0.771, 0.768, 0.79, 0.776, 0.781, 0.794]},
      {"name": "Press utilisation", "note": "Scheduled press hours put to work", "target": 0.75, "previous": 0.648, "current": 0.771,
        "series": [0.702, 0.735, 0.781, 0.644, 0.668, 0.712, 0.726, 0.739, 0.698, 0.611, 0.648, 0.771]},
      {"name": "Paper waste", "note": "Sheets spoiled in make-ready and trim, lower is better", "target": 0.06, "previous": 0.071, "current": 0.064,
        "series": [0.089, 0.084, 0.081, 0.083, 0.077, 0.075, 0.072, 0.074, 0.069, 0.068, 0.071, 0.064]},
      {"name": "Reprint rate", "note": "Orders reprinted for a fault, lower is better", "target": 0.02, "previous": 0.018, "current": 0.024,
        "series": [0.031, 0.027, 0.029, 0.022, 0.025, 0.021, 0.019, 0.023, 0.017, 0.02, 0.018, 0.024]},
      {"name": "Repeat customers", "note": "Orders from customers who ordered before", "target": 0.6, "previous": 0.617, "current": 0.633,
        "series": [0.548, 0.556, 0.571, 0.566, 0.582, 0.579, 0.594, 0.601, 0.598, 0.61, 0.617, 0.633]}
    ]
  }
}

What it renders

This isn’t a screenshot. quario compiles the definition and data above, runs spark.ts in your browser for each row, and @quario/viewer paints the result right here. The PDF button hands you the same report as real @quario/pdf bytes, with every sparkline as vector paths. There’s no Word button: @quario/docx refuses an SVG image.

Rendering live with @quario/viewerA4

Use it

shell
npm install quario @quario/pdf d3-scale d3-shape
kpi-scorecard.ts
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import scorecard from './kpi-scorecard.report.json' with { type: 'json' }

// `spark` is the function from spark.ts
const q = quario({ locale: 'en-IE' })                 // what `format` presents through
const report = q.report(scorecard, { spark })

const data = await getScorecard('2026-09')            // your metrics
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)

writeFileSync('kpi-scorecard.pdf', bytes)

Make it yours

A measure in euros rather than a rate is a second split in detail with a visible on each, so a row shows the one whose format fits it. A section per team is a group around the rows.

FAQ

Can I export the scorecard to Excel or Word?

Not with SVG sparklines in it. A worksheet drawing holds a raster picture only, and Word shows an SVG only beside a raster copy. So @quario/xlsx and @quario/docx refuse one, with a render error that names the item. For those renders, register a spark that returns PNG bytes.

Why percentage points for the change?

Because the measures are already percentages. A change of 12.3% on a rate of 64.8% would be 8 points. A reader would have to stop and work out which one they’re looking at. Points are the plain difference between this month and last.

Where are the other templates?

Browse the template gallery → — revenue reports, expense reports, invoices, and more.