# Sparkline (Moonarc)

A small line chart that draws itself from left to right when it scrolls into view; the last point lands and pulses. The SVG is rendered on the server from the data, the line is uncovered by a clip that sweeps from its left edge to its right, and the accessible name summarises the series. Composes Reveal; no script of its own.

- Import: `import Sparkline from '@moonarc/core/Sparkline'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/sparkline.json`
- Tier A · category data · trigger scroll
- Readout: `<Sparkline data={[…]}>`
- Browser support: widely (Chrome 55 · Firefox 54 · Safari 13.1)
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 8.6 kB raw)
- Page: https://moonarc.dev/components/sparkline/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `number[]` | none | The series, in order. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `width` | `number` | `120` | Width in px, also the viewBox; the drawing scales with it and shrinks to fit a narrower container. |
| `height` | `number` | `32` | See width. |
| `color` | `string` | `'var(--chart-1, currentColor)'` | Line colour; the area and the dot follow it. The first chart colour of the theme contract when a theme defines one, else the text colour. |
| `fill` | `boolean` | `false` | Tint the area under the line once it is drawn. |
| `dot` | `boolean` | `true` | Mark the last point and pulse it. |
| `duration` | `number` | `preset (700)` | Draw duration in ms. Unset, the preset's --ma-duration-slow. |
| `label` | `string` | none | Accessible name; "n points, min, max, last" is appended. |
| `delay` | `number` | `0` | Delay before drawing. |
| `once` | `boolean` | `true` | Play once and stay. |
| `threshold` | `number` | `0.2` | Fraction visible to trigger. |

## Usage

```astro
<Sparkline data={[12, 18, 15, 22, 30, 28, 41]} label="Stars per week" class="text-signal" />
<Sparkline data={downloads} width={240} height={48} fill dot={false} duration={1200} />
```

## Reduced motion

The line is drawn at once and the last point does not pulse.

## With ClientRouter

Inherits Reveal: rebinds after every navigation.

## Craft

- The draw is a clip-path inset whose right edge moves from the left of the chart to past its right, so it is one transition whatever the data and whatever size the chart is drawn at. A pathLength="1" dash does the same only at the viewBox's own size: with a non-scaling stroke the dash is measured on screen, and a chart drawn four times its viewBox showed a quarter dash, a gap, a dash.
- Drawn on ease-out at the slow duration: a chart line should arrive like a pen stroke, fast at first and settling at the last point.
- The dot lands when the line finishes (its transition is delayed by the draw duration) and then pulses on the ambient duration, marking the current value.
- vector-effect: non-scaling-stroke keeps the line 2 px at any size, so the same component works at 80 px in a table cell and 400 px in a card.
- role="img" and a label that says min, max and last: the summary a sighted reader gets from the shape.

## Replaces

- Sparkline (Preline charts)
- react-sparklines
- ApexCharts sparklines

## Source

```astro
---
/**
 * Sparkline — a small line chart that draws itself left to right when it
 * scrolls into view, the last point pulsing once drawn. The SVG is rendered
 * on the server from the data; the line is uncovered by a clip-path inset
 * whose right edge travels from the left of the chart to past its right, so
 * it draws at any size. Composes Reveal for the trigger; zero script of its
 * own. The label summarises the series.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';

interface Props extends HTMLAttributes<'span'> {
  /** The series. */
  data: number[];
  /** Element to render. */
  as?: HTMLTag;
  /** Size in px — also the viewBox, so the drawing scales with it; it shrinks to fit a narrower container. */
  width?: number;
  height?: number;
  /** Line colour. */
  color?: string;
  /** Fill the area under the line. */
  fill?: boolean;
  /** Mark the last point. */
  dot?: boolean;
  /** Draw duration in ms. Unset: the preset's --ma-duration-slow. */
  duration?: number;
  /** Accessible name; the summary (min, max, last) is appended. */
  label?: string;
  /** Delay in ms before drawing. */
  delay?: number;
  /** Play once and stay. */
  once?: boolean;
  /** Fraction visible to trigger. */
  threshold?: number;
}

const { data, as = 'span', width = 120, height = 32, color = 'var(--chart-1, currentColor)', fill = false, dot = true, duration, label, delay, once, threshold, class: className, style, ...rest } = Astro.props;
const series = data.length ? data : [0];
const n = series.length;
const min = Math.min(...series);
const max = Math.max(...series);
const span = max - min || 1;
const pad = 3;
const x = (i: number) => pad + (n > 1 ? (i / (n - 1)) * (width - 2 * pad) : (width - 2 * pad) / 2);
const y = (v: number) => height - pad - ((v - min) / span) * (height - 2 * pad);
const d = series.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const area = `${d} L${x(n - 1).toFixed(1)},${height} L${x(0).toFixed(1)},${height} Z`;
const last = series[n - 1]!;
const name = `${label ? `${label}: ` : ''}${n} points, min ${min}, max ${max}, last ${last}`;
const vars = [`--ma-spark-color:${color}`, duration !== undefined && `--ma-spark-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-spark', className].filter(Boolean).join(' ')} role="img" aria-label={name} style={vars} {...rest}>
  <svg class="ma-spark__svg" viewBox={`0 0 ${width} ${height}`} width={width} height={height} aria-hidden="true">
    {fill && <path class="ma-spark__area" d={area} />}
    <path class="ma-spark__line" d={d} vector-effect="non-scaling-stroke" />
    {dot && (
      <>
        <circle class="ma-spark__ring" cx={x(n - 1).toFixed(1)} cy={y(last).toFixed(1)} r="3" />
        <circle class="ma-spark__dot" cx={x(n - 1).toFixed(1)} cy={y(last).toFixed(1)} r="2.5" />
      </>
    )}
  </svg>
</Reveal>

<style is:global>
  @layer components {
    :where(.ma-spark) {
      display: inline-block;
      max-width: 100%;
      line-height: 0;
      vertical-align: middle;
    }
    :where(.ma-spark__svg) {
      display: block;
      max-width: 100%;
      height: auto;
      overflow: visible;
      color: var(--ma-spark-color, currentColor);
    }
    :where(.ma-spark__line) {
      fill: none;
      stroke: currentColor;
      stroke-width: 2;
      stroke-linecap: round;
      stroke-linejoin: round;
      /* A wipe, where this used to be a pathLength="1" dash: the stroke is non-scaling, so its dashes are measured on
         screen while pathLength is measured in the viewBox, and a chart drawn four times its viewBox came out as a
         quarter dash, a gap, a dash. The inset reaches 1em past every edge so the round caps and joins are not cut. */
      clip-path: inset(-1em);
      transition: clip-path var(--ma-spark-dur, var(--ma-duration-slow)) var(--ma-ease-out) var(--ma-r-delay, 0ms);
    }
    :where(.ma-spark__area) {
      fill: currentColor;
      opacity: 0.12;
      transition: opacity var(--ma-duration) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-spark-dur, var(--ma-duration-slow)));
    }
    :where(.ma-spark__dot) {
      fill: currentColor;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-spark-dur, var(--ma-duration-slow)));
    }
    /* hidden until its animation starts, so nothing shows while the line is still drawing */
    :where(.ma-spark__ring) {
      fill: currentColor;
      opacity: 0;
      transform-box: fill-box;
      transform-origin: 50% 50%;
      animation: ma-spark-pulse var(--ma-dur-ambient) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-spark-dur, var(--ma-duration-slow))) infinite;
    }
    /* Under the JS gate the line starts undrawn; Reveal's .ma-in lets it draw, then the dot lands and pulses. */
    :where([data-ma-js] .ma-spark:not(.ma-in) .ma-spark__line) {
      clip-path: inset(-1em calc(100% + 1em) -1em -1em);
      transition: none;
    }
    :where([data-ma-js] .ma-spark:not(.ma-in) .ma-spark__area),
    :where([data-ma-js] .ma-spark:not(.ma-in) .ma-spark__dot) {
      opacity: 0;
      transition: none;
    }
    :where([data-ma-js] .ma-spark:not(.ma-in) .ma-spark__ring) {
      animation: none;
      opacity: 0;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-spark__line),
      :where(.ma-spark__area),
      :where(.ma-spark__dot) {
        transition: none;
      }
      :where(.ma-spark__ring) {
        animation: none;
        opacity: 0;
      }
    }
    /* Print: drawn, with its area and its last point. A chart printed before it scrolled into view was empty. */
    @media print {
      :where(.ma-spark .ma-spark__line) {
        clip-path: none;
        transition: none;
      }
      :where(.ma-spark .ma-spark__area) {
        opacity: 0.12;
        transition: none;
      }
      :where(.ma-spark .ma-spark__dot) {
        opacity: 1;
        transition: none;
      }
      :where(.ma-spark .ma-spark__ring) {
        animation: none;
        opacity: 0;
      }
    }
  }

  @keyframes ma-spark-pulse {
    from {
      scale: 1;
      opacity: 0.5;
    }
    to {
      scale: 3;
      opacity: 0;
    }
  }
</style>

```
