← All components

ChartFrame

A chart with its title, its key, and a switch to read the same numbers as a table.

ARR against plan, $M
  • ARR
  • Plan
ARR and Plan by period01.32.53.85JanFebMarAprMayJunJulAug$4.2

The idea

The table is not a fallback for when the chart fails. It is the answer for a reader who wants the figure rather than the shape, or who cannot see the shape at all, so it sits one press away on every chart that has one. The key appears only when there are two series or more, since one series needs no key.

Composes LineChart.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ChartFrame — usage9 lines
import { ChartFrame, LineChart } from "mihirmodi-ui";

<ChartFrame
  title="ARR against plan, $M"
  legend={[{ name: "ARR", emphasis: true }, { name: "Plan" }]}
  table={{ columns: ["Month", "ARR", "Plan"], rows }}
>
  <LineChart labels={months} series={series} />
</ChartFrame>

Props

PropNotes
titleReactNodeAbove the chart, left.
legend{ name, emphasis? }[]Drawn only for two or more series. Emphasis gets the scale swatch.
table{ columns, rows }The same numbers. Leave it out and there is no toggle.
actionReactNodeControls beside the toggle — a Details link.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/ChartFrame.tsx132 lines
"use client";

import { useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CONTEXT, SCALE, ends, type Good } from "../../lib/scale.js";
import { FLAT, SLIDE } from "../../lib/motion.js";

export type ChartFrameProps = {
  title: ReactNode;
  children: ReactNode;
  /** Series names. Shown only when there are two or more — one series needs no key. */
  legend?: { name: string; emphasis?: boolean }[];
  good?: Good;
  /** The same numbers as a table, for the toggle. Leave it out and there is no toggle. */
  table?: { columns: string[]; rows: ReactNode[][] };
  /** Controls beside the toggle — a Details link. */
  action?: ReactNode;
  className?: string;
};

/**
 * A chart with its title, its key, and a switch to read the same numbers as a
 * table. The table is not a fallback for when the chart fails; it is the answer
 * for a reader who wants the figure rather than the shape, or who cannot see the
 * shape at all. The switch's highlight slides between its two positions.
 */
export function ChartFrame({ title, children, legend, good = "high", table, action, className = "" }: ChartFrameProps) {
  const [view, setView] = useState<"chart" | "table">("chart");
  const reduce = useReducedMotion();
  const [lo, hi] = ends(good);
  return (
    <LazyMotion features={domAnimation} strict>
      <figure className={className}>
        <figcaption className="mb-5 flex items-start justify-between gap-4">
          <div className="min-w-0">
            <div className="text-[13px] text-muted">{title}</div>
            {legend && legend.length > 1 && (
              <ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
                {legend.map((l) => (
                  <li key={l.name} className="flex items-center gap-1.5 text-[12px] text-quiet">
                    <span
                      aria-hidden
                      className="inline-block h-[2px] w-3 rounded-full"
                      style={{ background: l.emphasis ? `linear-gradient(to right, ${lo}, ${SCALE.mid}, ${hi})` : CONTEXT }}
                    />
                    {l.name}
                  </li>
                ))}
              </ul>
            )}
          </div>
          {(table || action) && (
            <div className="flex shrink-0 items-center gap-2">
              {action}
              {table && (
                <div className="relative flex gap-0.5 rounded-[10px] border-[0.5px] border-gray-alpha-400 p-0.5" role="group" aria-label="View">
                  <m.span
                    aria-hidden
                    className="absolute left-0.5 top-0.5 h-7 w-7 rounded-[8px] bg-gray-100"
                    initial={false}
                    animate={{ x: view === "chart" ? 0 : 30 }}
                    transition={reduce ? FLAT : SLIDE}
                  />
                  {(["chart", "table"] as const).map((v) => (
                    <button
                      key={v}
                      type="button"
                      aria-pressed={view === v}
                      aria-label={v === "chart" ? "Chart" : "Table"}
                      onClick={() => setView(v)}
                      className={`relative flex h-7 w-7 items-center justify-center transition-colors duration-150 ${view === v ? "text-gray-1000" : "text-quiet hover:text-gray-1000"}`}
                    >
                      {v === "chart" ? <ChartGlyph /> : <TableGlyph />}
                    </button>
                  ))}
                </div>
              )}
            </div>
          )}
        </figcaption>
        {view === "chart" || !table ? children : <DataTable {...table} />}
      </figure>
    </LazyMotion>
  );
}

/** A plain table with the first column as the row's name and the rest right-aligned figures. */
export function DataTable({ columns, rows }: { columns: string[]; rows: ReactNode[][] }) {
  return (
    <div className="overflow-x-auto">
      <table className="w-full text-[13px]">
        <thead>
          <tr>
            {columns.map((c, i) => (
              <th key={c} className={`border-b-[0.5px] border-gray-alpha-400 py-2 pr-3 font-medium text-gray-1000 ${i > 0 ? "text-right" : "text-left"}`}>
                {c}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => (
            <tr key={i}>
              {r.map((cell, j) => (
                <td key={j} className={`border-b-[0.5px] border-gray-alpha-400 py-2 pr-3 tabular-nums ${j > 0 ? "text-right text-gray-900" : "text-gray-1000"}`}>
                  {cell}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function ChartGlyph() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="h-[13px] w-[13px]">
      <path d="M4 19h16M7 15l4-4 3 3 5-6" />
    </svg>
  );
}

function TableGlyph() {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden className="h-[13px] w-[13px]">
      <rect x="4" y="5" width="16" height="14" rx="2" />
      <path d="M4 10h16M4 14.5h16M10 10v9" />
    </svg>
  );
}