# Animated SVG Line Chart in React Without a Chart Library (Draw-On Effect)

> Map your values into a 1000 by 300 SVG viewBox, draw one path, and set pathLength={1} so a stroke-dasharray of 1 covers the whole line. Animate stroke-dashoffset from 1 to 0 in CSS and it draws itself. Add vector-effect non-scaling-stroke, key the path on its d string to redraw on new data, a reduced motion fallback and a hidden data table for screen readers.

- URL: https://qodesign.dev/blog/animated-svg-line-chart-react-no-library
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

You can build an animated line chart in React with one SVG `path`, a little math to turn numbers into coordinates, and about 30 lines of CSS. Set `pathLength={1}` on the path, give it a dash as long as the line, and animate `stroke-dashoffset` from 1 to 0. The line draws itself from left to right, and the chart is still plain markup that renders on the server in the Next.js App Router. If you only need a trend line on a dashboard card, you don't need a chart library for it.

I make [qodesign](/), a set of animated React + TypeScript + Tailwind components. The revenue page in our [Command-K Dashboard](/components/dashboard-saas) draws its line this way, with a glow, while an odometer rolls up the total. The code below is a smaller version written for this post, not the component's source.

## When a chart library is too much

Libraries like Recharts or Chart.js earn their size when you need axes with smart ticks, tooltips, zoom, legends and many series. A lot of SaaS dashboards built with Cursor, v0 or Lovable show something simpler: one line per card, a soft fill under it, maybe a hover value. For that, a hand-written SVG gives you full control over the look and adds no dependency to your bundle.

The trade is that you write the scaling yourself. That part is short.

## Turn the numbers into a path

An SVG `viewBox` lets you draw in your own coordinate system and let the browser scale it. Pick a box of 1000 by 300, spread the points evenly across the width and map each value between the lowest and highest point onto the height. SVG's y axis points down, so subtract from the height.

```tsx
type Point = { label: string; value: number };

type LineChartProps = {
  data: readonly Point[];
  label: string;
  className?: string;
};

const W = 1000;
const H = 300;

function toPath(values: readonly number[]) {
  const lo = Math.min(...values);
  const hi = Math.max(...values);
  const span = hi - lo || 1;
  return values
    .map((v, i) => {
      const x = (i / Math.max(values.length - 1, 1)) * W;
      const y = H - ((v - lo) / span) * (H - 20) - 10;
      return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`;
    })
    .join(' ');
}

const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });

export function LineChart({ data, label, className }: LineChartProps) {
  const d = toPath(data.map((p) => p.value));
  return (
    <figure className={className}>
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={label} className="h-48 w-full">
        <path key={`area-${d}`} className="chart-area" d={`${d} L ${W} ${H} L 0 ${H} Z`} />
        <path key={d} className="chart-line" d={d} pathLength={1} vectorEffect="non-scaling-stroke" />
      </svg>
      <table className="sr-only">
        <caption>{label}</caption>
        <tbody>
          {data.map((p) => (
            <tr key={p.label}>
              <th scope="row">{p.label}</th>
              <td>{fmt.format(p.value)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </figure>
  );
}
```

A few details in there do real work:

- The 10 px of padding at the top and bottom keeps the highest and lowest points from being clipped by the stroke.
- `span || 1` stops a flat series (every value the same) from dividing by zero.
- `preserveAspectRatio="none"` stretches the drawing to any card width, so the same path fills a 320 px phone card and a 900 px desktop panel.
- Stretching would also stretch the stroke, so a 2.5 px line could turn into a fat smear on a wide card. [`vector-effect="non-scaling-stroke"`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/vector-effect) keeps the stroke width in screen pixels. In JSX it's `vectorEffect`.
- The second path closes the line down to the bottom corners to make the fill area.

The file has no `'use client'`. It has no state or effects, so it renders as a Server Component and ships no JavaScript for the chart itself.

## The draw-on animation

The trick is [`pathLength`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/pathLength). It tells the browser to treat the path as if its total length were the number you give, so dash lengths and offsets use that unit instead of real pixels. With `pathLength={1}`, a dash of 1 covers the whole line whatever its shape or width, and you never measure anything with `getTotalLength()`.

Put this in your `globals.css`, next to the Tailwind import:

```css
@keyframes chart-draw {
  to { stroke-dashoffset: 0; }
}
@keyframes chart-fade {
  to { opacity: 1; }
}
.chart-line {
  fill: none;
  stroke: #c6f432;
  stroke-width: 2.5;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  animation: chart-draw 1.2s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
.chart-area {
  fill: rgb(198 244 50 / 0.15);
  opacity: 0;
  animation: chart-fade 0.6s ease-out 0.8s forwards;
}
.chart-paused .chart-line,
.chart-paused .chart-area {
  animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
  .chart-line,
  .chart-area {
    animation: none;
    stroke-dashoffset: 0;
    opacity: 1;
  }
}
```

`stroke-dasharray: 1` makes one dash the length of the whole path followed by a gap of the same length. `stroke-dashoffset: 1` shifts the pattern so the gap covers the line, which hides it. The keyframes slide the offset to 0 and the dash moves into place, so the line appears to draw from the first point to the last. The fill fades in near the end so it doesn't sit there empty while the line is still drawing. The same dash and offset trick also drives a percentage gauge: [Circular Progress Ring in React and Tailwind](/blog/circular-progress-ring-react-tailwind-svg) sets the offset to `circumference * (1 - value / 100)` on a circle and rotates the SVG by minus 90 degrees so the ring fills from the top.

The reduced motion block shows the finished chart at once. We wrote more about that media query in [prefers-reduced-motion in React](/blog/prefers-reduced-motion-react).

## Redraw when the data changes

CSS animations run once when an element is mounted. Both paths use their `d` string as the React `key`, so new data gives React a new key, it mounts a fresh path and the animation starts again from the left. Switching a card from 7 days to 30 days redraws the line with no extra code.

## Draw when the card scrolls into view

On a long dashboard the animation can finish before anyone scrolls down to the chart. This small client component holds the animation paused until the chart is 40 percent visible, using an [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API):

```tsx
'use client';

import { useEffect, useRef, useState, type ReactNode } from 'react';

export function DrawWhenVisible({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [seen, setSeen] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el || seen) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) {
          setSeen(true);
          io.disconnect();
        }
      },
      { threshold: 0.4 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [seen]);

  return (
    <div ref={ref} className={seen ? undefined : 'chart-paused'}>
      {children}
    </div>
  );
}
```

And a card that uses both, with a range switch:

```tsx
'use client';

import { useState } from 'react';
import { LineChart } from './LineChart';
import { DrawWhenVisible } from './DrawWhenVisible';

const RANGES = {
  '7d': [
    { label: 'Mon', value: 4200 }, { label: 'Tue', value: 5100 }, { label: 'Wed', value: 4800 },
    { label: 'Thu', value: 6100 }, { label: 'Fri', value: 7300 }, { label: 'Sat', value: 6900 },
    { label: 'Sun', value: 8200 },
  ],
  '30d': [
    { label: 'Week 1', value: 21000 }, { label: 'Week 2', value: 26500 },
    { label: 'Week 3', value: 24800 }, { label: 'Week 4', value: 31200 },
  ],
} as const;

type Range = keyof typeof RANGES;

export function RevenueCard() {
  const [range, setRange] = useState<Range>('7d');
  return (
    <section className="rounded-2xl bg-zinc-900 p-5 text-zinc-100">
      <div className="mb-3 flex gap-2">
        {(Object.keys(RANGES) as Range[]).map((r) => (
          <button key={r} type="button" aria-pressed={r === range} onClick={() => setRange(r)}>
            {r}
          </button>
        ))}
      </div>
      <DrawWhenVisible>
        <LineChart data={RANGES[range]} label={`Revenue, last ${range}`} />
      </DrawWhenVisible>
    </section>
  );
}
```

`RevenueCard` is a client component because it holds the selected range. `LineChart` is still the same file and works on either side.

One trade to know about: the server HTML arrives with `chart-paused`, so if JavaScript never runs, the line stays hidden. If that matters for a page, skip the wrapper and let the chart draw on load.

## Accessibility

An SVG line means nothing to a screen reader. `role="img"` with an `aria-label` names the chart, and the visually hidden table under it gives the actual values, row by row, formatted as money. Tailwind's `sr-only` class hides the table on screen and keeps it readable for assistive tech.

Keep the label specific. "Revenue, last 7d" tells someone what the chart is about; "chart" does not. If you add a hover value on top of the line, give it a keyboard path too: [Accessible Tooltip in React and Tailwind CSS Without a Library](/blog/accessible-tooltip-react-tailwind-no-library) shows how to open it on focus as well as hover and close it on Escape.

## Hydration-safe numbers

Everything in the path is computed from your data with `toFixed(1)`, so the server and the browser produce the same `d` string. The currency formatter passes `'en-US'` explicitly. Without a locale, `Intl.NumberFormat` uses the runtime's default, and a server in one locale and a browser in another can format the same number differently, which React reports as a hydration mismatch. The same bug shows up with dates and random values; we covered that in [fixing hydration errors from dates and random values](/blog/fix-hydration-error-dates-random-nextjs).

## Adding a glow

The revenue chart in the Command-K Dashboard adds a CSS `drop-shadow` filter to the line, in the line's own color. A filter on one path is cheap enough. Avoid putting it on a parent that holds the whole card, because the browser then repaints the shadow for everything inside it while the line animates.

If you want the total above the chart to roll up while the line draws, we wrote about that in [an animated number counter in React without a library](/blog/animated-number-counter-react-no-library).

## Prompting Cursor, v0 or Lovable for this

AI tools tend to reach for a chart library, or measure the path with `getTotalLength()` inside an effect, which runs only after the page loads and can flash the full line first. Ask for the specific pieces instead: one SVG path, `pathLength={1}` with a dash animation in CSS, `vector-effect="non-scaling-stroke"`, a reduced motion fallback and a hidden data table for screen readers. With those in the prompt, the first result is usually close.

## FAQ

### Why is there a small dot at the start before the line draws?

That can happen with `stroke-linecap: round`. The hidden dash still has an end at the first point, and a round cap draws a dot there. Keep the default butt cap on the animated line, which is what the CSS above does.

### Can I draw a smooth curve instead of straight segments?

Yes. Replace the `L` commands with cubic Bézier `C` commands between points. `pathLength` works on any path, so the animation code doesn't change.

### Do I need Framer Motion for this?

No. Framer Motion can animate `pathLength` too, and if it's already in your app for other things, that works. For a single draw-on, CSS keyframes do the same job, and this version needs no client JavaScript unless you add the scroll trigger.

### Where can I see this in a full dashboard?

The [Command-K Dashboard](/components/dashboard-saas) uses a drawn line with a glow on its revenue page, and its palette's small sparkline grows into the full chart. There's a walkthrough in the [Command-K dashboard for React](/react/command-k-dashboard) guide.
