Confidence Fan Chart

Monte-Carlo percentile fan for long-horizon forecasts — shaded confidence bands around a median path, with event markers on the timeline.

"use client";import { ConfidenceFanChart, type FanEvent, type FanPoint } from "@reva/ui";const data: FanPoint[] = [{ year: 2026, age: 42, p10: 320_000, p25: 335_000, p50: 350_000, p75: 372_000, p90: 400_000 },// … one row per forecast year, in order …{ year: 2074, age: 90, p10: 310_000, p25: 620_000, p50: 950_000, p75: 1_380_000, p90: 1_800_000 },];const events: FanEvent[] = [{ year: 2028, label: "House" },{ year: 2048, label: "Stop work" },{ year: 2051, label: "Pension" },{ year: 2054, label: "State pension" },];const gbp = new Intl.NumberFormat("en-GB", {style: "currency", currency: "GBP", notation: "compact", maximumFractionDigits: 1,});export function ForecastFan() {return (  <ConfidenceFanChart    data={data}    events={events}    valueFormatter={(value) => gbp.format(value)}    label="Net worth forecast with 10th–90th percentile confidence bands"  />);}

Usage

import { ConfidenceFanChart, type FanEvent, type FanPoint } from "@reva/ui";

<ConfidenceFanChart data={data} events={events} />

ConfidenceFanChart renders a probabilistic forecast as a fan: an outer shaded band for the 10th–90th percentile range, a stronger inner band for the 25th–75th, and a solid median line on top. Each row of data carries the five fixed percentile keys (p10 / p25 / p50 / p75 / p90) plus the year and an optional age; the hover tooltip surfaces all five values for the year under the cursor. It fills its container's width at a fixed height (default 280px).

Stable references

Pass data (and events) with stable references — module scope, state, or useMemo. The x-axis is categorical, so every event's year must land exactly on a plotted row; a year between points renders no marker.

Customisation

Variants

Five variants map onto the chart role tokens — accent (default, gold), primary (amber), neutral (brass/olive), positive (fern), negative (mulberry). The outer band uses the variant's area-start alpha, the inner band its projected alpha, and the median line its stroke — all from the --chart-{variant}-* tokens, flipping with colour mode automatically.

Accent (default)

Primary

Neutral

Positive

Negative

<ConfidenceFanChart data={data} variant="positive" height={160} />

Value formatting

The component is currency-agnostic — valueFormatter shapes both the y-axis ticks and the tooltip values, defaulting to compact en-GB numbers. Pass a compact GBP formatter for money series:

const gbp = new Intl.NumberFormat("en-GB", {
  style: "currency",
  currency: "GBP",
  notation: "compact",
  maximumFractionDigits: 1,
});

<ConfidenceFanChart data={data} valueFormatter={(value) => gbp.format(value)} />

Y-axis domain

The y-axis anchors at zero by default and extends below only when the 10th percentile goes negative. Pass yDomain to override — for example [0, 2_000_000] to pin a shared scale across a row of fans.

Examples

On-track threshold

Give each row an onTrack value — the capital path required to fund every goal — and the fan switches to threshold colouring: band regions above the dashed line tint positive (fern), regions below tint negative (mulberry), and the median takes the positive stroke. Rows without onTrack keep the standard variant band colouring and the dashed line gaps there, so a threshold covering only part of the horizon reads as "not evaluated" rather than "on track" — supply it on every row for a continuous line. Omit onTrack everywhere to keep the standard variant colouring.

const data: FanPoint[] = [  { year: 2026, age: 42, p10: 320_000, p25: 335_000, p50: 350_000, p75: 372_000, p90: 400_000, onTrack: 340_000 },  // … one row per forecast year, each carrying its required-capital threshold …];<ConfidenceFanChart data={data} events={events} valueFormatter={(value) => gbp.format(value)} />

Without events

Omit events for a bare fan — useful inside a card where the timeline context lives elsewhere.

<ConfidenceFanChart data={data} height={220} label="Forecast confidence fan" />

Loading

Pass loading while a forecast is (re)computing to play a Monte-Carlo simulation: ~100 faint synthetic lives draw sequentially, fanning out from a common start, with a caption inside the plot. The simulation view is deliberately abstract — no axis values, no median line and no event markers, since mid-simulation none of that is known yet. The moment loading flips false the sim cross-fades out while the real fan — bands, median, axis values and milestones — fades in together, with the plot geometry held identical across both so nothing repositions however large the incoming data. Toggle the button below to watch the transition. Reduced-motion users get a static fan of lines and an instant swap.

each line = one simulated life

function ForecastFan() {  const [loading, setLoading] = React.useState(true);  return (    <VStack gap={4}>      <ConfidenceFanChart data={data} events={events} valueFormatter={(v) => gbp.format(v)} loading={loading} />      <Button size="sm" variant="secondary" onClick={() => setLoading((v) => !v)}>        {loading ? "Finish simulation" : "Run simulation"}      </Button>    </VStack>  );}

Props

PropTypeDefaultDescription
dataFanPoint[]Required. Fan rows in year order; stable reference.
eventsFanEvent[]Dashed vertical markers with staggered top labels. Each year must match a plotted row.
variant"accent" | "primary" | "neutral" | "positive" | "negative""accent"Chart role token group.
heightnumber280Chart height in px; width fills the container.
valueFormatter(value: number) => stringcompact en-GB numberFormats y-axis ticks and tooltip values.
xTickFormatter(value: number) => stringplain yearFormats x-axis tick labels.
medianLabelstring"Median"Tooltip label for the median row.
onTrackLabelstring"On track to fund goals"Label on the threshold line + its tooltip row (threshold mode only).
yDomain[number | string | fn, number | string | fn][min(0, dataMin), "auto"]Y-axis domain override.
labelstringSets role="img" + aria-label on the chart region.
loadingbooleanfalsePlays the Monte-Carlo simulation animation; cross-fades to the fan when it flips false.
loadingCaptionstring"each line = one simulated life"Caption shown inside the plot while loading.
classNamestringSizing / spacing hook.

FanPoint

FieldTypeDescription
yearnumberX-axis key (calendar year).
agenumber?Optional age, shown in the tooltip header.
p10number10th percentile — outer band lower bound.
p25number25th percentile — inner band lower bound.
p50numberMedian — the solid line.
p75number75th percentile — inner band upper bound.
p90number90th percentile — outer band upper bound.
onTracknumber?"On track to fund goals" threshold. Any row carrying it switches the fan to positive-above / negative-below threshold colouring with a dashed threshold line; rows without it keep the variant colouring and the line gaps.

FanEvent

FieldTypeDescription
yearnumberMust equal a plotted year exactly (categorical x-axis).
labelstringMarker label, drawn at the top of the dashed line.

On this page