Skip to content
Product block

Dual-Series Line Chart

A year-over-year trend in inline SVG with a printed axis, no charting dependency and the same data repeated as a screen-reader table.

277 linesNo dependenciesAdded 25 Aug 2026Updated 13 Sept 2026
  • line chart
  • chart
  • svg
  • trend
  • analytics

What's included

  • components/line-chart-panel.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add line-chart-panel

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Monthly active workspaces

Compared with the same month last year.

  • This year
  • Last year
  • 5,000
  • 3,500
  • 2,000
  • Jan
  • Mar
  • May
  • Jul
  • Sep
  • Nov
Monthly active workspaces
MonthThis yearLast year
Jan3,1202,980
Feb3,2603,010
Mar3,1803,090
Apr3,5403,120
May3,7203,050
Jun3,6103,210
Jul3,8803,180
Aug4,0203,340
Sep3,9603,290
Oct4,1803,410
Nov4,3203,380
Dec4,4103,520

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/line-chart-panel.tsx
/**
 * <LineChartPanel> — a trend over time, in SVG, with no charting library.
 *
 * <MetricSparklineCards> answers "is this going up". <BarChartPanel>
 * compares discrete things. Neither answers the question a dashboard is
 * usually opened for: what happened, and when. That needs a dated axis, a
 * readable scale and two series you can compare — which is the point at
 * which most teams reach for Recharts and add 90 KB to a page that draws
 * one line.
 *
 * The whole chart is a path and a polygon in an inline SVG, sized with a
 * viewBox and `preserveAspectRatio="none"` so it stretches to its
 * container. No dependency, no client component, no hydration: this is a
 * server component that renders identical markup on the server and in a
 * static export.
 *
 * WHY THE Y AXIS DOES NOT START AT ZERO, AND SAYS SO
 *
 * It starts at a rounded floor below the data. Forcing zero on a series
 * that moves between 2,900 and 4,400 flattens the shape into a straight
 * line near the top, which is a real way to hide a trend. The axis labels
 * are printed, so the choice is visible rather than concealed — a truncated
 * axis is honest when it is legible and a lie when it is not.
 *
 * ACCESSIBILITY: THE TABLE IS THE CHART
 *
 * An SVG polyline is unreadable to a screen reader no matter how many ARIA
 * attributes are hung off it, and `role="img"` with a summary label reduces
 * twelve months to one sentence somebody else wrote. So the same data is
 * rendered as a real `<table>`, positioned off-screen with `sr-only`. The
 * graphic carries `aria-hidden`. Anyone using assistive technology gets the
 * numbers rather than a description of a picture of them.
 *
 * The grid, the labels and the two series are all in one coordinate space
 * (0–100 on both axes) so the maths stays readable: every point is a
 * percentage of the plot, and the SVG scales it.
 */

import type * as React from 'react'

export interface LineSeries {
  name: string
  values: number[]
  /**
   * A Tailwind *text* colour class, not a colour value.
   *
   * The series is drawn with `currentColor`, so one class colours the line,
   * the area fill and the legend swatch together and there is no hex to
   * keep in step across three places. It also means the block inherits the
   * consuming project's palette — `text-primary` is whatever their primary
   * is — instead of shipping a brand colour of its own.
   */
  colorClass?: string
  /** Fill the area under this series. One series should, both should not. */
  area?: boolean
}

export interface LineChartPanelProps {
  heading?: string
  description?: string
  labels?: string[]
  series?: LineSeries[]
  /** Formats the axis and the accessible table. */
  format?: (value: number) => string
  className?: string
}

const DEFAULT_LABELS = [
  'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
]

const DEFAULT_SERIES: LineSeries[] = [
  {
    name: 'This year',
    values: [3120, 3260, 3180, 3540, 3720, 3610, 3880, 4020, 3960, 4180, 4320, 4410],
    colorClass: 'text-primary',
    area: true,
  },
  {
    name: 'Last year',
    values: [2980, 3010, 3090, 3120, 3050, 3210, 3180, 3340, 3290, 3410, 3380, 3520],
    colorClass: 'text-muted-foreground',
  },
]

/** Round a bound outward to something a human would have chosen. */
function niceFloor(value: number, step: number): number {
  return Math.floor(value / step) * step
}
function niceCeil(value: number, step: number): number {
  return Math.ceil(value / step) * step
}

/**
 * A per-instance heading id, derived rather than generated.
 *
 * `React.useId()` is the textbook fix for a literal id in a reusable
 * component and is not available here: this block has no `'use client'`
 * and does not warrant one. It renders text, and a band that hydrates on
 * every page to render text is a cost with nothing on the other side of
 * it. Same call, and the same reasoning, as `footer-mega`.
 *
 * Hashing the heading *and* the description is deliberate. Either alone works
 * until two callers happen to agree, which is exactly what a catalog hub
 * rendering every page into one document arranges for; between them they
 * are the two props nobody leaves at the default.
 */
function headingId(heading: string, description: string): string {
  let hash = 0
  const source = `${heading}|${description}`
  for (let i = 0; i < source.length; i++) {
    hash = (Math.imul(hash, 31) + source.charCodeAt(i)) | 0
  }
  return `line-chart-heading-${(hash >>> 0).toString(36).slice(0, 4)}`
}

export function LineChartPanel({
  heading = 'Monthly active workspaces',
  description = 'Compared with the same month last year.',
  labels = DEFAULT_LABELS,
  series = DEFAULT_SERIES,
  format = (value) => value.toLocaleString('en-US'),
  className = '',
}: LineChartPanelProps) {
  const all = series.flatMap((s) => s.values)
  const rawMin = Math.min(...all)
  const rawMax = Math.max(...all)

  /*
    A step sized to the data rather than a constant, so the axis reads in
    round numbers whether the series is in hundreds or millions.
  */
  const magnitude = Math.pow(10, Math.floor(Math.log10(rawMax - rawMin || 1)))
  const min = niceFloor(rawMin - (rawMax - rawMin) * 0.1, magnitude)
  const max = niceCeil(rawMax + (rawMax - rawMin) * 0.1, magnitude)
  const span = max - min || 1

  const x = (index: number, length: number) =>
    length <= 1 ? 0 : (index / (length - 1)) * 100
  const y = (value: number) => 100 - ((value - min) / span) * 100

  const ticks = [max, min + span * 0.5, min]

  return (
    <section
      aria-labelledby={headingId(heading, description)}
      className={`mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
    >
      <div className="relative rounded-2xl border border-border bg-card p-6 sm:p-8">
        <div className="flex flex-wrap items-start justify-between gap-4">
          <div>
            <h2 id={headingId(heading, description)} className="text-lg font-semibold text-foreground">
              {heading}
            </h2>
            <p className="mt-1 text-sm text-muted-foreground">{description}</p>
          </div>

          <ul className="flex flex-wrap items-center gap-4">
            {series.map((s) => (
              <li key={s.name} className="flex items-center gap-2 text-sm text-muted-foreground">
                <span
                  aria-hidden
                  className={`h-0.5 w-5 rounded-full bg-current ${s.colorClass ?? 'text-primary'}`}
                />
                {s.name}
              </li>
            ))}
          </ul>
        </div>

        <div className="mt-6 flex gap-3">
          {/* Axis labels live outside the SVG so they render at real text
              size — text inside a non-uniformly scaled viewBox is stretched
              with it, which is the classic way this pattern goes wrong. */}
          <ul className="flex min-w-14 shrink-0 flex-col justify-between py-0.5 text-end font-mono text-xs text-muted-foreground">
            {ticks.map((tick) => (
              <li key={tick}>{format(Math.round(tick))}</li>
            ))}
          </ul>

          <div className="min-w-0 flex-1">
            <svg
              aria-hidden
              viewBox="0 0 100 100"
              preserveAspectRatio="none"
              className="h-56 w-full overflow-visible"
            >
              {ticks.map((tick) => (
                <line
                  key={tick}
                  x1="0"
                  x2="100"
                  y1={y(tick)}
                  y2={y(tick)}
                  className="text-border"
                  stroke="currentColor"
                  strokeWidth="0.4"
                  vectorEffect="non-scaling-stroke"
                />
              ))}

              {series.map((s) => {
                const points = s.values
                  .map((value, i) => `${x(i, s.values.length)},${y(value)}`)
                  .join(' ')

                /* One class on the group; everything inside inherits it
                   through `currentColor`. */
                return (
                  <g key={s.name} className={s.colorClass ?? 'text-primary'}>
                    {s.area ? (
                      <polygon
                        points={`0,100 ${points} 100,100`}
                        fill="currentColor"
                        opacity="0.12"
                      />
                    ) : null}
                    <polyline
                      points={points}
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      vectorEffect="non-scaling-stroke"
                    />
                  </g>
                )
              })}
            </svg>

            <ul className="mt-2 flex justify-between gap-1 font-mono text-xs text-muted-foreground">
              {labels.map((label, i) =>
                /* Every other label below `sm`, all of them above it —
                   twelve months in 320 pixels overlap into a smudge. */
                i % 2 === 0 ? (
                  <li key={label} className="min-w-0 truncate">{label}</li>
                ) : (
                  <li key={label} className="hidden min-w-0 truncate sm:block">
                    {label}
                  </li>
                ),
              )}
            </ul>
          </div>
        </div>

        {/* The chart, for anyone who cannot see the chart. */}
        <table className="sr-only">
          <caption>{heading}</caption>
          <thead>
            <tr>
              <th scope="col">Month</th>
              {series.map((s) => (
                <th key={s.name} scope="col">
                  {s.name}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {labels.map((label, i) => (
              <tr key={label}>
                <th scope="row">{label}</th>
                {series.map((s) => (
                  <td key={s.name}>{s.values[i] === undefined ? '—' : format(s.values[i]!)}</td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Nothing to install. No component library, no icon package.
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/line-chart-panel.tsx and import it where you need the section:

import { LineChartPanel } from '@/components/line-chart-panel'

Customize

2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
headingstring'Monthly active workspaces'
descriptionstring'Compared with the same month last year.'
labelsstring[]DEFAULT_LABELS
seriesLineSeries[]DEFAULT_SERIES
formatFormats the axis and the accessible table.(value: number) => string(value) => value.toLocaleString('en-US')
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

line-chart-panel.html
<!--
  Dual-Series Line Chart — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section aria-labelledby="line-chart-heading-1pxp" class="mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 lg:px-8 ">
  <div class="relative rounded-2xl border border-border bg-card p-6 sm:p-8">
    <div class="flex flex-wrap items-start justify-between gap-4">
      <div>
        <h2 id="line-chart-heading-1pxp" class="text-lg font-semibold text-foreground">Monthly active workspaces</h2>
        <p class="mt-1 text-sm text-muted-foreground">Compared with the same month last year.</p>
      </div>
      <ul class="flex flex-wrap items-center gap-4">
        <li class="flex items-center gap-2 text-sm text-muted-foreground"><span aria-hidden="true" class="h-0.5 w-5 rounded-full bg-current text-primary"></span>This year</li>
        <li class="flex items-center gap-2 text-sm text-muted-foreground"><span aria-hidden="true" class="h-0.5 w-5 rounded-full bg-current text-muted-foreground"></span>Last year</li>
      </ul>
    </div>
    <div class="mt-6 flex gap-3">
      <ul class="flex min-w-14 shrink-0 flex-col justify-between py-0.5 text-end font-mono text-xs text-muted-foreground">
        <li>5,000</li>
        <li>3,500</li>
        <li>2,000</li>
      </ul>
      <div class="min-w-0 flex-1">
        <svg aria-hidden="true" viewBox="0 0 100 100" preserveAspectRatio="none" class="h-56 w-full overflow-visible">
          <line x1="0" x2="100" y1="0" y2="0" class="text-border" stroke="currentColor" stroke-width="0.4" vector-effect="non-scaling-stroke"></line>
          <line x1="0" x2="100" y1="50" y2="50" class="text-border" stroke="currentColor" stroke-width="0.4" vector-effect="non-scaling-stroke"></line>
          <line x1="0" x2="100" y1="100" y2="100" class="text-border" stroke="currentColor" stroke-width="0.4" vector-effect="non-scaling-stroke"></line>
          <g class="text-primary">
            <polygon points="0,100 0,62.666666666666664 9.090909090909092,58 18.181818181818183,60.66666666666667 27.27272727272727,48.66666666666667 36.36363636363637,42.666666666666664 45.45454545454545,46.333333333333336 54.54545454545454,37.33333333333333 63.63636363636363,32.66666666666667 72.72727272727273,34.66666666666667 81.81818181818183,27.33333333333333 90.9090909090909,22.66666666666667 100,19.66666666666667 100,100" fill="currentColor" opacity="0.12"></polygon>
            <polyline points="0,62.666666666666664 9.090909090909092,58 18.181818181818183,60.66666666666667 27.27272727272727,48.66666666666667 36.36363636363637,42.666666666666664 45.45454545454545,46.333333333333336 54.54545454545454,37.33333333333333 63.63636363636363,32.66666666666667 72.72727272727273,34.66666666666667 81.81818181818183,27.33333333333333 90.9090909090909,22.66666666666667 100,19.66666666666667" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></polyline>
          </g>
          <g class="text-muted-foreground">
            <polyline points="0,67.33333333333334 9.090909090909092,66.33333333333334 18.181818181818183,63.666666666666664 27.27272727272727,62.666666666666664 36.36363636363637,65 45.45454545454545,59.66666666666667 54.54545454545454,60.66666666666667 63.63636363636363,55.333333333333336 72.72727272727273,57 81.81818181818183,53 90.9090909090909,54 100,49.33333333333333" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></polyline>
          </g>
        </svg>
        <ul class="mt-2 flex justify-between gap-1 font-mono text-xs text-muted-foreground">
          <li class="min-w-0 truncate">Jan</li>
          <li class="hidden min-w-0 truncate sm:block">Feb</li>
          <li class="min-w-0 truncate">Mar</li>
          <li class="hidden min-w-0 truncate sm:block">Apr</li>
          <li class="min-w-0 truncate">May</li>
          <li class="hidden min-w-0 truncate sm:block">Jun</li>
          <li class="min-w-0 truncate">Jul</li>
          <li class="hidden min-w-0 truncate sm:block">Aug</li>
          <li class="min-w-0 truncate">Sep</li>
          <li class="hidden min-w-0 truncate sm:block">Oct</li>
          <li class="min-w-0 truncate">Nov</li>
          <li class="hidden min-w-0 truncate sm:block">Dec</li>
        </ul>
      </div>
    </div>
    <table class="sr-only">
      <caption>Monthly active workspaces</caption>
      <thead>
        <tr>
          <th scope="col">Month</th>
          <th scope="col">This year</th>
          <th scope="col">Last year</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th scope="row">Jan</th>
          <td>3,120</td>
          <td>2,980</td>
        </tr>
        <tr>
          <th scope="row">Feb</th>
          <td>3,260</td>
          <td>3,010</td>
        </tr>
        <tr>
          <th scope="row">Mar</th>
          <td>3,180</td>
          <td>3,090</td>
        </tr>
        <tr>
          <th scope="row">Apr</th>
          <td>3,540</td>
          <td>3,120</td>
        </tr>
        <tr>
          <th scope="row">May</th>
          <td>3,720</td>
          <td>3,050</td>
        </tr>
        <tr>
          <th scope="row">Jun</th>
          <td>3,610</td>
          <td>3,210</td>
        </tr>
        <tr>
          <th scope="row">Jul</th>
          <td>3,880</td>
          <td>3,180</td>
        </tr>
        <tr>
          <th scope="row">Aug</th>
          <td>4,020</td>
          <td>3,340</td>
        </tr>
        <tr>
          <th scope="row">Sep</th>
          <td>3,960</td>
          <td>3,290</td>
        </tr>
        <tr>
          <th scope="row">Oct</th>
          <td>4,180</td>
          <td>3,410</td>
        </tr>
        <tr>
          <th scope="row">Nov</th>
          <td>4,320</td>
          <td>3,380</td>
        </tr>
        <tr>
          <th scope="row">Dec</th>
          <td>4,410</td>
          <td>3,520</td>
        </tr>
      </tbody>
    </table>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

9 more blocks in Charts & Metrics

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Charts & Metrics

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Charts & Metrics blocks

View category
Open the full page for this block

Sparkline Metric Cards

KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.

Charts & Metrics142 linesNo deps
Open the full page for this block

CSS Bar Chart Panel

Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.

Charts & Metrics139 linesNo deps
Open the full page for this block

Quota & Usage Meters

Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.

Charts & Metrics172 lines1 dep
Open the full page for this block

Donut Breakdown

Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.

Charts & Metrics205 linesNo deps
Open the full page for this block

Daily Activity Heatmap

A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.

Charts & Metrics231 linesNo deps
Open the full page for this block

Conversion Funnel

A funnel that leads with the step-to-step rate and the people who fell out of it, not the tidy share-of-top percentages nobody can act on. Biggest drop named in words, and a footnote saying which definition of funnel this is.

Charts & Metrics195 lines1 dep
Open the full page for this block

Metric Alert List

Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.

Charts & Metrics141 linesNo deps
Open the full page for this block

KPI Summary Band

The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.

Charts & Metrics118 linesNo deps
Open the full page for this block

Market Price Ticker

The moving price rail that sits under an exchange’s navbar — direction carried by an arrow and a sign as well as by colour, and the duplicate half dropped rather than read twice.

Charts & Metrics180 lines1 dep