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.
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.
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
<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
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
<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
| Input | Default | Purpose |
|---|---|---|
data | [] | Ordered { label, value } points. Non-finite values create honest gaps in the line. |
label | — | Makes the graphic informative and supplies its accessible name. |
class / className | — | Ordinary Tailwind merged after the compact neutral size. Color follows currentColor. |
| native/global attributes | — | IDs, 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:
<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:
<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.
Related components
- 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
<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
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
<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>