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
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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| titleReactNode | ReactNode | Above the chart, left. |
| legend{ name, emphasis? }[] | { name, emphasis? }[] | Drawn only for two or more series. Emphasis gets the scale swatch. |
| table{ columns, rows } | { columns, rows } | The same numbers. Leave it out and there is no toggle. |
| actionReactNode | ReactNode | Controls 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.
"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>
);
}