LineChart
Show change across an ordered sequence with multiple series, optional areas and precise presentation controls.
LineChartPlayground
Edit either series, rename it, or hide it to inspect the other line. Try crossing trends, gains and losses, a flat baseline or a single observation. Every chart control below is reactive, and Reset restores the full example. All data is illustrative.
<RegorApp id="line-chart-demo" src="./playground.ts"/> import {
defineBadgeComponents,
defineButtonComponents,
defineFlexComponents,
defineFormComponents,
defineFormInputField,
defineFormSelectField,
defineGridComponents,
defineIconComponents,
defineLineChartComponents,
definePanelComponents,
type FormSelectOption,
type LineChartCurve,
type LineChartSeries,
} from '@purestack/ts-components'
import { getThemePaletteVar, type SemanticTone } from '@purestack/ts-style'
import { lucide_chevron_down } from '@purestack/ts-svg-icons'
import {
batch,
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
type SRef,
sref,
} from 'regor'
export type LineChartPreset =
| 'growth'
| 'crossing'
| 'signed'
| 'flat'
| 'single'
export interface LineChartEditorPoint {
id: string
label: string
value: Ref<number | string>
}
export interface LineChartEditorSeries {
id: string
label: Ref<string>
enabled: Ref<boolean>
tone: SemanticTone
color: string
points: LineChartEditorPoint[]
}
export interface LineChartPlayground {
editorSeries: SRef<LineChartEditorSeries[]>
chartSeries: ComputedRef<LineChartSeries[]>
summary: ComputedRef<string>
replayDisabled: ComputedRef<boolean>
preset: Ref<LineChartPreset>
curve: Ref<LineChartCurve>
suffix: Ref<string>
minimum: Ref<number | string>
maximum: Ref<number | string>
height: Ref<number | string>
areaVisible: Ref<boolean>
pointsVisible: Ref<boolean>
valuesVisible: Ref<boolean>
labelsVisible: Ref<boolean>
axisVisible: Ref<boolean>
motionEnabled: Ref<boolean>
presets: FormSelectOption[]
curves: FormSelectOption[]
units: FormSelectOption[]
applyPreset: (event: Event) => void
replay: () => void
clear: () => void
reset: () => void
}
const lineChartPlaygroundTemplate = html`<Flex direction="column">
<Grid columns="1" columnsMd="3">
<FormSelectField
id="line-preset"
label="Dataset"
:model="preset"
:options="presets"
@change="applyPreset"
/>
<FormSelectField id="line-curve" label="Curve" :model="curve" :options="curves" />
<FormSelectField id="line-unit" label="Value suffix" :model="suffix" :options="units" />
</Grid>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<Flex justify="between" align="center" wrap="true">
<p class="text-eyebrow m-0">LIVE PREVIEW · SERIES COMPARISON</p>
<Btn variant="outline" size="sm" :disabled="replayDisabled" @click="replay">Replay animation</Btn>
</Flex>
<Flex wrap="true" class="mt-3">
<template r-for="entry in editorSeries">
<Badge r-if="entry.enabled" :tone="entry.tone" variant="surface">{{ entry.label }}</Badge>
</template>
</Flex>
<div class="overflow-x-auto" tabindex="0" role="region" aria-label="Scrollable line chart preview">
<LineChart
:series="chartSeries"
title="Illustrative series comparison"
description="Equally spaced observations. Exact values can be edited in each series below."
:ariaLabel="summary"
emptyLabel="No visible series"
:curve="curve"
:valueSuffix="suffix"
:minValue="minimum"
:maxValue="maximum"
:height="height"
:showArea="areaVisible"
:showPoints="pointsVisible"
:showValues="valuesVisible"
:showLabels="labelsVisible"
:showAxis="axisVisible"
:animated="motionEnabled"
style="min-width: 440px"
/>
</div>
<FormStatus>{{ summary }}</FormStatus>
</Panel>
<Flex wrap="true">
<FormCheck id="line-area" label="Area fill" :checked="areaVisible" />
<FormCheck id="line-points" label="Point markers" :checked="pointsVisible" />
<FormCheck id="line-values" label="Values" :checked="valuesVisible" />
<FormCheck id="line-labels" label="Category labels" :checked="labelsVisible" />
<FormCheck id="line-axis" label="Grid and zero line" :checked="axisVisible" />
<FormCheck id="line-motion" label="Entrance animation" :checked="motionEnabled" />
</Flex>
<Grid columns="1" columnsMd="3">
<FormInputField id="line-min" label="Minimum bound" type="number" placeholder="Automatic" :model="minimum" />
<FormInputField id="line-max" label="Maximum bound" type="number" placeholder="Automatic" :model="maximum" />
<FormInputField id="line-height" label="Chart height (px)" type="number" min="180" step="20" :model="height" />
</Grid>
<p class="m-0 text-muted">Blank bounds use the data range. Bounds always expand to include zero and all visible points. Scroll the preview horizontally on narrow screens.</p>
<Flex justify="between" align="center" wrap="true">
<h3 class="m-0">Edit the series</h3>
<Flex wrap="true">
<Btn variant="outline" @click="clear">Empty data</Btn>
<Btn tone="accent" variant="surface" @click="reset">Reset playground</Btn>
</Flex>
</Flex>
<Panel r-for="entry in editorSeries" tone="neutral" variant="surface" bodyClass="p-3 min-w-0" :aria-label="entry.label">
<Flex direction="column">
<Flex justify="between" align="center" wrap="true">
<FormInputField :id="'line-name-' + entry.id" label="Series name" :model="entry.label" />
<FormCheck :id="'line-enabled-' + entry.id" label="Show series" :checked="entry.enabled" />
</Flex>
<Grid columns="2" columnsSm="3" columnsLg="5">
<FormInputField
r-for="point in entry.points"
:id="point.id"
:label="point.label"
type="number"
step="1"
:model="point.value"
/>
</Grid>
</Flex>
</Panel>
</Flex>`
const presetValues: Record<LineChartPreset, [number[], number[]]> = {
growth: [
[18, 26, 37, 48, 64],
[10, 18, 25, 34, 43],
],
crossing: [
[20, 38, 28, 58, 48],
[48, 26, 50, 32, 64],
],
signed: [
[-18, -8, 12, 28, 40],
[-32, -18, -4, 12, 22],
],
flat: [
[24, 24, 24, 24, 24],
[12, 12, 12, 12, 12],
],
single: [[24], [12]],
}
function createLineChartSeries(
preset: LineChartPreset,
): LineChartEditorSeries[] {
const colors = [
getThemePaletteVar('semanticTone.accent.button.hover.bgcolor'),
getThemePaletteVar('semanticTone.feature.button.hover.bgcolor'),
]
return presetValues[preset].map((values, index) => ({
id: String(index),
label: ref(index === 0 ? 'Current' : 'Baseline'),
enabled: ref(true),
tone: index === 0 ? 'accent' : 'feature',
color: colors[index],
points: values.map((value, pointIndex) => ({
id: `line-value-${index}-${pointIndex}`,
label: `R${pointIndex + 1}`,
value: ref<number | string>(value),
})),
}))
}
function createLineChartPlayground(): LineChartPlayground {
const editorSeries = sref(createLineChartSeries('growth'))
const preset = ref<LineChartPreset>('growth')
const curve = ref<LineChartCurve>('smooth')
const suffix = ref('%')
const minimum = ref<number | string>('')
const maximum = ref<number | string>('')
const height = ref<number | string>(300)
const areaVisible = ref(false)
const pointsVisible = ref(true)
const valuesVisible = ref(false)
const labelsVisible = ref(true)
const axisVisible = ref(true)
const motionEnabled = ref(true)
const chartSeries = computed<LineChartSeries[]>(() =>
editorSeries()
.filter((entry) => entry.enabled())
.map((entry, index) => ({
label: entry.label().trim() || `Series ${index + 1}`,
color: entry.color,
points: entry.points.map((point) => ({
label: point.label,
value: Number(point.value()),
})),
})),
)
return {
editorSeries,
chartSeries,
summary: computed(() => {
const visible = editorSeries().filter((entry) => entry.enabled())
if (!visible.length)
return 'No visible series. Enable a series, choose a dataset, or reset.'
return visible
.map((entry, index) => {
const latest = entry.points.at(-1)
return `${entry.label().trim() || `Series ${index + 1}`}: ${latest?.label} = ${Number(latest?.value())}${suffix()}`
})
.join(' · ')
}),
replayDisabled: computed(() => !motionEnabled() || !chartSeries().length),
preset,
curve,
suffix,
minimum,
maximum,
height,
areaVisible,
pointsVisible,
valuesVisible,
labelsVisible,
axisVisible,
motionEnabled,
presets: [
{ label: 'Steady growth', value: 'growth' },
{ label: 'Crossing trends', value: 'crossing' },
{ label: 'Gains and losses', value: 'signed' },
{ label: 'Flat series', value: 'flat' },
{ label: 'Single observation', value: 'single' },
],
curves: [
{ label: 'Smooth', value: 'smooth' },
{ label: 'Linear', value: 'linear' },
],
units: [
{ label: 'Percent (%)', value: '%' },
{ label: 'Milliseconds (ms)', value: 'ms' },
{ label: 'No suffix', value: '' },
],
applyPreset: (event) => {
const next = (event.target as HTMLSelectElement).value as LineChartPreset
editorSeries(createLineChartSeries(next))
},
replay: () => {
for (const animation of document.querySelectorAll<SVGAnimationElement>(
'#line-chart-demo .line-chart animate',
)) {
animation.beginElement()
}
},
clear: () => editorSeries([]),
reset: () =>
batch(() => {
preset('growth')
curve('smooth')
suffix('%')
minimum('')
maximum('')
height(300)
areaVisible(false)
pointsVisible(true)
valuesVisible(false)
labelsVisible(true)
axisVisible(true)
motionEnabled(true)
editorSeries(createLineChartSeries('growth'))
}),
}
}
const lineChartPlayground = defineComponent<LineChartPlayground>(
lineChartPlaygroundTemplate,
{
context: createLineChartPlayground,
},
)
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
}
createApp(
{
components: {
LineChartPlayground: lineChartPlayground,
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...defineFormInputField(),
...defineFormSelectField(),
...defineGridComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
...defineLineChartComponents(),
...definePanelComponents(),
},
},
{ selector: 'app#line-chart-demo', template: html`<LineChartPlayground />` },
) Entrance animation starts enabled. Use Replay animation to run the SVG animations again without changing your data. Turn it off for immediate comparisons while editing. With overlapping series, hide value labels and use the named input fields to read exact values.
Compare aligned series
Give each series the same ordered categories and a visible legend. A fixed zero-to-100 scale keeps this comparison consistent. These are illustrative percentages.
Adoption across five releases
<Panel variant="surface" bodyClass="p-3">
<h3 class="mt-0">Adoption across five releases</h3>
<Flex wrap="true">
<Badge tone="accent">Current</Badge>
<Badge tone="feature">Previous</Badge>
</Flex>
<LineChart
title="Adoption by release"
ariaLabel="R1 to R5: Current 18, 26, 37, 48, 64 percent; Previous 10, 18, 25, 34, 43 percent"
:series="[
{label:'Current',points:[{label:'R1',value:18},{label:'R2',value:26},{label:'R3',value:37},{label:'R4',value:48},{label:'R5',value:64}]},
{label:'Previous',points:[{label:'R1',value:10},{label:'R2',value:18},{label:'R3',value:25},{label:'R4',value:34},{label:'R5',value:43}]}
]"
minValue="0"
maxValue="100"
valueSuffix="%"
height="280"
curve="linear"
/>
</Panel> Fill an area across zero
Area fill closes each path against zero. Positive and negative regions keep their direction, while value labels show the actual observations. Multiple series would overlap; area fill is not stacking.
<LineChart
title="Change from the release baseline"
ariaLabel="R1 minus 32 percent, R2 minus 12, R3 plus 8, R4 plus 24, R5 plus 36"
:series="[{label:'Change',points:[{label:'R1',value:-32},{label:'R2',value:-12},{label:'R3',value:8},{label:'R4',value:24},{label:'R5',value:36}]}]"
minValue="-40"
maxValue="40"
valueSuffix="%"
height="280"
:showArea="true"
:showValues="true"
/> A compact trend card
Use a nearby headline to explain a small trend. Remove the axis, category labels and markers to emphasize its shape; retain the exact observations in the accessible name.
ILLUSTRATIVE LATENCY
18 ms latest measurement
Six observations across a tuning session.
<Panel tone="accent" variant="surface" bodyClass="p-3">
<p class="text-eyebrow mt-0">ILLUSTRATIVE LATENCY</p>
<h3>18 ms latest measurement</h3>
<p>Six observations across a tuning session.</p>
<LineChart
title="Latency trend"
ariaLabel="Latency measurements in milliseconds: 42, 36, 44, 29, 24, 18"
:series="[{label:'Latency',points:[{value:42},{value:36},{value:44},{value:29},{value:24},{value:18}]}]"
height="160"
curve="linear"
:showArea="true"
:showAxis="false"
:showLabels="false"
:showPoints="false"
/>
</Panel> Single-point and empty states
One observation needs a marker because there is no line segment yet. An empty series list displays your emptyLabel and removes the chart marks. Neither case needs placeholder data.
First measurement
Awaiting measurements
<Grid columns="1" columnsMd="2">
<Panel variant="surface" bodyClass="p-3">
<h3 class="mt-0">First measurement</h3>
<LineChart
title="First measurement"
ariaLabel="R1: 24 percent"
:series="[{label:'Current',points:[{label:'R1',value:24}]}]"
valueSuffix="%"
height="220"
:showPoints="true"
:showValues="true"
/>
</Panel>
<Panel variant="surface" bodyClass="p-3">
<h3 class="mt-0">Awaiting measurements</h3>
<LineChart
title="No measurements yet"
emptyLabel="Waiting for the first run"
:series="[]"
height="220"
/>
</Panel>
</Grid> Behavior and accessibility
- Category positions: points are equally spaced by index. Date-like labels are text; this is not a continuous time axis. Compared series need the same point count and category order.
- Scale: automatic bounds include zero and all valid values. Explicit bounds can widen the range, but never crop measurements. Hiding a series can change an automatic scale.
- Readable values: use markers, a visible legend and nearby data when exact comparisons matter. Narrow previews can scroll horizontally. Value labels from coincident series are not automatically separated.
- Accessible context: provide a meaningful title and description or ariaLabel. Each point has an SVG title, while the playground also exposes the measurements through labelled native fields. Color is supported by named series and editable values.
- Animation: paths fade in and markers grow on entrance. Area fills remain static. Replay uses each SVG animation's native beginElement method.
API reference
LineChart contract
Props below are the public template API. RefOrValue accepts a literal or a reactive ref; bind refs with a colon-prefixed attribute. Native attributes and events can be passed through the component root.
Data and accessible descriptions
series
RefOrValue<Array<RefOrValue<LineChartSeries>>> - Default
[]
An array of named series with optional CSS colors and ordered points. Keep category order and point counts aligned across compared series. The data contracts below show the supported ref shapes.
title
RefOrValue<string> - Default
not set
SVG title text. Add a visible heading outside the SVG when the chart needs one.
description
RefOrValue<string> - Default
not set
SVG description with context such as the metric, period and meaning of each series.
ariaLabel
RefOrValue<string> - Default
title, then Line chart
Accessible name on the SVG image.
emptyLabel
RefOrValue<string> - Default
No data
Visible fallback when no usable data remains.
valueSuffix
RefOrValue<string> - Default
not set
Unit appended to formatted values, such as ms or %.
Size and scale
width
RefOrValue<number | string> - Default
100%
CSS width. Numeric values become pixel lengths; strings can use CSS units.
height
RefOrValue<number | string> - Default
automatic
CSS height; omitted height preserves the SVG aspect ratio.
minValue
RefOrValue<number | string> - Default
automatic
Requested lower bound. The resolved range also includes zero and the smallest visible value. Leave it unset for automatic scaling; it never clips points.
maxValue
RefOrValue<number | string> - Default
automatic
Requested upper bound. The resolved range expands to include zero and the largest visible value. Use the same bounds on charts that should be compared.
Curves and visibility
curve
RefOrValue<LineChartCurve> - Default
smooth
smooth connects points with cubic curves; linear uses straight segments. Neither mode changes the measurements or adds intermediate observations.
animated
RefOrValue<boolean | string> - Default
true
Fades in paths and grows point markers when they mount. This does not interpolate between datasets or animate area fills. Disable it for rapid editing; the playground also shows how to replay the native SVG animations.
showArea
RefOrValue<boolean | string> - Default
false
Fills each series between its path and zero, including negative regions. Multiple areas overlap with transparency; they are not stacked.
showPoints
RefOrValue<boolean | string> - Default
true
Draws a marker with a native SVG title at each observation. Keep this enabled for a single-point series, whose path alone has no visible segment.
showValues
RefOrValue<boolean | string> - Default
false
Places values above positive points and below negative points. Labels from close or coincident series can overlap. Isolate a series or use the editable data fields for exact comparisons in a dense chart.
showLabels
RefOrValue<boolean | string> - Default
true
Shows labels from the first visible series, thinning them for long sequences and retaining the final label. Points are spaced by index, not by time or a numeric x coordinate.
showAxis
RefOrValue<boolean | string> - Default
true
Shows the axis grid and value labels.
Appearance
tone
RefOrValue<SemanticTone> - Default
inherited
Semantic intent: neutral, accent, secondary, info, success, warning, danger, feature, custom or ghost. The active skin supplies the colors.
variant
RefOrValue<ComponentVariant> - Default
none
Visual treatment. Accepts solid, surface, surfaceAlt, spotlight, glass, flat, flatAlt, flatSolid, outlineFill, outline, subtle, subtleBtn, link, sheen, underline, rail, bracket or none.
variantMode
RefOrValue<ComponentVariantMode> - Default
stateless
Use stateless for content surfaces; stateful enables the treatment’s hover, focus and active styles. It does not add interaction handlers.
Point and series contracts
import type { RefOrValue } from 'regor'
interface LineChartPoint {
label?: RefOrValue<string>
value?: RefOrValue<number | string>
}
interface LineChartSeries {
label?: RefOrValue<string>
color?: RefOrValue<string>
points?: RefOrValue<Array<RefOrValue<LineChartPoint>>>
} Missing names become Series 1 and Point 1. Non-finite values are removed; series with no valid points are removed too. Filtering a point closes the sequence, so missing observations do not create visible gaps. Normalize your data before comparing series.
Explicit colors stay attached to a series when others are hidden. Without a color, the palette is assigned by the visible series order. The playground uses theme variables so its legend and series colors stay consistent in both skins.
Composition
No slots. Register defineLineChartComponents. Series labels identify paths; provide an external legend or value list where readers need precise comparisons.