Skip to content

Sparkline

Sparkline is the small trend that sits beside an exact value. The number remains the truth; the line adds quick direction and shape without turning a compact status row into a chart dashboard.

Sparkline.vue

Installation

One command detects Vue, React, or Svelte and copies the matching one-file component into the conventional UI directory:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add sparkline

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

When to use

Use Sparkline in operational summaries, small metric cards, table cells, and dense side panels where a nearby visible number already states the current value.

Use Line Chart when the trend deserves its own caption, time span, and exact accessible values. Use Table when comparison and lookup matter more than shape.

Usage

Vue

CpuUsage.vue
<script setup>
import Sparkline from '@/components/ui/sparkline/Sparkline.vue'

const cpu = [
  { label: '12:00', value: 18 },
  { label: '12:05', value: 24 },
  { label: '12:10', value: 21 },
  { label: '12:15', value: 39 },
  { label: '12:20', value: 31 },
  { label: '12:25', value: 42 }
]
</script>

<template>
  <p class="flex items-end gap-3">
    <strong class="text-2xl tabular-nums">42%</strong>
    <Sparkline :data="cpu" class="mb-1 h-6 w-24 text-emerald-600" />
  </p>
</template>

React

CpuUsage.jsx
import Sparkline from '@/components/ui/sparkline/Sparkline.jsx'

const cpu = [
  { label: '12:00', value: 18 },
  { label: '12:05', value: 24 },
  { label: '12:10', value: 21 },
  { label: '12:15', value: 39 },
  { label: '12:20', value: 31 },
  { label: '12:25', value: 42 }
]

export default function CpuUsage() {
  return (
    <p className="flex items-end gap-3">
      <strong className="text-2xl tabular-nums">42%</strong>
      <Sparkline data={cpu} className="mb-1 h-6 w-24 text-emerald-600" />
    </p>
  )
}

Svelte

CpuUsage.svelte
<script>
  import Sparkline from '@/components/ui/sparkline/Sparkline.svelte'

  const cpu = [
    { label: '12:00', value: 18 },
    { label: '12:05', value: 24 },
    { label: '12:10', value: 21 },
    { label: '12:15', value: 39 },
    { label: '12:20', value: 31 },
    { label: '12:25', value: 42 }
  ]
</script>

<p class="flex items-end gap-3">
  <strong class="text-2xl tabular-nums">42%</strong>
  <Sparkline data={cpu} class="mb-1 h-6 w-24 text-emerald-600" />
</p>

API

InputDefaultPurpose
data[]Ordered { label, value } points. Non-finite values create honest gaps in the line.
labelMakes the graphic informative and supplies its accessible name.
class / classNameOrdinary Tailwind merged after the compact neutral size. Color follows currentColor.
native/global attributesIDs, data hooks, event hooks, and framework-native element references when truly needed.

There is no variant, color scale, tooltip, provider, animation, or chart configuration object.

Accessible by default

Without label, Sparkline is decorative and stays out of the accessibility tree. This is the normal choice when an adjacent number and visible text already communicate the metric.

Add label only when the shape itself contributes information that is not otherwise present:

vue
<Sparkline :data="cpu" label="CPU usage rose from 18 to 42 percent" />

A tooltip is never the only source of a value. Pointer hover, keyboard focus, touch, screenshots, print, and assistive technology all need the same truthful information.

Data behavior

  • Empty data renders an empty graphic without fabricated values.
  • One finite point renders a visible point.
  • Flat, zero, negative, and very large values remain finite and stable.
  • A missing or non-finite point breaks the line instead of drawing through unknown data.
  • The component is deterministic during server rendering.

Styling with Tailwind

The line uses the element's text color, so ordinary Tailwind owns size and color:

vue
<Sparkline :data="memory" class="h-8 w-40 text-sky-600 dark:text-sky-400" />

For repeated product treatment, keep a small application-owned wrapper. Sparkline does not acquire product tones or semantic variants.

  • Line Chart — a captioned trend with exact accessible values.
  • Table — exact comparison and lookup across rows and columns.
  • Tooltip — supplementary help, never the only source of chart data.
  • Card — a semantic surface that can contain a compact metric.

Complete framework source

Vue

Sparkline.vue
<script setup>
import { computed, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  data: { type: Array, default: () => [] },
  label: { type: String, default: undefined }
})

const attrs = useAttrs()
const width = 120
const height = 24
const inset = 1.5

function finiteValue(point) {
  return Number.isFinite(point?.value) ? point.value : undefined
}

function geometry(data) {
  const values = data.map(finiteValue).filter((value) => value !== undefined)
  if (!values.length) return { segments: [], points: [] }

  let minimum = Math.min(0, ...values)
  let maximum = Math.max(0, ...values)
  if (minimum === maximum) {
    minimum -= 1
    maximum += 1
  }

  const x = (index) =>
    data.length === 1
      ? width / 2
      : inset + (index / (data.length - 1)) * (width - inset * 2)
  const y = (value) =>
    inset + ((maximum - value) / (maximum - minimum)) * (height - inset * 2)

  const segments = []
  const points = []
  let segment = []

  data.forEach((point, index) => {
    const value = finiteValue(point)
    if (value === undefined) {
      if (segment.length) segments.push(segment)
      segment = []
      return
    }

    const coordinate = { x: x(index), y: y(value) }
    points.push(coordinate)
    segment.push(coordinate)
  })
  if (segment.length) segments.push(segment)

  return { segments, points }
}

const chart = computed(() => geometry(props.data))
const forwardedAttrs = computed(() => {
  const {
    class: _class,
    role: _role,
    'aria-label': _ariaLabel,
    'aria-hidden': _ariaHidden,
    'data-slot': _dataSlot,
    ...rest
  } = attrs
  return rest
})

function coordinates(segment) {
  return segment.map((point) => `${point.x},${point.y}`).join(' ')
}
</script>

<template>
  <svg
    v-bind="forwardedAttrs"
    data-slot="sparkline"
    :role="label ? 'img' : undefined"
    :aria-label="label"
    :aria-hidden="label ? undefined : 'true'"
    focusable="false"
    viewBox="0 0 120 24"
    preserveAspectRatio="none"
    fill="none"
    :class="twMerge('h-6 w-30 overflow-visible', attrs.class)"
  >
    <template v-for="(segment, index) in chart.segments" :key="index">
      <polyline
        v-if="segment.length > 1"
        data-slot="sparkline-line"
        :points="coordinates(segment)"
        stroke="currentColor"
        stroke-width="1.5"
        stroke-linecap="round"
        stroke-linejoin="round"
        vector-effect="non-scaling-stroke"
      />
      <circle
        v-else
        data-slot="sparkline-point"
        :cx="segment[0].x"
        :cy="segment[0].y"
        r="1.75"
        fill="currentColor"
        vector-effect="non-scaling-stroke"
      />
    </template>
  </svg>
</template>

React

Sparkline.jsx
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

const width = 120
const height = 24
const inset = 1.5

function finiteValue(point) {
  return Number.isFinite(point?.value) ? point.value : undefined
}

function geometry(data) {
  const values = data.map(finiteValue).filter((value) => value !== undefined)
  if (!values.length) return { segments: [], points: [] }

  let minimum = Math.min(0, ...values)
  let maximum = Math.max(0, ...values)
  if (minimum === maximum) {
    minimum -= 1
    maximum += 1
  }

  const x = (index) =>
    data.length === 1
      ? width / 2
      : inset + (index / (data.length - 1)) * (width - inset * 2)
  const y = (value) =>
    inset + ((maximum - value) / (maximum - minimum)) * (height - inset * 2)

  const segments = []
  const points = []
  let segment = []

  data.forEach((point, index) => {
    const value = finiteValue(point)
    if (value === undefined) {
      if (segment.length) segments.push(segment)
      segment = []
      return
    }

    const coordinate = { x: x(index), y: y(value) }
    points.push(coordinate)
    segment.push(coordinate)
  })
  if (segment.length) segments.push(segment)

  return { segments, points }
}

function coordinates(segment) {
  return segment.map((point) => `${point.x},${point.y}`).join(' ')
}

const Sparkline = forwardRef(function Sparkline(
  {
    data = [],
    label,
    className,
    role: _role,
    'aria-label': _ariaLabel,
    'aria-hidden': _ariaHidden,
    'data-slot': _dataSlot,
    ...props
  },
  ref
) {
  const chart = geometry(data)

  return (
    <svg
      {...props}
      ref={ref}
      data-slot="sparkline"
      role={label ? 'img' : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      focusable="false"
      viewBox="0 0 120 24"
      preserveAspectRatio="none"
      fill="none"
      className={twMerge('h-6 w-30 overflow-visible', className)}
    >
      {chart.segments.map((segment, index) =>
        segment.length > 1 ? (
          <polyline
            key={index}
            data-slot="sparkline-line"
            points={coordinates(segment)}
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            vectorEffect="non-scaling-stroke"
          />
        ) : (
          <circle
            key={index}
            data-slot="sparkline-point"
            cx={segment[0].x}
            cy={segment[0].y}
            r="1.75"
            fill="currentColor"
            vectorEffect="non-scaling-stroke"
          />
        )
      )}
    </svg>
  )
})

export default Sparkline

Svelte

Sparkline.svelte
<script>
  import { twMerge } from "tailwind-merge";

  let {
    data = [],
    label,
    class: className,
    role: _role,
    "aria-label": _ariaLabel,
    "aria-hidden": _ariaHidden,
    "data-slot": _dataSlot,
    ...props
  } = $props();

  const width = 120;
  const height = 24;
  const inset = 1.5;

  function finiteValue(point) {
    return Number.isFinite(point?.value) ? point.value : undefined;
  }

  function geometry(points) {
    const values = points
      .map(finiteValue)
      .filter((value) => value !== undefined);
    if (!values.length) return { segments: [], points: [] };

    let minimum = Math.min(0, ...values);
    let maximum = Math.max(0, ...values);
    if (minimum === maximum) {
      minimum -= 1;
      maximum += 1;
    }

    const x = (index) =>
      points.length === 1
        ? width / 2
        : inset + (index / (points.length - 1)) * (width - inset * 2);
    const y = (value) =>
      inset + ((maximum - value) / (maximum - minimum)) * (height - inset * 2);

    const segments = [];
    const chartPoints = [];
    let segment = [];

    points.forEach((point, index) => {
      const value = finiteValue(point);
      if (value === undefined) {
        if (segment.length) segments.push(segment);
        segment = [];
        return;
      }

      const coordinate = { x: x(index), y: y(value) };
      chartPoints.push(coordinate);
      segment.push(coordinate);
    });
    if (segment.length) segments.push(segment);

    return { segments, points: chartPoints };
  }

  function coordinates(segment) {
    return segment.map((point) => `${point.x},${point.y}`).join(" ");
  }

  let chart = $derived(geometry(data));
</script>

<svg
  {...props}
  data-slot="sparkline"
  role={label ? "img" : undefined}
  aria-label={label}
  aria-hidden={label ? undefined : "true"}
  focusable="false"
  viewBox="0 0 120 24"
  preserveAspectRatio="none"
  fill="none"
  class={twMerge("h-6 w-30 overflow-visible", className)}
>
  {#each chart.segments as segment, index (index)}
    {#if segment.length > 1}
      <polyline
        data-slot="sparkline-line"
        points={coordinates(segment)}
        stroke="currentColor"
        stroke-width="1.5"
        stroke-linecap="round"
        stroke-linejoin="round"
        vector-effect="non-scaling-stroke"
      />
    {:else}
      <circle
        data-slot="sparkline-point"
        cx={segment[0].x}
        cy={segment[0].y}
        r="1.75"
        fill="currentColor"
        vector-effect="non-scaling-stroke"
      />
    {/if}
  {/each}
</svg>

All open source projects are released under the MIT License.