# Line chart

Crisp lines with dithered range bands and point markers, for latency, conversion and period-over-period comparisons.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/line-chart.json](https://dithercn.dev/r/line-chart.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/line-chart.json
```

## Usage [#usage]

```tsx
import { LineChart } from "@/components/dithercn/line-chart";

const data = [
  { time: "12:00", p10: 160, p50: 188, p90: 238 },
  { time: "12:30", p10: 164, p50: 184, p90: 230 },
  { time: "13:00", p10: 167, p50: 191, p90: 245 },
];

export function Latency() {
  return (
    <LineChart
      data={data}
      x="time"
      series={[
        { label: "p50", value: "p50", range: { low: "p10", high: "p90" } },
      ]}
    />
  );
}
```

Each series is a 2px line. Give it a `range` and the space between `low` and `high` fills with a light dither, which suits percentiles, forecasts and min/max envelopes. The readout lists the value with its range.

## Examples [#examples]

### This week against last week [#this-week-against-last-week]

A `dashed` series reads as a reference: a previous period or a target. The legend repeats the dash, so the two lines never rely on colour alone. Here this week's `range` runs from last week's value to its own, so the dither fills exactly the gap between the two.

```tsx title="examples/line-chart-compare.tsx"
import { LineChart } from "@/components/dithercn/line-chart";

const data = [
  { day: "Mon", lastWeek: 1240, thisWeek: 1310 },
  { day: "Tue", lastWeek: 1380, thisWeek: 1520 },
  { day: "Wed", lastWeek: 1410, thisWeek: 1490 },
  { day: "Thu", lastWeek: 1350, thisWeek: 1640 },
  { day: "Fri", lastWeek: 1520, thisWeek: 1810 },
  { day: "Sat", lastWeek: 980, thisWeek: 1160 },
  { day: "Sun", lastWeek: 910, thisWeek: 1090 },
];

export default function LineChartCompare() {
  return (
    <div className="bg-background border-border w-full max-w-xl rounded-2xl border p-5 [--dither-paper:var(--background)] sm:p-6">
      <div className="mb-5 flex flex-wrap items-end justify-between gap-x-4 gap-y-1">
        <div className="flex flex-col gap-1">
          <span className="text-muted-foreground text-sm">Checkouts</span>
          <span className="text-3xl font-semibold tracking-tight">10,020</span>
        </div>
        <span className="text-muted-foreground text-sm">
          <span className="text-foreground font-medium">+13.4%</span> on last
          week
        </span>
      </div>
      <LineChart
        data={data}
        x="day"
        height={220}
        series={[
          {
            label: "This week",
            range: { high: "thisWeek", low: "lastWeek" },
            value: "thisWeek",
          },
          { dashed: true, label: "Last week", value: "lastWeek" },
        ]}
      />
    </div>
  );
}
```

### Few points, every one marked [#few-points-every-one-marked]

With sparse data, `markers="all"` puts a dot on each point and `curve="linear"` keeps straight segments between them, so nobody reads a curve the data does not have. The band is a sector benchmark in its own `color`, and `zero` starts the axis at 0 because a score's distance from zero matters here.

```tsx title="examples/line-chart-points.tsx"
import { LineChart } from "@/components/dithercn/line-chart";

// The benchmark band is the middle half of companies in the same sector.
const data = [
  { high: 41, low: 28, month: "Apr", score: 31 },
  { high: 41, low: 28, month: "May", score: 34 },
  { high: 42, low: 29, month: "Jun", score: 33 },
  { high: 42, low: 29, month: "Jul", score: 39 },
  { high: 43, low: 30, month: "Aug", score: 42 },
  { high: 43, low: 30, month: "Sep", score: 47 },
];

export default function LineChartPoints() {
  return (
    <div className="bg-background border-border w-full max-w-sm rounded-2xl border p-5 [--dither-paper:var(--background)]">
      <div className="mb-4 flex flex-col gap-1">
        <span className="text-muted-foreground text-sm">
          Net promoter score
        </span>
        <span className="text-3xl font-semibold tracking-tight">47</span>
        <span className="text-muted-foreground text-xs">
          <span className="text-foreground font-medium">+16</span> since April,
          now above the sector benchmark
        </span>
      </div>
      <LineChart
        data={data}
        x="month"
        markers="all"
        curve="linear"
        height={180}
        zero
        series={[
          {
            label: "NPS",
            range: {
              color: "--muted-foreground",
              high: "high",
              low: "low",
            },
            value: "score",
          },
        ]}
      />
    </div>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` (required) | `readonly T[]` |  | One entry per point along the x axis, in order. |
| `x` (required) | `keyof T \| ((datum: T) => string \| number)` |  | The field (or function) holding each point's x axis label. |
| `series` (required) | `[LineSeries<T>, ...LineSeries<T>[]]` |  | Each series is { label, value, color?, range?: { low, high, color? }, dashed? }. value, low and high are numeric fields of T or functions. |
| `markers` | `"all" \| "last" \| "none"` | `"last"` | Which points get a dot. |
| `zero` | `boolean` | `false` | Start the value axis at zero. By default it fits the data, so small changes stay visible. |
| `height` | `number` | `240` | Height in px, axes included. |
| `curve` | `"smooth" \| "linear"` | `"smooth"` | smooth is a monotone curve that never overshoots a data point. |
| `fill` | `"gradient" \| "dotted" \| "hatched" \| "solid"` | `"gradient"` | Texture of the range bands. gradient is one even dither level, set by level; solid draws a translucent band. |
| `bloom` | `"off" \| "low" \| "high"` | `"off"` | A soft glow in each series' colour behind its fill. Its strength follows --dither-bloom, lower in light mode. |
| `colors` | `readonly DitherColor[]` | `ink, orange, green, pink, …` | Colours by series position: palette names such as "green" or any CSS colour. A series' own color wins. |
| `interactive` | `boolean` | `true` | Scrubbing, the readout and the legend spotlight. false makes a decorative chart: no focus stop, no readout, just an image with a summary. |
| `animate` | `boolean` | `true` | Entrance motion. Always off under reduced motion. |
| `level` | `number` | `0.25` | Gradient only: coverage of the range bands, from 0 to 1. |
| `matrix` | `"bayer2" \| "bayer4" \| "bayer8" \| "blue-noise"` | `"bayer4"` | Gradient only: the dither texture of the bands. |
| `cell` | `number` | `--dither-cell` | Size of one dither cell in px, rounded to whole device pixels. |
| `axes` | `boolean` | `true` | Show the value and category axes. |
| `tooltip` | `boolean` | `true` | Show the readout while scrubbing. |
| `legend` | `boolean` | `Two or more series` | Show the legend under the chart. Its entries spotlight a series on hover and lock it on click. |
| `format` | `Intl.NumberFormatOptions \| ((value: number) => string)` | `Compact, as in 1.2K` | How values are written on the axis, in the readout and in the table. |

Series colours follow the palette: ink, then orange, green, pink, purple, blue, red and grey. Pass `colors`, or `color` on one series, with a palette name or any CSS colour.

## Notes [#notes]

* The chart is a focusable group with a short summary, and every value, including ranges, is in a visually hidden table. The arrow keys, Home and End move the readout without easing, a polite live region reads out each stop, and Escape clears the readout and any locked series. With `bloom`, each line gets a soft glow in its colour.
* On entrance solid lines draw from left to right, dashed lines fade in, and bands step up through the dither levels. With reduced motion the finished chart appears at once.
* Markers carry a 2px ring in `--dither-paper` so they stay legible where lines cross. Set `--dither-paper` to your card's background if it differs from the page.