Line Chart
Plot an ordered trend with exact values available on hover, touch, and keyboard focus.
<script setup>
import LineChart from '@/components/ui/line-chart/LineChart.vue'
const signups = [
{ label: 'Fri', value: 4, detail: 'Friday, 4 signups' },
{ label: 'Sat', value: 4, detail: 'Saturday, 4 signups' },
{ label: 'Sun', value: 7, detail: 'Sunday, 7 signups' },
{ label: 'Mon', value: 7, detail: 'Monday, 7 signups' },
{ label: 'Tue', value: 4, detail: 'Tuesday, 4 signups' },
{ label: 'Wed', value: 4, detail: 'Wednesday, 4 signups' },
{ label: 'Thu', value: 5, detail: 'Thursday, 5 signups' }
]
</script>
<template>
<LineChart
:data="signups"
caption="Signups — last 7 days"
class="h-56 text-gray-950"
/>
</template>import LineChart from '@/components/ui/line-chart/LineChart.jsx'
const signups = [
{ label: 'Fri', value: 4, detail: 'Friday, 4 signups' },
{ label: 'Sat', value: 4, detail: 'Saturday, 4 signups' },
{ label: 'Sun', value: 7, detail: 'Sunday, 7 signups' },
{ label: 'Mon', value: 7, detail: 'Monday, 7 signups' },
{ label: 'Tue', value: 4, detail: 'Tuesday, 4 signups' },
{ label: 'Wed', value: 4, detail: 'Wednesday, 4 signups' },
{ label: 'Thu', value: 5, detail: 'Thursday, 5 signups' }
]
export default function SignupChart() {
return (
<LineChart
data={signups}
caption="Signups — last 7 days"
className="h-56 text-gray-950"
/>
)
}<script>
import LineChart from '@/components/ui/line-chart/LineChart.svelte'
const signups = [
{ label: 'Fri', value: 4, detail: 'Friday, 4 signups' },
{ label: 'Sat', value: 4, detail: 'Saturday, 4 signups' },
{ label: 'Sun', value: 7, detail: 'Sunday, 7 signups' },
{ label: 'Mon', value: 7, detail: 'Monday, 7 signups' },
{ label: 'Tue', value: 4, detail: 'Tuesday, 4 signups' },
{ label: 'Wed', value: 4, detail: 'Wednesday, 4 signups' },
{ label: 'Thu', value: 5, detail: 'Thursday, 5 signups' }
]
</script>
<LineChart
data={signups}
caption="Signups — last 7 days"
class="h-56 text-gray-950"
/>View source LineChart.vue
<script setup>
import { computed, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
data: { type: Array, default: () => [] },
caption: { type: String, required: true },
emptyLabel: { type: String, default: 'No data' },
formatValue: { type: Function, default: (value) => String(value) }
})
const attrs = useAttrs()
const width = 640
const height = 200
const inset = 8
const cornerRadius = 20
const guides = [inset, height / 2, height - inset]
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: [], minimum: undefined, maximum: undefined }
}
const minimum = Math.min(...values)
const maximum = Math.max(...values)
let domainMinimum = minimum
let domainMaximum = maximum
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1)
domainMinimum -= padding
domainMaximum += padding
}
const x = (index) =>
data.length === 1
? width / 2
: inset + (index / (data.length - 1)) * (width - inset * 2)
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(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), index }
points.push(coordinate)
segment.push(coordinate)
})
if (segment.length) segments.push(segment)
return { segments, points, minimum, maximum }
}
const chart = computed(() => geometry(props.data))
const hasValues = computed(() => chart.value.points.length > 0)
const firstLabel = computed(() => props.data[0]?.label ?? '')
const middleLabel = computed(() =>
props.data.length > 2
? (props.data[Math.floor((props.data.length - 1) / 2)]?.label ?? '')
: ''
)
const lastLabel = computed(() =>
props.data.length > 1 ? (props.data.at(-1)?.label ?? '') : ''
)
const currentPoint = computed(() => chart.value.points.at(-1))
const forwardedAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
function compact(value) {
return Number(value.toFixed(2))
}
function roundedPath(segment) {
if (segment.length < 2) return ''
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1]
const point = segment[index]
const next = segment[index + 1]
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y
)
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y)
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3
)
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius
}
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius
}
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`
}
const last = segment.at(-1)
return `${path} L ${compact(last.x)} ${compact(last.y)}`
}
function exactValue(point) {
if (point?.detail) return point.detail
const value = finiteValue(point)
return value === undefined ? props.emptyLabel : props.formatValue(value)
}
function pointLabel(point) {
const value = exactValue(point)
return point?.detail || !point?.label ? value : `${point.label}: ${value}`
}
function pointStyle(point) {
return {
left: `${(point.x / width) * 100}%`,
top: `${(point.y / height) * 100}%`
}
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? 'left-1/2'
: point.x >= width * 0.8
? 'right-1/2'
: 'left-1/2 -translate-x-1/2'
const vertical =
point.y <= height * 0.32 ? 'top-full mt-1.5' : 'bottom-full mb-1.5'
return [
'pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950',
horizontal,
vertical
]
}
</script>
<template>
<figure
v-bind="forwardedAttrs"
data-slot="line-chart"
:class="
twMerge(
'grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white',
attrs.class
)
"
>
<figcaption
data-slot="line-chart-caption"
class="col-span-2 text-sm font-semibold"
>
{{ caption }}
</figcaption>
<div
v-if="hasValues"
data-slot="line-chart-scale"
:class="[
'row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400',
chart.minimum === chart.maximum
? 'place-items-center'
: 'content-between'
]"
>
<span>{{ formatValue(chart.maximum) }}</span>
<span v-if="chart.minimum !== chart.maximum">
{{ formatValue(chart.minimum) }}
</span>
</div>
<div
v-if="hasValues"
data-slot="line-chart-plot"
class="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
class="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
class="text-gray-200 dark:text-gray-800"
>
<line
v-for="guide in guides"
:key="guide"
data-slot="line-chart-guide"
:x1="inset"
:x2="width - inset"
:y1="guide"
:y2="guide"
stroke="currentColor"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</g>
<template v-for="(segment, index) in chart.segments" :key="index">
<path
v-if="segment.length > 1"
data-slot="line-chart-line"
:d="roundedPath(segment)"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</template>
<circle
v-for="point in chart.points"
:key="point.index"
data-slot="line-chart-point"
:cx="point.x"
:cy="point.y"
r="2.25"
fill="currentColor"
opacity="0.38"
vector-effect="non-scaling-stroke"
/>
<template v-if="currentPoint">
<circle
data-slot="line-chart-current-halo"
:cx="currentPoint.x"
:cy="currentPoint.y"
r="7"
fill="currentColor"
opacity="0.14"
vector-effect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
:cx="currentPoint.x"
:cy="currentPoint.y"
r="3.5"
fill="currentColor"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
data-slot="line-chart-values"
role="list"
:aria-label="`${caption} values`"
class="pointer-events-none absolute inset-0"
>
<span v-for="point in chart.points" :key="point.index" role="listitem">
<button
type="button"
data-slot="line-chart-hit"
:aria-label="`Inspect ${pointLabel(data[point.index])}`"
:style="pointStyle(point)"
class="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
class="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
/>
<span
data-slot="line-chart-tip"
aria-hidden="true"
:class="tipPosition(point)"
>
{{ pointLabel(data[point.index]) }}
</span>
</button>
</span>
<template v-for="(point, index) in data" :key="`missing-${index}`">
<span
v-if="finiteValue(point) === undefined"
role="listitem"
class="sr-only"
>
{{ pointLabel(point) }}
</span>
</template>
</div>
</div>
<p
v-else
data-slot="line-chart-empty"
class="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{{ emptyLabel }}
</p>
<div
v-if="hasValues"
data-slot="line-chart-labels"
:class="[
'col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400',
lastLabel ? 'justify-between' : 'justify-center'
]"
>
<span>{{ firstLabel }}</span>
<span v-if="middleLabel">{{ middleLabel }}</span>
<span v-if="lastLabel">{{ lastLabel }}</span>
</div>
</figure>
</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 line-chartWhen to use
Use Line Chart for one small ordered series such as signups over seven days, deployment duration over recent releases, or CPU usage over the last hour.
Use Sparkline when an adjacent visible number is primary and space is tight. Use Table when people need to compare or retrieve many exact values. A dense analytical workspace with axes, zooming, brushing, stacked series, or statistical transforms deserves a purpose-built application chart—not more props on Line Chart.
Usage
Copy the example above for your framework.
API
| Input | Default | Purpose |
|---|---|---|
data | [] | Ordered { label, value, detail? } points. Non-finite values create honest gaps. |
caption | required | Visible name for the figure. |
emptyLabel | No data | Visible and announced wording when no finite values exist. |
formatValue | String | Formats the visible scale and exact accessible values when a point does not supply detail. |
class / className | — | Ordinary Tailwind merged after the neutral responsive frame. Color follows currentColor. |
| native/global attributes | — | IDs, data hooks, event hooks, and framework-native figure references when genuinely needed. |
The stable datum contract is intentionally boring:
{
label: 'Mon',
value: 7,
detail: 'Monday, 7 signups'
}detail is optional. Use it when the exact accessible sentence needs more context than formatValue(value) can provide.
Inspecting exact values
The visible caption names a native figure. Hover, tap, or focus any finite sample to inspect its exact label and value. Edge points place their readout inward and high points place it below, so the callout stays with the chart in narrow containers.
The visual line, guides, and SVG markers remain decorative to assistive technology. The inspectable points form an exact labelled list generated from the same data, including unavailable samples. There is no second dataset to drift out of sync and hover is never the only route to the data.
The default exact value is String(value). Format units or locale-sensitive numbers at the call site:
<KleanLineChart
:data="cpu"
caption="CPU usage — last hour"
:format-value="formatPercent"
/>Use Intl.NumberFormat, Intl.DateTimeFormat, or application-owned formatting before data reaches the component when locale changes the meaning. The chart does not guess locale, timezone, or units.
Render a visible Table from the same array when people need persistent comparison or retrieval rather than one-at-a-time inspection.
Empty, missing, and live data
- Empty or entirely invalid data shows
emptyLabel. - One finite value renders a point rather than inventing a trend.
- Flat, zero, negative, and very large values remain finite and stable.
- The finite minimum and maximum stay visible, so the tighter vertical range remains honest.
- Missing values break the line instead of implying continuity.
- Replacing
dataupdates the figure; polling, realtime transport, and loading policy remain application state. - Time range, metric selection, and filters belong in the URL only when they should survive refresh, history, or sharing.
Styling with Tailwind
Height, width, color, caption treatment, labels, empty state, line treatment, and point readouts stay in caller Tailwind. Stable data-slot hooks make targeted styling explicit:
<LineChart
:data="memory"
caption="Memory — last hour"
class="h-80 text-sky-600 **:data-[slot=line-chart-caption]:text-gray-950 **:data-[slot=line-chart-line]:stroke-[3]"
/>Dashboard composition
Place the chart beside its current value and time range. Keep metric selection, polling, loading state, and filters in your application, then pass the resulting series to Line Chart.
Use Sparkline for a compact trend beside a number, or Table when people need to compare exact values.
Related components
- Sparkline — a compact trend beside an exact visible number.
- Table — visible exact values and comparison.
- Card — a semantic surface for one chart and its supporting content.
- Tooltip — supplementary explanation, never the only source of chart data.
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: () => [] },
caption: { type: String, required: true },
emptyLabel: { type: String, default: 'No data' },
formatValue: { type: Function, default: (value) => String(value) }
})
const attrs = useAttrs()
const width = 640
const height = 200
const inset = 8
const cornerRadius = 20
const guides = [inset, height / 2, height - inset]
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: [], minimum: undefined, maximum: undefined }
}
const minimum = Math.min(...values)
const maximum = Math.max(...values)
let domainMinimum = minimum
let domainMaximum = maximum
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1)
domainMinimum -= padding
domainMaximum += padding
}
const x = (index) =>
data.length === 1
? width / 2
: inset + (index / (data.length - 1)) * (width - inset * 2)
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(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), index }
points.push(coordinate)
segment.push(coordinate)
})
if (segment.length) segments.push(segment)
return { segments, points, minimum, maximum }
}
const chart = computed(() => geometry(props.data))
const hasValues = computed(() => chart.value.points.length > 0)
const firstLabel = computed(() => props.data[0]?.label ?? '')
const middleLabel = computed(() =>
props.data.length > 2
? (props.data[Math.floor((props.data.length - 1) / 2)]?.label ?? '')
: ''
)
const lastLabel = computed(() =>
props.data.length > 1 ? (props.data.at(-1)?.label ?? '') : ''
)
const currentPoint = computed(() => chart.value.points.at(-1))
const forwardedAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
function compact(value) {
return Number(value.toFixed(2))
}
function roundedPath(segment) {
if (segment.length < 2) return ''
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1]
const point = segment[index]
const next = segment[index + 1]
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y
)
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y)
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3
)
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius
}
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius
}
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`
}
const last = segment.at(-1)
return `${path} L ${compact(last.x)} ${compact(last.y)}`
}
function exactValue(point) {
if (point?.detail) return point.detail
const value = finiteValue(point)
return value === undefined ? props.emptyLabel : props.formatValue(value)
}
function pointLabel(point) {
const value = exactValue(point)
return point?.detail || !point?.label ? value : `${point.label}: ${value}`
}
function pointStyle(point) {
return {
left: `${(point.x / width) * 100}%`,
top: `${(point.y / height) * 100}%`
}
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? 'left-1/2'
: point.x >= width * 0.8
? 'right-1/2'
: 'left-1/2 -translate-x-1/2'
const vertical =
point.y <= height * 0.32 ? 'top-full mt-1.5' : 'bottom-full mb-1.5'
return [
'pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950',
horizontal,
vertical
]
}
</script>
<template>
<figure
v-bind="forwardedAttrs"
data-slot="line-chart"
:class="
twMerge(
'grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white',
attrs.class
)
"
>
<figcaption
data-slot="line-chart-caption"
class="col-span-2 text-sm font-semibold"
>
{{ caption }}
</figcaption>
<div
v-if="hasValues"
data-slot="line-chart-scale"
:class="[
'row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400',
chart.minimum === chart.maximum
? 'place-items-center'
: 'content-between'
]"
>
<span>{{ formatValue(chart.maximum) }}</span>
<span v-if="chart.minimum !== chart.maximum">
{{ formatValue(chart.minimum) }}
</span>
</div>
<div
v-if="hasValues"
data-slot="line-chart-plot"
class="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
class="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
class="text-gray-200 dark:text-gray-800"
>
<line
v-for="guide in guides"
:key="guide"
data-slot="line-chart-guide"
:x1="inset"
:x2="width - inset"
:y1="guide"
:y2="guide"
stroke="currentColor"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</g>
<template v-for="(segment, index) in chart.segments" :key="index">
<path
v-if="segment.length > 1"
data-slot="line-chart-line"
:d="roundedPath(segment)"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</template>
<circle
v-for="point in chart.points"
:key="point.index"
data-slot="line-chart-point"
:cx="point.x"
:cy="point.y"
r="2.25"
fill="currentColor"
opacity="0.38"
vector-effect="non-scaling-stroke"
/>
<template v-if="currentPoint">
<circle
data-slot="line-chart-current-halo"
:cx="currentPoint.x"
:cy="currentPoint.y"
r="7"
fill="currentColor"
opacity="0.14"
vector-effect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
:cx="currentPoint.x"
:cy="currentPoint.y"
r="3.5"
fill="currentColor"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
data-slot="line-chart-values"
role="list"
:aria-label="`${caption} values`"
class="pointer-events-none absolute inset-0"
>
<span v-for="point in chart.points" :key="point.index" role="listitem">
<button
type="button"
data-slot="line-chart-hit"
:aria-label="`Inspect ${pointLabel(data[point.index])}`"
:style="pointStyle(point)"
class="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
class="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
/>
<span
data-slot="line-chart-tip"
aria-hidden="true"
:class="tipPosition(point)"
>
{{ pointLabel(data[point.index]) }}
</span>
</button>
</span>
<template v-for="(point, index) in data" :key="`missing-${index}`">
<span
v-if="finiteValue(point) === undefined"
role="listitem"
class="sr-only"
>
{{ pointLabel(point) }}
</span>
</template>
</div>
</div>
<p
v-else
data-slot="line-chart-empty"
class="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{{ emptyLabel }}
</p>
<div
v-if="hasValues"
data-slot="line-chart-labels"
:class="[
'col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400',
lastLabel ? 'justify-between' : 'justify-center'
]"
>
<span>{{ firstLabel }}</span>
<span v-if="middleLabel">{{ middleLabel }}</span>
<span v-if="lastLabel">{{ lastLabel }}</span>
</div>
</figure>
</template>import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
const width = 640
const height = 200
const inset = 8
const cornerRadius = 20
const guides = [inset, height / 2, height - inset]
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: [], minimum: undefined, maximum: undefined }
}
const minimum = Math.min(...values)
const maximum = Math.max(...values)
let domainMinimum = minimum
let domainMaximum = maximum
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1)
domainMinimum -= padding
domainMaximum += padding
}
const x = (index) =>
data.length === 1
? width / 2
: inset + (index / (data.length - 1)) * (width - inset * 2)
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(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), index }
points.push(coordinate)
segment.push(coordinate)
})
if (segment.length) segments.push(segment)
return { segments, points, minimum, maximum }
}
function compact(value) {
return Number(value.toFixed(2))
}
function roundedPath(segment) {
if (segment.length < 2) return ''
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1]
const point = segment[index]
const next = segment[index + 1]
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y
)
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y)
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3
)
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius
}
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius
}
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`
}
const last = segment.at(-1)
return `${path} L ${compact(last.x)} ${compact(last.y)}`
}
function pointStyle(point) {
return {
left: `${(point.x / width) * 100}%`,
top: `${(point.y / height) * 100}%`
}
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? 'left-1/2'
: point.x >= width * 0.8
? 'right-1/2'
: 'left-1/2 -translate-x-1/2'
const vertical =
point.y <= height * 0.32 ? 'top-full mt-1.5' : 'bottom-full mb-1.5'
return twMerge(
'pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950',
horizontal,
vertical
)
}
const LineChart = forwardRef(function LineChart(
{
data = [],
caption,
emptyLabel = 'No data',
formatValue = String,
className,
'data-slot': _dataSlot,
...props
},
ref
) {
const chart = geometry(data)
const hasValues = chart.points.length > 0
const firstLabel = data[0]?.label ?? ''
const middleLabel =
data.length > 2
? (data[Math.floor((data.length - 1) / 2)]?.label ?? '')
: ''
const lastLabel = data.length > 1 ? (data.at(-1)?.label ?? '') : ''
const currentPoint = chart.points.at(-1)
const exactValue = (point) => {
if (point?.detail) return point.detail
const value = finiteValue(point)
return value === undefined ? emptyLabel : formatValue(value)
}
const pointLabel = (point) => {
const value = exactValue(point)
return point?.detail || !point?.label ? value : `${point.label}: ${value}`
}
return (
<figure
{...props}
ref={ref}
data-slot="line-chart"
className={twMerge(
'grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white',
className
)}
>
<figcaption
data-slot="line-chart-caption"
className="col-span-2 text-sm font-semibold"
>
{caption}
</figcaption>
{hasValues ? (
<div
data-slot="line-chart-scale"
className={twMerge(
'row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400',
chart.minimum === chart.maximum
? 'place-items-center'
: 'content-between'
)}
>
<span>{formatValue(chart.maximum)}</span>
{chart.minimum !== chart.maximum ? (
<span>{formatValue(chart.minimum)}</span>
) : null}
</div>
) : null}
{hasValues ? (
<div
data-slot="line-chart-plot"
className="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
className="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
className="text-gray-200 dark:text-gray-800"
>
{guides.map((guide) => (
<line
key={guide}
data-slot="line-chart-guide"
x1={inset}
x2={width - inset}
y1={guide}
y2={guide}
stroke="currentColor"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
))}
</g>
{chart.segments.map((segment, index) =>
segment.length > 1 ? (
<path
key={index}
data-slot="line-chart-line"
d={roundedPath(segment)}
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
) : null
)}
{chart.points.map((point) => (
<circle
key={point.index}
data-slot="line-chart-point"
cx={point.x}
cy={point.y}
r="2.25"
fill="currentColor"
opacity="0.38"
vectorEffect="non-scaling-stroke"
/>
))}
{currentPoint ? (
<>
<circle
data-slot="line-chart-current-halo"
cx={currentPoint.x}
cy={currentPoint.y}
r="7"
fill="currentColor"
opacity="0.14"
vectorEffect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
cx={currentPoint.x}
cy={currentPoint.y}
r="3.5"
fill="currentColor"
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</svg>
<div
data-slot="line-chart-values"
role="list"
aria-label={`${caption} values`}
className="pointer-events-none absolute inset-0"
>
{chart.points.map((point) => (
<span key={point.index} role="listitem">
<button
type="button"
data-slot="line-chart-hit"
aria-label={`Inspect ${pointLabel(data[point.index])}`}
style={pointStyle(point)}
className="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
/>
<span
data-slot="line-chart-tip"
aria-hidden="true"
className={tipPosition(point)}
>
{pointLabel(data[point.index])}
</span>
</button>
</span>
))}
{data.map((point, index) =>
finiteValue(point) === undefined ? (
<span
key={`missing-${index}`}
role="listitem"
className="sr-only"
>
{pointLabel(point)}
</span>
) : null
)}
</div>
</div>
) : (
<p
data-slot="line-chart-empty"
className="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{emptyLabel}
</p>
)}
{hasValues ? (
<div
data-slot="line-chart-labels"
className={twMerge(
'col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400',
lastLabel ? 'justify-between' : 'justify-center'
)}
>
<span>{firstLabel}</span>
{middleLabel ? <span>{middleLabel}</span> : null}
{lastLabel ? <span>{lastLabel}</span> : null}
</div>
) : null}
</figure>
)
})
export default LineChart<script>
import { twMerge } from "tailwind-merge";
let {
data = [],
caption,
emptyLabel = "No data",
formatValue = String,
class: className,
"data-slot": _dataSlot,
...props
} = $props();
const width = 640;
const height = 200;
const inset = 8;
const cornerRadius = 20;
const guides = [inset, height / 2, height - inset];
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: [],
minimum: undefined,
maximum: undefined,
};
}
const minimum = Math.min(...values);
const maximum = Math.max(...values);
let domainMinimum = minimum;
let domainMaximum = maximum;
if (domainMinimum === domainMaximum) {
const padding = Math.max(Math.abs(domainMinimum) * 0.1, 1);
domainMinimum -= padding;
domainMaximum += padding;
}
const x = (index) =>
points.length === 1
? width / 2
: inset + (index / (points.length - 1)) * (width - inset * 2);
const y = (value) =>
inset +
((domainMaximum - value) / (domainMaximum - domainMinimum)) *
(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), index };
chartPoints.push(coordinate);
segment.push(coordinate);
});
if (segment.length) segments.push(segment);
return { segments, points: chartPoints, minimum, maximum };
}
function compact(value) {
return Number(value.toFixed(2));
}
function roundedPath(segment) {
if (segment.length < 2) return "";
let path = `M ${compact(segment[0].x)} ${compact(segment[0].y)}`;
for (let index = 1; index < segment.length - 1; index += 1) {
const previous = segment[index - 1];
const point = segment[index];
const next = segment[index + 1];
const previousDistance = Math.hypot(
point.x - previous.x,
point.y - previous.y,
);
const nextDistance = Math.hypot(next.x - point.x, next.y - point.y);
const radius = Math.min(
cornerRadius,
previousDistance / 3,
nextDistance / 3,
);
const before = {
x: point.x + ((previous.x - point.x) / previousDistance) * radius,
y: point.y + ((previous.y - point.y) / previousDistance) * radius,
};
const after = {
x: point.x + ((next.x - point.x) / nextDistance) * radius,
y: point.y + ((next.y - point.y) / nextDistance) * radius,
};
path += ` L ${compact(before.x)} ${compact(before.y)} Q ${compact(point.x)} ${compact(point.y)} ${compact(after.x)} ${compact(after.y)}`;
}
const last = segment.at(-1);
return `${path} L ${compact(last.x)} ${compact(last.y)}`;
}
function exactValue(point) {
if (point?.detail) return point.detail;
const value = finiteValue(point);
return value === undefined ? emptyLabel : formatValue(value);
}
function pointLabel(point) {
const value = exactValue(point);
return point?.detail || !point?.label ? value : `${point.label}: ${value}`;
}
function pointStyle(point) {
return `left: ${(point.x / width) * 100}%; top: ${(point.y / height) * 100}%`;
}
function tipPosition(point) {
const horizontal =
point.x <= width * 0.2
? "left-1/2"
: point.x >= width * 0.8
? "right-1/2"
: "left-1/2 -translate-x-1/2";
const vertical =
point.y <= height * 0.32 ? "top-full mt-1.5" : "bottom-full mb-1.5";
return twMerge(
"pointer-events-none absolute z-10 w-max max-w-52 rounded-md bg-gray-950 px-2.5 py-1.5 text-xs font-medium text-white opacity-0 shadow-lg group-hover:opacity-100 group-focus:opacity-100 dark:bg-white dark:text-gray-950",
horizontal,
vertical,
);
}
let chart = $derived(geometry(data));
let hasValues = $derived(chart.points.length > 0);
let firstLabel = $derived(data[0]?.label ?? "");
let middleLabel = $derived(
data.length > 2
? (data[Math.floor((data.length - 1) / 2)]?.label ?? "")
: "",
);
let lastLabel = $derived(data.length > 1 ? (data.at(-1)?.label ?? "") : "");
let currentPoint = $derived(chart.points.at(-1));
</script>
<figure
{...props}
data-slot="line-chart"
class={twMerge(
"grid h-56 grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-2 text-gray-950 dark:text-white",
className,
)}
>
<figcaption
data-slot="line-chart-caption"
class="col-span-2 text-sm font-semibold"
>
{caption}
</figcaption>
{#if hasValues}
<div
data-slot="line-chart-scale"
class={twMerge(
"row-start-2 grid min-w-8 text-right text-[11px] leading-none text-gray-500 tabular-nums dark:text-gray-400",
chart.minimum === chart.maximum
? "place-items-center"
: "content-between",
)}
>
<span>{formatValue(chart.maximum)}</span>
{#if chart.minimum !== chart.maximum}
<span>{formatValue(chart.minimum)}</span>
{/if}
</div>
{/if}
{#if hasValues}
<div
data-slot="line-chart-plot"
class="relative col-start-2 row-start-2 min-h-0"
>
<svg
data-slot="line-chart-graphic"
aria-hidden="true"
focusable="false"
viewBox="0 0 640 200"
preserveAspectRatio="none"
fill="none"
class="h-full w-full overflow-visible"
>
<g
data-slot="line-chart-guides"
class="text-gray-200 dark:text-gray-800"
>
{#each guides as guide (guide)}
<line
data-slot="line-chart-guide"
x1={inset}
x2={width - inset}
y1={guide}
y2={guide}
stroke="currentColor"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
{/each}
</g>
{#each chart.segments as segment, index (index)}
{#if segment.length > 1}
<path
data-slot="line-chart-line"
d={roundedPath(segment)}
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
{/if}
{/each}
{#each chart.points as point (point.index)}
<circle
data-slot="line-chart-point"
cx={point.x}
cy={point.y}
r="2.25"
fill="currentColor"
opacity="0.38"
vector-effect="non-scaling-stroke"
/>
{/each}
{#if currentPoint}
<circle
data-slot="line-chart-current-halo"
cx={currentPoint.x}
cy={currentPoint.y}
r="7"
fill="currentColor"
opacity="0.14"
vector-effect="non-scaling-stroke"
/>
<circle
data-slot="line-chart-current"
cx={currentPoint.x}
cy={currentPoint.y}
r="3.5"
fill="currentColor"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
<div
data-slot="line-chart-values"
role="list"
aria-label={`${caption} values`}
class="pointer-events-none absolute inset-0"
>
{#each chart.points as point (point.index)}
<span role="listitem">
<button
type="button"
data-slot="line-chart-hit"
aria-label={`Inspect ${pointLabel(data[point.index])}`}
style={pointStyle(point)}
class="group pointer-events-auto absolute size-7 -translate-x-1/2 -translate-y-1/2 cursor-crosshair rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
>
<span
data-slot="line-chart-hover-point"
aria-hidden="true"
class="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current opacity-0 ring-2 ring-white group-hover:opacity-100 group-focus:opacity-100 dark:ring-gray-950"
></span>
<span
data-slot="line-chart-tip"
aria-hidden="true"
class={tipPosition(point)}
>
{pointLabel(data[point.index])}
</span>
</button>
</span>
{/each}
{#each data as point, index (index)}
{#if finiteValue(point) === undefined}
<span role="listitem" class="sr-only">{pointLabel(point)}</span>
{/if}
{/each}
</div>
</div>
{:else}
<p
data-slot="line-chart-empty"
class="col-span-2 grid min-h-32 place-items-center text-sm text-gray-500 dark:text-gray-400"
>
{emptyLabel}
</p>
{/if}
{#if hasValues}
<div
data-slot="line-chart-labels"
class={twMerge(
"col-start-2 row-start-3 flex text-xs text-gray-500 tabular-nums dark:text-gray-400",
lastLabel ? "justify-between" : "justify-center",
)}
>
<span>{firstLabel}</span>
{#if middleLabel}<span>{middleLabel}</span>{/if}
{#if lastLabel}<span>{lastLabel}</span>{/if}
</div>
{/if}
</figure>