ARR Goal-Seek (Explorable)
Drag the projection's endpoint to a goal and growth back-solves; drag the driver handles and a live tornado ranks what moves the outcome. The chart as an instrument.
12-month revenue projection
$7.72M
5.2%/mo · 12 months
Ending ARR is most sensitive to growth — it swings $33.2M across the modeled range.
| Field | Value |
|---|---|
| Start ARR | $4.2M |
| Growth / mo | 5.2% |
| Ending ARR | $7.72M |
| Most sensitive | Growth |
drag the ● (or arrow-key it) to a goal — growth back-solves
Drag the endpoint to explore required growth rates
Demo data is illustrative. Replace with your own typed data prop.
How it computes
Compound ARR projection arr(t) = start·(1+g)ᵗ; drag the endpoint to back-solve the required growth by bisection.
ending = start·(1+g)^months; back-solve g via bisection to tol = $1; tornado swing = |ending(high) − ending(low)|Assumptions
- Growth is a constant monthly rate — no acceleration or decay.
- Start ARR is held fixed while solving for growth.
- Sensitivity ranges (g ∈ [−5%, +20%], start ∈ [50k, 2M]) are hardcoded.
Honest about
- The back-solve clamps to its bracket rather than diverging outside it.
- Pixel-to-value mapping for the drag handle is exact only on linear axes.
- The tornado assumes a monotonic outcome (true for compound growth) — it would mislead on a non-monotonic model.
When to reach for it
Most charts are outputs. This one is an instrument. Use the ARR Goal-Seek when the question is "what would it take?" — drag the projection's endpoint to a number you want to hit, and it back-solves the growth rate required to get there. It turns a planning conversation ("can we reach $10M by December?") into a direct manipulation.
How it thinks
- Drag-to-target — the endpoint handle reads the live axis scale (
useYAxisInverseScale) to turn your drag into a target value, thenbackSolve()(a deterministic monotone bisection) finds the monthly growth that lands the projection exactly there. - Driver handles — the growth and starting-ARR sliders reshape the projection directly; everything recomputes live.
- Live tornado —
tornado()flexes each driver across its range and ranks them, so asensitivityinsight names what moves the outcome most: "Ending ARR is most sensitive to growth."
Good to know
- It's fully interactive standalone — the model lives in local state, so dragging works in production with no provider or wiring.
- Keyboard-accessible: the handle is a
role="slider"you can focus and nudge with the arrow keys; the Y-axis domain is fixed so the mapping stays stable through a drag. - Deterministic throughout — the solver and tornado are pure functions, no LLM, so the same drag always yields the same answer.
Use it with your data
Data contract
Passed via the config prop — an object: validated at runtime, so a bad shape degrades to the error state rather than crashing.
| Field | Type | Required |
|---|---|---|
| startArr | number | yes |
| horizon | integer | yes |
| initialGrowth | number | yes |
Static props
import { ChartExplorableTarget } from "@/components/blocks/chart-explorable-target/chart-explorable-target";
<ChartExplorableTarget config={myData} />Client fetch (SWR)
const { data, isLoading, error } = useSWR("/api/metric", fetcher);
// route isLoading/error through <ChartStates>, then:
<ChartExplorableTarget config={data} />Server component (RSC)
const config = await fetch(url, {
next: { revalidate: 3600 },
}).then((r) => r.json());
<ChartExplorableTarget config={config} />Map arbitrary rows
import { mapRows } from "@/lib/sextant/column-map";
const out = mapRows(rows, { /* contractKey: "yourColumn" */ }, dataSchema);
if (out.ok) <ChartExplorableTarget config={out.data} />;Make it yours
Every customization below is an optional prop — omit them all and you get the defaults shown above. Recolor, reformat, restyle, or drop the card chrome without forking the component.
Recolor or rename a series (arr)
<ChartExplorableTarget
series={{ arr: { color: "var(--chart-3)", label: "…" } }}
/>Format numbers (currency / locale)
const eur = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
notation: "compact",
});
<ChartExplorableTarget valueFormatter={(n) => eur.format(n)} />Replace or hide the narrative
<ChartExplorableTarget narrative="Your own one-liner." />
// …or hide it entirely:
<ChartExplorableTarget narrative={false} />Restyle any region
<ChartExplorableTarget
className="max-w-xl"
classNames={{ body: "bg-muted/20" }}
/>