---
title: "KPI scorecard template with sparklines for JavaScript — quario"
description: "A ready-made quario KPI scorecard. Each measure gets a sparkline of its last twelve months, its target, this month and the change. Copy the definition and the sparkline function, point them at your metrics, and render to PDF or HTML."
url: "https://getquario.com/templates/kpi-scorecard"
---

# 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:

```json title="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:

```ts title="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:

```json title="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.

*Preview: the page renders this definition and its sample data live, with downloads — [see it rendered](https://getquario.com/templates/kpi-scorecard).*

## Use it

```bash title="shell"
npm install quario @quario/pdf d3-scale d3-shape
```

```ts title="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 →](https://getquario.com/templates.md) — revenue reports, expense reports, invoices, and more.

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Templates","item":"https://getquario.com/templates"},{"@type":"ListItem","position":3,"name":"KPI scorecard","item":"https://getquario.com/templates/kpi-scorecard"}]}
```
