Responsive Line Chart

A server-rendered responsive line chart built with D3.

SMTWTFS024681012
SMTWTFS024681012
SMTWTFS024681012

A server-rendered responsive line chart built with D3 and React, ripe for customization and interaction.

The six charts on this page all render exactly the same component, are layed out with CSS Grid, customize their appearance based on the size of their container, and render on the server and client with no layout shift.

Most charts either render on the client once they've measured the bounds of their container, or render on the server using fixed dimensions or a fixed aspect ratio. There are also some charts that can server render inside responsive containers, but they include the text labels inside the scaled SVG element, which scales the text with the size of the container.

To achieve a fixed pixel size for the text labels while keeping the chart itself responsive, this component uses multiple SVG elements. The SVG containing the chart has a viewBox attribute which scales the graph correctly on initial render and resize, but the SVG containing the text labels has no viewBox, preserving CSS rules like font-size: 14px regardless of the container size. This lets you use CSS or libraries like Tailwind to control font weight, text size, and other properties to change the look and feel of the axis labels without having to consider their rendered size.

This also lets you use CSS features like media queries to change the typography based on the screen or container size. The demo on this page uses Tailwind's container queries to shorten the X axis labels (e.g. from "Mon" to "M") once a chart's width is less than 384px, regardless of the overall viewport size or how the chart is laid out.

Because the chart is designed to fill its container, the container can use either fixed dimensions (like width: 200px and height: 100px) or fluid techniques (like percentage widths, aspect-ratio, Flexbox, or CSS Grid) to lay out the charts. This demo uses a 12-column grid with responsive gap and column values to adapt the layout for mobile and desktop screens. No special props are needed to control other spacing concerns like padding or margin – just normal CSS can be used.

Other server-rendered charts also settle for percentage-based margins to add space between the graph and the axes, since both are within an SVG element that has viewBox set. This compromises the the chart's appearance in small or large containers because a 5% margin is not enough space on a phone, and it's too much space on a large monitor. This chart uses the CSS calc() function to specify a fixed pixel distance between the graph and the axes to solve this problem. It also uses CSS variables and container queries to adjust that distance at different container sizes.

Code

npm i date-fns d3 @types/d3

You'll also need Tailwind CSS installed and configured.

import * as d3 from "d3";
import { format } from "date-fns";
import { CSSProperties } from "react";

function Chart({ data }: { data: { value: number; date: Date }[] }) {
  let xScale = d3
    .scaleTime()
    .domain([data[0].date, data[data.length - 1].date])
    .range([0, 100]);
  let yScale = d3
    .scaleLinear()
    .domain([0, d3.max(data.map((d) => d.value)) ?? 0])
    .range([100, 0]);

  let line = d3
    .line<(typeof data)[number]>()
    .x((d) => xScale(d.date))
    .y((d) => yScale(d.value));

  let d = line(data);

  if (!d) {
    return null;
  }

  return (
    <div
      className="@container relative h-full w-full"
      style={
        {
          "--marginTop": "6px",
          "--marginRight": "8px",
          "--marginBottom": "25px",
          "--marginLeft": "25px",
        } as CSSProperties
      }
    >
      {/* X axis */}
      <svg
        className="absolute inset-0
          h-[calc(100%-var(--marginTop))]
          w-[calc(100%-var(--marginLeft)-var(--marginRight))]
          translate-x-[var(--marginLeft)]
          translate-y-[var(--marginTop)]
          overflow-visible
        "
      >
        {data.map((day, i) => (
          <g key={i} className="overflow-visible font-medium text-gray-500">
            <text
              x={`${xScale(day.date)}%`}
              y="100%"
              textAnchor={
                i === 0 ? "start" : i === data.length - 1 ? "end" : "middle"
              }
              fill="currentColor"
              className="@sm:inline hidden text-sm"
            >
              {format(day.date, "EEE")}
            </text>
            <text
              x={`${xScale(day.date)}%`}
              y="100%"
              textAnchor={
                i === 0 ? "start" : i === data.length - 1 ? "end" : "middle"
              }
              fill="currentColor"
              className="@sm:hidden text-xs"
            >
              {format(day.date, "EEEEE")}
            </text>
          </g>
        ))}
      </svg>

      {/* Y axis */}
      <svg
        className="absolute inset-0
          h-[calc(100%-var(--marginTop)-var(--marginBottom))]
          translate-y-[var(--marginTop)]
          overflow-visible
        "
      >
        <g className="translate-x-4">
          {yScale
            .ticks(8)
            .map(yScale.tickFormat(8, "d"))
            .map((value, i) => (
              <text
                key={i}
                y={`${yScale(+value)}%`}
                alignmentBaseline="middle"
                textAnchor="end"
                className="text-xs tabular-nums text-gray-600"
                fill="currentColor"
              >
                {value}
              </text>
            ))}
        </g>
      </svg>

      {/* Chart area */}
      <svg
        className="absolute inset-0
          h-[calc(100%-var(--marginTop)-var(--marginBottom))]
          w-[calc(100%-var(--marginLeft)-var(--marginRight))]
          translate-x-[var(--marginLeft)]
          translate-y-[var(--marginTop)]
          overflow-visible
        "
      >
        <svg
          viewBox="0 0 100 100"
          className="overflow-visible"
          preserveAspectRatio="none"
        >
          {/* Grid lines */}
          {yScale
            .ticks(8)
            .map(yScale.tickFormat(8, "d"))
            .map((active, i) => (
              <g
                transform={`translate(0,${yScale(+active)})`}
                className="text-gray-700"
                key={i}
              >
                <line
                  x1={0}
                  x2={100}
                  stroke="currentColor"
                  strokeDasharray="6,5"
                  strokeWidth={0.5}
                  vectorEffect="non-scaling-stroke"
                />
              </g>
            ))}

          {/* Line */}
          <path
            d={d}
            fill="none"
            className="text-gray-600"
            stroke="currentColor"
            strokeWidth="2"
            vectorEffect="non-scaling-stroke"
          />

          {/* Circles */}
          {data.map((d) => (
            <path
              key={d.date.toString()}
              d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
              vectorEffect="non-scaling-stroke"
              strokeWidth="8"
              strokeLinecap="round"
              fill="none"
              stroke="currentColor"
              className="text-gray-400"
            />
          ))}
        </svg>
      </svg>
    </div>
  );
}

Usage:

let sales = [
  { date: "2023-04-30T12:00:00.00+00:00", value: 4 },
  { date: "2023-05-01T12:00:00.00+00:00", value: 6 },
  { date: "2023-05-02T12:00:00.00+00:00", value: 8 },
  { date: "2023-05-03T12:00:00.00+00:00", value: 7 },
  { date: "2023-05-04T12:00:00.00+00:00", value: 10 },
  { date: "2023-05-05T12:00:00.00+00:00", value: 12 },
  { date: "2023-05-06T12:00:00.00+00:00", value: 4 },
];
let data = sales.map((d) => ({ ...d, date: new Date(d.date) }));

<div className="grid grid-cols-2 gap-x-4 gap-y-12 p-4">
  <div className="col-span-2 h-60">
    <Chart data={data} />
  </div>
  <div className="h-40">
    <Chart data={data} />
  </div>
  <div className="h-40">
    <Chart data={data} />
  </div>
</div>;

Libraries used