Skip to content

Components / Data

Sparkline

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.

Live demo

sample data

visitors5.2k
signups470
LCP p751.7 s

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 8.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 55 · Firefox 54 · Safari 13.1; needs clip-path.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Sparkline from '@moonarc/core/Sparkline';
---
<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} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/sparkline.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.

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

Props

PropTypeDefaultDescription
datanumber[]noneThe series, in order.
asHTMLTag'span'Element to render.
widthnumber120Width in px, also the viewBox; the drawing scales with it and shrinks to fit a narrower container.
heightnumber32See width.
colorstring'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.
fillbooleanfalseTint the area under the line once it is drawn.
dotbooleantrueMark the last point and pulse it.
durationnumberpreset (700)Draw duration in ms. Unset, the preset's --ma-duration-slow.
labelstringnoneAccessible name; "n points, min, max, last" is appended.
delaynumber0Delay before drawing.
oncebooleantruePlay once and stay.
thresholdnumber0.2Fraction visible to trigger.

Reduced motion

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

With ClientRouter

Inherits Reveal: rebinds after every navigation.

Why it is built this way

Replaces: Sparkline (Preline charts) · react-sparklines · ApexCharts sparklines. See the migration table.

A bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value.

every browserscroll

Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Progress

Loading

A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown