Win/Loss Reasons
freeDiverging win/loss bars by reason with the overall win rate and the objection costing the most deals.
Editable — click an element to tune it
npx shadcn add @sextant/sales-win-lossSales · Win/Loss Reasons
38%
87 won · 140 lost
- Won
- Lost
Win rate grew +59.7% QoQ to 38.3% — driven by losing on Price.
Sales ops · updated every 6 hours
Generated code
<SalesWinLoss />Source — this is the whole widget
"use client";
import * as React from "react";
import { Bar, BarChart, ReferenceLine, XAxis, YAxis } from "recharts";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import { sheenBar, SheenDefs } from "@/registry/default/widget-kit/bar-kit";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
ChartNarrative,
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import type { Insight } from "@/registry/default/sextant-insights/types";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import {
FloatingPanelsProvider,
type Drilldown,
} from "@/registry/default/chart-floating-panels/chart-floating-panels";
import { PanelDragLayer } from "@/registry/default/chart-floating-panels/panel-drag-layer";
import {
makeBarDrilldown,
type BarDatum,
} from "@/registry/default/chart-floating-panels/bar-detail";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
ChartClassNames,
SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { winLossInputSchema, winLoss, type WinLossInput } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export {
winLossReasonSchema,
winLossInputSchema,
type WinLossReason,
type WinLossInput,
} from "./data";
const COLOR_PRESETS = [
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Terracotta", value: "var(--chart-negative)" },
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Ink", value: "var(--chart-2)" },
{ name: "Honey", value: "var(--chart-warning)" },
];
const WIN_LOSS_PLAN: SizePlan = {
lg: { shape: "full", height: "h-64", legend: "full" },
md: { shape: "full", height: "h-56", legend: "inline" },
sm: { shape: "compact" },
mobile: { shape: "full", height: "h-64", legend: "inline", stack: true },
};
/**
* Two series — deals won and deals lost. Recolor either:
* `series={{ lost: { color: "var(--chart-warning)" } }}`.
*/
export type WinLossSeries = { won?: SeriesConfig; lost?: SeriesConfig };
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
data?: WinLossInput;
// ── Card chrome ───────────────────────────────────────────────────────────
label?: string;
attribution?: string;
/** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
className?: string;
/** Targeted class overrides per region (root/header/body/narrative/footnote). */
classNames?: ChartClassNames;
/** Force a size tier, or "auto" (default) to adapt to the card's own width. */
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Recolor the won/lost bars. */
series?: WinLossSeries;
/** Override count formatting (axis, tooltip). Default absolute integer. */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
// ── Floating drill-down panels ────────────────────────────────────────────
/**
* Enable drag-out floating panels: drag a reason's bar out of the plot to spawn
* a draggable, resizable drill-down panel beside the chart (or click a bar /
* focus it and press Enter). Default off, so the widget is byte-identical at
* rest unless opted in. Auto-falls back to stacked panels below the card on
* phones. Powered by `@sextant/chart-floating-panels`.
*/
floatingPanels?: boolean;
/**
* Map a win/loss reason to its panel content. Defaults to a shared breakdown —
* the reason's total deal volume, its share of all closed deals, its rank, and
* a peer comparison across the other reasons.
*/
drilldown?: Drilldown<Row>;
};
const fmtCount = (n: number): string => `${Math.abs(Math.round(n))}`;
type Row = { reason: string; won: number; lostNeg: number; lost: number };
/**
* Pure: diverging rows — won to the right, lost (negated) to the left — sorted
* by total volume so the highest-traffic reasons sit on top. Exported so the
* headless `<SalesWinLossPlot>` and your own code build the same rows.
*/
export function buildWinLossRows(reasons: WinLossInput["reasons"]): Row[] {
return [...reasons]
.sort((a, b) => b.won + b.lost - (a.won + a.lost))
.map((r) => ({ reason: r.reason, won: r.won, lostNeg: -r.lost, lost: r.lost }));
}
/**
* SSR-safe coarse-pointer detection — the same signal `ChartSizeProvider` uses to
* flip a card into its `touch` tier. Read here (outside the size context, since
* the floating-panels stage must wrap the card from *outside* its `overflow-hidden`
* box) to choose stacked panels on phones and floating panels on the desktop.
*/
function useCoarsePointer(): boolean {
const [coarse, setCoarse] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(pointer: coarse), (hover: none)");
const update = () => setCoarse(mql.matches);
update();
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}, []);
return coarse;
}
export function SalesWinLoss(props: Props) {
// Validate consumer data at the boundary: a bad shape shows the error
// state instead of crashing. The built-in demo data is trusted, so skip it.
const parsed =
props.data === undefined ? null : safeData(winLossInputSchema, props.data);
if (parsed && !parsed.ok) {
return (
<ChartStates status="error" shape="bar" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
return <SalesWinLossImpl {...props} />;
}
function SalesWinLossImpl({
data = winLoss,
label,
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
narrative,
floatingPanels = false,
drilldown,
}: Props) {
const studio = useStudio();
const title = studio.text("title", label ?? "Sales · Win/Loss Reasons", {
label: "Chart title",
});
// `series.*.color` sets the default; Studio (when mounted) still edits on top.
const wonColor = studio.color(
"wonColor",
series?.won?.color ?? "var(--chart-positive)",
{ label: "Won color", presets: COLOR_PRESETS },
);
const lostColor = studio.color(
"lostColor",
series?.lost?.color ?? "var(--chart-negative)",
{ label: "Lost color", presets: COLOR_PRESETS },
);
const attributionText = studio.text(
"attribution",
attribution ?? "Sales ops · updated every 6 hours",
{ label: "Attribution" },
);
const wonLabel = series?.won?.label ?? "Won";
const lostLabel = series?.lost?.label ?? "Lost";
const fmt = valueFormatter ?? fmtCount;
const { reasons, priorWinRate } = data;
const totalWon = reasons.reduce((s, r) => s + r.won, 0);
const totalLost = reasons.reduce((s, r) => s + r.lost, 0);
const winRate = totalWon + totalLost > 0 ? totalWon / (totalWon + totalLost) : 0;
const topLoss = reasons.reduce(
(b, r) => (r.lost > b.lost ? r : b),
reasons[0] ?? { reason: "—", won: 0, lost: 0 },
);
// Win rate vs last quarter, attributing the move to the top loss bucket.
const insight: Insight = {
kind: "delta",
metric: "Win rate",
value: winRate,
delta: priorWinRate > 0 ? (winRate - priorWinRate) / priorWinRate : 0,
period: "QoQ",
driver: `losing on ${topLoss?.reason ?? "—"}`,
format: "percent",
};
const config = {
won: { label: wonLabel, color: wonColor },
lost: { label: lostLabel, color: lostColor },
} satisfies ChartConfig;
const winRateLabel = `${(winRate * 100).toFixed(0)}%`;
// ── Floating drill-down panels ────────────────────────────────────────────
// The prop is the hard gate; within it, a Studio toggle governs (no-op in
// production) — the same "prop gates, toggle governs" discipline as ChartCard's
// Motion knob. The stage must wrap the card from *outside* its `overflow-hidden`
// box, so the gate + mode are resolved here (not in the inner size context).
const panelsOn =
floatingPanels &&
studio.toggle("floatingPanels", true, {
label: "Floating panels",
onLabel: "On",
offLabel: "Off",
});
const coarse = useCoarsePointer();
// No horizontal room on a phone → panels stack below the card instead of
// floating around it.
const panelMode = coarse ? "stacked" : "float";
// One drillable bar per reason; magnitude = total deal volume so the shared
// detail's share / rank / peers reflect each reason's overall traffic — the
// same ordering the diverging bars are sorted by.
const panelBars: BarDatum[] = reasons.map((r) => ({
key: r.reason,
label: r.reason,
value: r.won + r.lost,
}));
const resolvedDrilldown =
drilldown ??
makeBarDrilldown<Row>({
bars: panelBars,
format: (n) => fmt(n),
accent: wonColor,
unit: "deals",
});
const card = (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<WinLossInner
data={data}
config={config}
insight={insight}
narrative={narrative}
title={title}
winRateLabel={winRateLabel}
totalWon={totalWon}
totalLost={totalLost}
wonColor={wonColor}
lostColor={lostColor}
wonLabel={wonLabel}
lostLabel={lostLabel}
valueFormatter={valueFormatter}
fmt={fmt}
attributionText={attributionText}
classNames={classNames}
panelsOn={panelsOn}
drilldown={resolvedDrilldown}
/>
</ChartCard.Root>
);
// Wrap the card in the floating-panels stage only when enabled — at rest the
// widget is byte-identical (the provider adds one relative wrapper and nothing
// else).
if (!panelsOn) return card;
return <FloatingPanelsProvider mode={panelMode}>{card}</FloatingPanelsProvider>;
}
type InnerProps = {
data: WinLossInput;
config: ChartConfig;
insight: Insight;
narrative: NarrativeProp | undefined;
title: string;
winRateLabel: string;
totalWon: number;
totalLost: number;
wonColor: string;
lostColor: string;
wonLabel: string;
lostLabel: string;
valueFormatter?: (n: number) => string;
fmt: (n: number) => string;
attributionText: string;
classNames?: ChartClassNames;
/** Resolved drag-out gate (prop AND Studio toggle). */
panelsOn: boolean;
/** Resolved per-reason drill-down content for the panels. */
drilldown: Drilldown<Row>;
};
function WinLossInner({
data,
config,
insight,
narrative,
title,
winRateLabel,
totalWon,
totalLost,
wonColor,
lostColor,
wonLabel,
lostLabel,
valueFormatter,
attributionText,
classNames,
panelsOn,
drilldown,
}: InnerProps) {
const spec = useSizePlan(WIN_LOSS_PLAN);
if (spec.shape === "compact") {
return (
<SparkTile
label={title}
value={winRateLabel}
chip={
<ChartNarrative text={`${totalWon} won · ${totalLost} lost`} variant="chip" />
}
/>
);
}
// Diverging rows — rebuilt here (cheap, pure) so the drag-out overlay shares
// the plot's exact bar geometry; the standalone plot builds its own internally.
const rows = buildWinLossRows(data.reasons);
return (
<>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{winRateLabel}</ChartCard.HeroNumber>
<p className="tabular text-sm text-muted-foreground">
{totalWon} won · {totalLost} lost
</p>
</ChartCard.HeaderMain>
{(spec.legend === "full" || spec.legend === "inline") && (
<ChartCard.Legend
items={[
{ label: wonLabel, color: wonColor },
{ label: lostLabel, color: lostColor },
]}
/>
)}
</ChartCard.Header>
{resolveNarrative(narrative, insight, { className: classNames?.narrative })}
<ChartCard.Body className={classNames?.body}>
<SalesWinLossPlot
data={data}
config={config}
heightClass={spec.height}
wonColor={wonColor}
lostColor={lostColor}
valueFormatter={valueFormatter}
overlay={
panelsOn ? (
<PanelDragLayer<Row>
bars={rows}
getKey={(r) => r.reason}
getSpan={(r) => [-r.lost, r.won]}
orientation="horizontal"
drilldown={drilldown}
accent={wonColor}
ghostValue={(r) => `${r.won} won · ${r.lost} lost`}
/>
) : undefined
}
/>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</>
);
}
type PlotProps = {
/** Closed-deal reasons — sorted into diverging rows internally. */
data: WinLossInput;
/** Series colors + labels (a shadcn ChartConfig keyed by `won` / `lost`). */
config: ChartConfig;
heightClass?: string;
wonColor?: string;
lostColor?: string;
/** Override axis + tooltip number formatting. */
valueFormatter?: (n: number) => string;
/**
* Extra chart children rendered on top of the bars (a Recharts subtree), so a
* caller can layer interactive overlays that read the live axis scales — e.g.
* the `PanelDragLayer` from chart-floating-panels. Rendered last so its hit
* targets sit above the bars. Inert/undefined for the standalone plot.
*/
overlay?: React.ReactNode;
};
/**
* Just the chart-drawing piece — the diverging won/lost bars by reason (won
* right, lost left) with a symmetric domain, axes, and tooltip, but no card
* chrome, narrative, or states. Takes the same input the widget does and sorts
* it internally, so you can drop it into your own layout or compose several.
*/
export function SalesWinLossPlot({
data,
config,
heightClass = "h-64",
wonColor = "var(--chart-positive)",
lostColor = "var(--chart-negative)",
valueFormatter,
overlay,
}: PlotProps) {
const rows = React.useMemo(() => buildWinLossRows(data.reasons), [data.reasons]);
const fmt = valueFormatter ?? fmtCount;
// Symmetric domain so the zero line sits dead center.
const bound = Math.max(1, ...rows.map((r) => Math.max(r.won, r.lost)));
const yWidth = Math.min(130, Math.max(72, ...rows.map((r) => r.reason.length * 7)));
return (
<ChartContainer config={config} className={cn(heightClass, "w-full")}>
<BarChart
data={rows}
layout="vertical"
margin={{ left: 0, right: 16, top: 4, bottom: 4 }}
>
<XAxis
type="number"
domain={[-bound, bound]}
tickFormatter={(v: number) => fmt(v)}
tickLine={false}
axisLine={false}
fontSize={10}
className="smallcaps tabular"
/>
<YAxis
type="category"
dataKey="reason"
tickLine={false}
axisLine={false}
width={yWidth}
fontSize={11}
className="smallcaps"
/>
<ChartTooltip
cursor={{ fill: "var(--muted)", fillOpacity: 0.4 }}
content={
<ChartTooltipContent
formatter={(value, name) => (
<span className="tabular">
<span className="text-muted-foreground">{name}</span>{" "}
{fmt(Number(value))}
</span>
)}
/>
}
/>
<ReferenceLine x={0} stroke="var(--foreground)" strokeOpacity={0.5} />
<SheenDefs />
<Bar
dataKey="won"
name="Won"
fill={wonColor}
radius={[0, 3, 3, 0]}
isAnimationActive={false}
shape={sheenBar}
/>
<Bar
dataKey="lostNeg"
name="Lost"
fill={lostColor}
radius={[3, 0, 0, 3]}
isAnimationActive={false}
shape={sheenBar}
/>
{/* Caller overlay (e.g. the floating-panels drag layer) — last child so
its hit targets sit above the bars and it reads the live axis scales,
hence the static (non-animated) bars above. No-op without a
<FloatingPanelsProvider> ancestor. */}
{overlay}
</BarChart>
</ChartContainer>
);
}