eNPS
freeEmployee NPS on a −100→+100 gauge with the promoter/passive/detractor split and the move since the last survey.
Editable — click an element to tune it
npx shadcn add @sextant/people-enpsEmployee NPS · Q2 2026
+57
eNPS · 287 responses
- Promoters
- Passives
- Detractors
eNPS climbed to +57 (+35 pts since last survey) — 65% promoters vs 8% detractors.
−100+100
28%
65%
People team · Q2 survey closed May 30 · 287 responses · 65% participation
Generated code
<PeopleEnps />Source — this is the whole widget
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
ChartNarrative,
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
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 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 { enpsSchema, enps, type EnpsInput } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { enpsSchema, type EnpsInput } from "./data";
const COLOR_PRESETS = [
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Honey", value: "var(--chart-4)" },
{ name: "Terracotta", value: "var(--chart-negative)" },
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Ink", value: "var(--chart-2)" },
];
// CSS gauge widget: no recharts height to thread. Small drops the gauge for a
// compact stat tile (score hero + a delta-points chip); the other tiers keep
// the full gauge + distribution bar, gating only the legend by tier.
const ENPS_PLAN: SizePlan = {
lg: { shape: "full", legend: "full" },
md: { shape: "full", legend: "inline" },
sm: { shape: "compact" },
mobile: { shape: "full", legend: "inline", stack: true },
};
/**
* Three segments of the response mix, each independently recolorable:
* `series={{ detractors: { color: "var(--chart-warning)" } }}`.
*/
export type EnpsSeries = {
promoters?: SeriesConfig;
passives?: SeriesConfig;
detractors?: SeriesConfig;
};
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
data?: EnpsInput;
// ── Card chrome ───────────────────────────────────────────────────────────
/** Card title (also the export filename). */
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 promoter / passive / detractor segments. */
series?: EnpsSeries;
/** Override the segment % labels. Default rounds to a whole percent. */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
};
const fmtPct = (n: number): string => `${Math.round(n)}%`;
/** Map an eNPS score (−100..+100) to a 0..100 track position. */
const gaugePos = (score: number): number => ((score + 100) / 200) * 100;
/** Pure: turn raw response counts into the percentages + score the gauge reads. */
export function buildEnpsStats(data: EnpsInput) {
const total = data.promoters + data.passives + data.detractors;
const promPct = total > 0 ? (data.promoters / total) * 100 : 0;
const passPct = total > 0 ? (data.passives / total) * 100 : 0;
const detrPct = total > 0 ? (data.detractors / total) * 100 : 0;
const score = Math.round(promPct - detrPct);
const deltaPts = score - data.priorScore;
return { total, promPct, passPct, detrPct, score, deltaPts };
}
export function PeopleEnps(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(enpsSchema, props.data);
if (parsed && !parsed.ok) {
return (
<ChartStates status="error" shape="area" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
return <PeopleEnpsImpl {...props} />;
}
function PeopleEnpsImpl({
data = enps,
label,
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
narrative,
}: Props) {
const studio = useStudio();
const title = studio.text("title", label ?? "Employee NPS · Q2 2026", {
label: "Chart title",
});
// `series.*.color` sets the default; Studio (when mounted) still edits on top.
const promColor = studio.color(
"promColor",
series?.promoters?.color ?? "var(--chart-positive)",
{ label: "Promoter color", presets: COLOR_PRESETS },
);
const passColor = studio.color(
"passColor",
series?.passives?.color ?? "var(--chart-4)",
{
label: "Passive color",
presets: COLOR_PRESETS,
},
);
const detrColor = studio.color(
"detrColor",
series?.detractors?.color ?? "var(--chart-negative)",
{ label: "Detractor color", presets: COLOR_PRESETS },
);
const attributionText = studio.text(
"attribution",
attribution ??
"People team · Q2 survey closed May 30 · 287 responses · 65% participation",
{ label: "Attribution" },
);
const fmt = valueFormatter ?? fmtPct;
const s = React.useMemo(() => buildEnpsStats(data), [data]);
const signedScore = `${s.score >= 0 ? "+" : ""}${s.score}`;
const scoreColor = s.score >= 0 ? "var(--chart-positive)" : "var(--chart-negative)";
const autoNarrative = `eNPS ${s.deltaPts >= 0 ? "climbed" : "slipped"} to ${signedScore} (${s.deltaPts >= 0 ? "+" : ""}${s.deltaPts} pts since last survey) — ${Math.round(s.promPct)}% promoters vs ${Math.round(s.detrPct)}% detractors.`;
return (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<EnpsInner
data={data}
s={s}
title={title}
narrative={narrative}
autoNarrative={autoNarrative}
signedScore={signedScore}
scoreColor={scoreColor}
promColor={promColor}
passColor={passColor}
detrColor={detrColor}
attributionText={attributionText}
fmt={fmt}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: EnpsInput;
s: ReturnType<typeof buildEnpsStats>;
title: string;
narrative: NarrativeProp | undefined;
autoNarrative: string;
signedScore: string;
scoreColor: string;
promColor: string;
passColor: string;
detrColor: string;
attributionText: string;
fmt: (n: number) => string;
classNames?: ChartClassNames;
};
function EnpsInner({
data,
s,
title,
narrative,
autoNarrative,
signedScore,
scoreColor,
promColor,
passColor,
detrColor,
attributionText,
fmt,
classNames,
}: InnerProps) {
const spec = useSizePlan(ENPS_PLAN);
// Small: the gauge can't shrink to a tile, so it drops — a compact stat tile
// (score hero + a delta-points chip) carries the glance.
if (spec.shape === "compact") {
const chipText = `${s.deltaPts >= 0 ? "▲" : "▼"} ${Math.abs(s.deltaPts)} pts since last survey`;
return (
<SparkTile
label={title}
value={signedScore}
chip={
narrative === false ? undefined : typeof narrative === "string" ? (
<ChartNarrative
text={narrative}
variant="chip"
className={classNames?.narrative}
/>
) : (
<ChartNarrative
text={chipText}
variant="chip"
className={classNames?.narrative}
/>
)
}
/>
);
}
return (
<>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{signedScore}</ChartCard.HeroNumber>
<p className="tabular text-sm text-muted-foreground">
eNPS · {s.total} responses
</p>
</ChartCard.HeaderMain>
{(spec.legend === "full" || spec.legend === "inline") && (
<ChartCard.Legend
items={[
{ label: "Promoters", color: promColor },
{ label: "Passives", color: passColor },
{ label: "Detractors", color: detrColor },
]}
/>
)}
</ChartCard.Header>
{resolveNarrative(narrative ?? autoNarrative, undefined, {
className: classNames?.narrative,
})}
<ChartCard.Body className={classNames?.body}>
<div
role="img"
aria-label={`eNPS of ${signedScore}, ${s.deltaPts >= 0 ? "up" : "down"} ${Math.abs(s.deltaPts)} points since the last survey. ${Math.round(s.promPct)}% promoters, ${Math.round(s.passPct)}% passives, ${Math.round(s.detrPct)}% detractors.`}
className="space-y-6"
>
{/* (a) −100..+100 gauge */}
<div className="space-y-1.5">
<div className="relative h-2 rounded-full bg-muted">
{/* zero midpoint */}
<div
aria-hidden
className="absolute inset-y-[-3px] left-1/2 w-px -translate-x-1/2 bg-border"
/>
{/* prior-survey ghost marker */}
<div
aria-hidden
className="absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[1px] border border-muted-foreground/60"
style={{ left: `${gaugePos(data.priorScore)}%` }}
/>
{/* current marker */}
<div
aria-hidden
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[1px] ring-1 ring-card"
style={{ left: `${gaugePos(s.score)}%`, backgroundColor: scoreColor }}
/>
</div>
<div className="smallcaps tabular flex justify-between text-[10px] text-muted-foreground">
<span>−100</span>
<span>+100</span>
</div>
</div>
{/* (b) 100%-stacked distribution */}
<div className="flex h-6 w-full overflow-hidden rounded">
{[
{ key: "detractors", pct: s.detrPct, color: detrColor },
{ key: "passives", pct: s.passPct, color: passColor },
{ key: "promoters", pct: s.promPct, color: promColor },
].map((seg) => (
<div
key={seg.key}
className="tabular flex items-center justify-center text-[10px] font-medium text-background"
style={{ width: `${seg.pct}%`, backgroundColor: seg.color }}
title={`${seg.key}: ${fmt(seg.pct)}`}
>
{seg.pct >= 12 ? fmt(seg.pct) : ""}
</div>
))}
</div>
</div>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</>
);
}