Skip to content

Line Chart

Line Chart gives one ordered trend a visible caption, a readable scale, calm responsive geometry, and points that disclose the same exact values on hover, touch, or keyboard focus. It is deliberately small enough for real application dashboards without bringing a charting system into the product.

LineChart.vue

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.

Terminal
npx klean-ui add line-chart

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

When 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

Vue

SignupChart.vue
<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>

React

SignupChart.jsx
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"
    />
  )
}

Svelte

SignupChart.svelte
<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"
/>

API

InputDefaultPurpose
data[]Ordered { label, value, detail? } points. Non-finite values create honest gaps.
captionrequiredVisible name for the figure.
emptyLabelNo dataVisible and announced wording when no finite values exist.
formatValueStringFormats the visible scale and exact accessible values when a point does not supply detail.
class / classNameOrdinary Tailwind merged after the neutral responsive frame. Color follows currentColor.
native/global attributesIDs, data hooks, event hooks, and framework-native figure references when genuinely needed.

The stable datum contract is intentionally boring:

js
{
  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:

vue
<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 data updates 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:

vue
<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]"
/>

There is no variant, palette, theme object, legend system, animation setting, or global chart provider. Repeated application treatment belongs in a small local wrapper around the copied component.

Slipway and Hagfish

Slipway can replace its Lookout line geometry with Line Chart while retaining its exact current readings, dark operational styling, polling, and metric controls in application markup. Its compact CPU and memory rows use Sparkline.

Hagfish can use the same primitive for small invoice or payment trends without inheriting Slipway colors or dashboard assumptions. Tailwind preserves each product's visual language; the data and accessibility contract stays the same.

  • 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

Vue

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>

React

LineChart.jsx
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

Svelte

LineChart.svelte
<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>

All open source projects are released under the MIT License.