Skip to content

Line Chart ​

Plot an ordered trend with exact values available on hover, touch, and keyboard focus.

Preview Live · Vue
Usage
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>
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
<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
LineChart.vue
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.

Terminal
npx klean-ui add line-chart

Source you own. Edit the installed files to make them yours.

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 ​

Copy the example above for your framework.

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 / 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:

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

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.

  • 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
<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>
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
<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.