Estimate Chart
betaWhat was expected against what came in, per period: a ring for the estimate and a dot for the actual, colored by beat, miss, or match.
Preview
data
below 0
match
size
loading
Loading chart
empty
No quarters reported yet
error
Quarters could not load.
Quarters could not load.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/estimate-chart
Use it for
- Showing a forecast next to the result for each period, like earnings per share against the estimate.
- Pass data with a period, an estimate, and an actual for each row, oldest first.
- Leave actual out for a period still to come. It shows its estimate ring alone.
- Change data, as from tabs, and each mark glides to its new value in a wave from left to right. Periods are matched by name.
- Pass matchWithin to count a near value as a match.
- Pass valueFormat, like { style: "currency", currency: "USD" }, to format the tooltip and the axis.
Avoid it for
- One value per period with no forecast. Use LineChart or BarChart.
- Many series at once. This chart compares one estimate with one actual.
- Hundreds of periods. The marks crowd together. Show the latest periods and offer more.
Props
- data
One row per period, oldest first. Leave actual out for a period still to come. Periods may share a name, like Q1 in two years, and each keeps its place. An actual with no estimate draws a plain dot with an Actual key.
{ period: string; estimate?: number | null; actual?: number | null }[]
- title
A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby.
ReactNode
- aria-label
Names the chart when it has no visible title.
string
- valueFormat
Formats tooltip values, and the value labels in short form, like $120B.
Intl.NumberFormatOptions
- locale
The locale for numbers. Fixed, so server and browser print the same text.
string · default "en-US"
- matchWithin
An actual this close to its estimate counts as a match.
number · default 0
- labels
The words for each mark, in the legend and the tooltip.
{ estimate?, actual?, beat?, miss?, match?: string }
- legend
Shows the legend: the estimate ring, then Beat, Miss, and Match once any period is scored. The keys stay put when the data changes. While loading, it shows the estimate ring with Beat, Miss, and Match.
boolean · default true
- size
Height: 5, 7, or 9 times --control-h-md, so density scales it.
"sm" | "md" | "lg" · default "md"
- loading
Shows skeleton points at the same height. When data arrives, they fade out while the chart draws in.
boolean · default false
- error
Shows this message in place of the chart. true shows a default message.
ReactNode
- onRetry
Shows a retry button under the error.
() => void
- emptyText
Shown when no period has an estimate or an actual.
ReactNode · default "No data yet"
- className
Extra classes. Later classes win, so className='h-96' replaces the height.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| data | { period: string; estimate?: number | null; actual?: number | null }[] | - | One row per period, oldest first. Leave actual out for a period still to come. Periods may share a name, like Q1 in two years, and each keeps its place. An actual with no estimate draws a plain dot with an Actual key. |
| title | ReactNode | - | A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby. |
| aria-label | string | - | Names the chart when it has no visible title. |
| valueFormat | Intl.NumberFormatOptions | - | Formats tooltip values, and the value labels in short form, like $120B. |
| locale | string | "en-US" | The locale for numbers. Fixed, so server and browser print the same text. |
| matchWithin | number | 0 | An actual this close to its estimate counts as a match. |
| labels | { estimate?, actual?, beat?, miss?, match?: string } | - | The words for each mark, in the legend and the tooltip. |
| legend | boolean | true | Shows the legend: the estimate ring, then Beat, Miss, and Match once any period is scored. The keys stay put when the data changes. While loading, it shows the estimate ring with Beat, Miss, and Match. |
| size | "sm" | "md" | "lg" | "md" | Height: 5, 7, or 9 times --control-h-md, so density scales it. |
| loading | boolean | false | Shows skeleton points at the same height. When data arrives, they fade out while the chart draws in. |
| error | ReactNode | - | Shows this message in place of the chart. true shows a default message. |
| onRetry | () => void | - | Shows a retry button under the error. |
| emptyText | ReactNode | "No data yet" | Shown when no period has an estimate or an actual. |
| className | string | - | Extra classes. Later classes win, so className='h-96' replaces the height. |
Accessibility
- Renders a <figure> named by its title, or by aria-label. TypeScript requires one.
- Tab focuses the chart. The arrow keys step through the periods, and the tooltip reads out each one: its estimate, its actual, and whether it beat, missed, or matched, with the gap in percent.
- Beat, miss, and match are said in words in the legend and the tooltip, not by color alone. The estimate is a ring and the actual a filled dot, so the two differ by shape too.
- The period under the pointer or the arrow keys grows a little, and a line marks it. The value labels fade back while it is read, since the tooltip carries the numbers. Escape hides the tooltip and brings them back.
- The value labels have their own room at the start of the plot, so no mark covers one. Period labels at either end are nudged inside the chart, and long names are spaced so they never overlap. Right to left, the labels stay in their room. A ring its dot would almost cover is left out, and the tooltip still gives both numbers.
- Marks grow in from left to right once the chart is on screen. When the data changes, each glides to its new value in the same wave, and the value labels cross-fade. When periods come or go, the others slide over first. At 0ms everything draws at once, and Recharts skips the motion for reduced motion.
- Keeps one height while it loads, draws, fails, or has no data, so the page does not move.
Examples
Earnings per share
<EstimateChart
title="Earnings per share"
data={[
{ period: "Q1", estimate: 1.5, actual: 1.6 },
{ period: "Q2", estimate: 1.4, actual: 1.3 },
{ period: "Q3", estimate: 1.7, actual: 1.7 },
{ period: "Q4", estimate: 2.1 },
]}
valueFormat={{ style: "currency", currency: "USD" }}
/>Data that follows tabs
const [measure, setMeasure] = useState("share");
<Tabs value={measure} onValueChange={(next) => setMeasure(String(next))}>
<TabsList aria-label="Measure">
<TabsTab value="share">Per share</TabsTab>
<TabsTab value="revenue">Revenue</TabsTab>
</TabsList>
<TabsPanel value={measure}>
<EstimateChart
title="Estimate and actual per quarter"
data={measure === "share" ? perShare : revenue}
valueFormat={measure === "share"
? { style: "currency", currency: "USD" }
: { style: "currency", currency: "USD", notation: "compact" }}
/>
</TabsPanel>
</Tabs>While it loads, and when it fails
<EstimateChart
aria-label="Earnings per share"
data={data ?? []}
loading={isLoading}
error={error ? "Quarters could not load." : undefined}
onRetry={() => refetch()}
/>