Sparkline
Show a compact trend beside an exact value. The line adds direction and shape while the visible number provides the detail.
<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>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>
)
}<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>View source 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>Installation
One command detects Vue, React, or Svelte and copies the matching one-file component into the conventional UI directory:
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add sparklineWhen 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
Copy the example above for your framework.
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. |
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
<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>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<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>