Skip to content
txbrendan/ui

Estimate Chart

beta

What 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

Estimate and actual per quarter
  • Estimate
  • Beat
  • Miss
  • Match

below 0

Estimate and actual, from a loss to a profit
  • Estimate
  • Beat
  • Miss
  • Match

match

Close counts as a match
  • Estimate
  • Beat
  • Miss
  • Match

size

Small

Medium

Large

loading

Estimate and actual per quarter
  • Estimate
  • Beat
  • Miss
  • Match

Loading chart

empty

Estimate and actual per quarter
No quarters reported yet

No quarters reported yet

error

Estimate and actual per quarter

Quarters could not load.

Quarters could not load.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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()}
/>