Sextant
← Playground

eNPS

free

Employee 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-enps

Employee NPS · Q2 2026

+57

eNPS · 287 responses

  • Promoters
  • Passives
  • Detractors

eNPS climbed to +57 (+35 pts since last survey) — 65% promoters vs 8% detractors.

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