Skip to content
  1. Metrics
  2. Sparkline

Sparkline

The shape of a short series, at the size of a table cell. No axis and no ticks: the exact figure is in the label.

Volume
€28,400
Payments
412
Customers
96
Refunds
7
import { Sparkline } from '@/components/sparkline'

export default function Example() {
  return <Sparkline series={[12, 18, 14, 22, 19, 27, 28]} label="Payments, last 7 days: rising" />
}
<script setup lang="ts">
import Sparkline from '@/components/sparkline.vue'
</script>

<template>
  <Sparkline :series="[12, 18, 14, 22, 19, 27, 28]" label="Payments, last 7 days: rising" />
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import clsx from 'clsx'
import type React from 'react'

const padding = 2

export function Sparkline({
  series,
  label,
  width = 96,
  height = 24,
  color = 'text-zinc-900 dark:text-white',
  className,
  ...props
}: {
  series: number[]
  label: string
  width?: number
  height?: number
  color?: string
} & Omit<React.ComponentPropsWithoutRef<'svg'>, 'width' | 'height' | 'color' | 'children'>) {
  let values = series.filter(Number.isFinite)
  let points: { x: number; y: number }[] = []
  if (values.length >= 2) {
    let max = Math.max(...values)
    let min = Math.min(...values)
    let step = width / (values.length - 1)
    let usable = height - padding * 2
    points = values.map((value, index) => ({
      x: index * step,
      y: max === min ? height / 2 : padding + (1 - (value - min) / (max - min)) * usable,
    }))
  }
  let line = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(' ')
  let area = points.length === 0 ? '' : `M 0,${height} ${line.slice(1)} L ${width},${height} Z`

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      {...props}
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
      role="img"
      aria-label={label}
      className={clsx(className, 'shrink-0', color)}
    >
      <path d={area} fill="currentColor" className="opacity-10" />
      <path
        d={line}
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        vectorEffect="non-scaling-stroke"
      />
    </svg>
  )
}
<script setup lang="ts">
import { computed } from 'vue'

const props = withDefaults(
  defineProps<{
    series: number[]
    label: string
    width?: number
    height?: number
    color?: string
  }>(),
  { width: 96, height: 24, color: 'text-zinc-900 dark:text-white' }
)

const padding = 2

const points = computed(() => {
  const values = props.series.filter(Number.isFinite)
  if (values.length < 2) return []

  const max = Math.max(...values)
  const min = Math.min(...values)
  const step = props.width / (values.length - 1)
  const usable = props.height - padding * 2

  return values.map((value, index) => ({
    x: index * step,
    y: max === min ? props.height / 2 : padding + (1 - (value - min) / (max - min)) * usable,
  }))
})

const line = computed(() =>
  points.value.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(' ')
)

const area = computed(() =>
  points.value.length === 0 ? '' : `M 0,${props.height} ${line.value.slice(1)} L ${props.width},${props.height} Z`
)
</script>

<template>
  <svg
    xmlns="http://www.w3.org/2000/svg"
    :width="width"
    :height="height"
    :viewBox="`0 0 ${width} ${height}`"
    role="img"
    :aria-label="label"
    :class="['shrink-0', color]"
  >
    <path :d="area" fill="currentColor" class="opacity-10" />
    <path
      :d="line"
      fill="none"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
      vector-effect="non-scaling-stroke"
    />
  </svg>
</template>

Component API

PropDefaultDescription
Sparklinerenders a <svg> element
series-Values, oldest first. Values that are not finite numbers are skipped, under two values nothing is drawn, and equal values draw a flat line at mid height.
label-What the curve says, for whoever cannot see it: the figure and the direction.
width96Width in pixels.
height24Height in pixels.
colortext-zinc-900 dark:text-whiteText color classes. The line and the area under it read `currentColor`.

Examples

Basic example

Pass the values, oldest first, and a label that says what a sighted reader would take from the curve. The line spans the width and is scaled to its own minimum and maximum:

import { Sparkline } from '@/components/sparkline'

export default function Example() {
  return <Sparkline series={[12, 18, 14, 22, 19, 27, 28]} label="Payments, last 7 days: rising" />
}
<script setup lang="ts">
import Sparkline from '@/components/sparkline.vue'
</script>

<template>
  <Sparkline :series="[12, 18, 14, 22, 19, 27, 28]" label="Payments, last 7 days: rising" />
</template>

Sizing

Set width and height in pixels. The stroke stays at 1.5 pixels whatever the size:

<Sparkline series={series} label="Payments, last 7 days" width={80} height={28} />
<Sparkline series={series} label="Payments, last 7 days" width={160} height={40} />
<Sparkline :series="series" label="Payments, last 7 days" :width="80" :height="28" />
<Sparkline :series="series" label="Payments, last 7 days" :width="160" :height="40" />

Color

Pass text color classes to color. Keep it for a verdict the label also carries:

<Sparkline series={rising} label="Payments, last 7 days: rising" color="text-emerald-600 dark:text-emerald-400" />
<Sparkline series={falling} label="Refunds, last 7 days: falling" color="text-red-600 dark:text-red-400" />
<Sparkline :series="rising" label="Payments, last 7 days: rising" color="text-emerald-600 dark:text-emerald-400" />
<Sparkline :series="falling" label="Refunds, last 7 days: falling" color="text-red-600 dark:text-red-400" />

In a table

The default size fits a table cell. When there is nothing to draw, show a dash rather than an empty box:

CustomerPaymentsTrend (30d)
Awa Diallo42
Moussa Traoré18
Nadia Market0—
<TableCell>
  {customer.payments > 0 ? (
    <Sparkline series={customer.series} label={`${customer.payments} payments in the last 30 days`} />
  ) : (
    <span className="text-zinc-400 dark:text-zinc-500">—</span>
  )}
</TableCell>
<TableCell>
  <Sparkline
    v-if="customer.payments > 0"
    :series="customer.series"
    :label="`${customer.payments} payments in the last 30 days`"
  />
  <span v-else class="text-zinc-400 dark:text-zinc-500">—</span>
</TableCell>

With a figure

Beside a figure, align it to the baseline with items-end and let the number keep the left:

Payments
412
<div>
  <dt className="truncate text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">Payments</dt>
  <dd className="mt-1 flex items-end justify-between gap-4">
    <span className="text-2xl/8 font-semibold text-zinc-950 tabular-nums sm:text-xl/8 dark:text-white">412</span>
    <Sparkline series={[40, 52, 48, 61, 58, 70, 83]} label="Payments, last 7 days: rising" width={80} height={28} />
  </dd>
</div>
<div>
  <dt class="truncate text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">Payments</dt>
  <dd class="mt-1 flex items-end justify-between gap-4">
    <span class="text-2xl/8 font-semibold text-zinc-950 tabular-nums sm:text-xl/8 dark:text-white">412</span>
    <Sparkline :series="[40, 52, 48, 61, 58, 70, 83]" label="Payments, last 7 days: rising" :width="80" :height="28" />
  </dd>
</div>