LineChart

Show change across an ordered sequence with multiple series, optional areas and precise presentation controls.

LineChart

View source · API reference

Playground

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

Current Previous
Adoption by release 100% 75% 50% 25% 0% Current Previous Current, R1: 18% Current, R2: 26% Current, R3: 37% Current, R4: 48% Current, R5: 64% Previous, R1: 10% Previous, R2: 18% Previous, R3: 25% Previous, R4: 34% Previous, R5: 43% R1R2R3R4R5
<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.

Change from the release baseline 40% 20% 0% -20% -40% Change Change, R1: -32% Change, R2: -12% Change, R3: 8% Change, R4: 24% Change, R5: 36% -32%-12%8%24%36% R1R2R3R4R5
<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.

Latency trend Latency
<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

First measurement 24% 18% 12% 6% 0% Current Current, R1: 24% 24% R1

Awaiting measurements

No measurements yet Waiting for the first run
<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.