Charts

Likert Diverging

A four-state diverging Likert chart — one row per question with the negative options stacked leftward and the positive rightward around a shared centre, a neutral band that either straddles the axis or steps out of the bar, a switchable percentage base and net-score sorting.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildLikertLayout,
  type ChartLikertDivergingData,
  type LikertBase,
  type LikertNeutralMode,
  type LikertRow,
  type LikertSegment,
  type LikertSort,
} from "./chart-likert-diverging.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-likert-diverging.json

Prompt

Build a React + TypeScript + Tailwind "ChartLikertDiverging" card — a diverging
stacked bar per survey question — with zod. No chart library: the plot is
percentage-positioned boxes inside a track, so question text wraps as real HTML
text and nothing has to be measured at any width.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string; caption?: string;
    scale: { key: string; label: string;
             polarity: "negative" | "neutral" | "positive" }[];
    items: { id: string; label: string;
             counts: Record<scaleKey, number >= 0> }[] }.
  counts are RAW tallies, never pre-computed percentages — the shares have to be
  apportioned across the whole row at once to total 100, and a feed that already
  rounded them has thrown away what that needs. A missing key counts as 0.
- The scale IS the order: refine that it runs negative, then at most one
  neutral, then positive, with unique keys, and that "ready" carries at least
  one item plus one point on each side. A scale with interleaved polarities or
  two neutrals has no unambiguous geometry, so it is refused rather than drawn.
- Component props = z.infer of the schema plus base ("all" | "opinionated",
  controlled) / defaultBase / onBaseChange / showBaseToggle, neutralMode
  ("split" | "aside"), sort ("given" | "net-desc" | "net-asc"), decimals
  (clamped 0-2), axisMax, segmentLabels, barHeight (clamped 10-36),
  skeletonRows (clamped 1-24), locale, onRetry, emptyState, className.
- Ship the geometry as a pure, DOM-free module beside the schema:
  inspectLikert() for the structural pass, apportionPercent() for the rounding,
  buildLikertLayout() returning { ok: true, layout } | { ok: false, issue }.
  The layout carries, per row, every segment with its rank on its own side, its
  count, its apportioned percent and its signed span around the axis, plus the
  row's negative / neutral / positive totals, its net, its two extents and a
  blank flag; and per chart the shared axisMax, the ticks, pooled totals and the
  most and least positive rows.

Behavior — THE NEUTRAL DECISION IS THE WHOLE ARGUMENT OF THIS CHART TYPE
- Do not pick one policy silently. Expose it and state what each one costs:
  - neutralMode="split" puts half the neutral band on each side of the axis.
    Every response stays in the picture and each bar is a whole 100% of its
    base. The cost: the centre line is now the MIDDLE of the neutral band, not
    the line between disagreement and agreement, so the point where negatives
    end sits half a band away from the axis and moves row to row as the neutral
    share changes — exactly the comparison a reader thinks the centre line is
    for.
  - neutralMode="aside" keeps the neutral out of the bar and prints its share
    beside the question with a swatch in the same fill. The centre line is then
    exactly where disagreement ends, so rows are directly comparable on that
    boundary. The cost: the bar no longer adds up to its own base, so its length
    understates how many people answered, and the eye can read a wide bar as a
    strong response when it only means "few people sat on the fence".
  - Neither is a default anyone can inherit blindly, so whichever one is on,
    the card prints the sentence explaining it under the plot.
- The percentage base is switchable because the two denominators answer
  different questions: "all" counts everybody, "opinionated" divides by the
  people who took a side (the "excluding neutral / of those with an opinion"
  convention). Under "opinionated" the neutral has no share of the base at all,
  so it is out of the bar whatever neutralMode says — but its share of ALL
  responses stays printed beside the question, because a base change must never
  silently delete a third of the sample. Render the switch as two real radios in
  a fieldset (one tab stop and arrow keys come from the platform); hide it
  entirely when the scale has no neutral point, since both bases are then the
  same denominator and the control would change nothing.
- Percentages use largest-remainder (Hamilton) apportionment on an integer grid
  of 10^decimals units: floor every share, then hand the leftover units to the
  largest fractional parts, ties to the bigger count and then to the earlier
  point. The bar is drawn FROM THOSE SAME NUMBERS, so the printed row total and
  the drawn extent can never disagree. Say the known cost out loud: at
  decimals=0 four identical 0.246% slices print as 1 / 0 / 0 / 0 — pass
  decimals={1} when the tail matters.
- ONE SHARED SYMMETRIC AXIS. Every row is drawn against the same ±axisMax,
  rounded up to a multiple of 10 from the largest extent on either side. It has
  to be symmetric or a 30% left bar and a 30% right bar come out different
  lengths; it has to be shared or rows cannot be compared at all. The axisMax
  prop is clamped to [auto, 100] — it can only add headroom, never clip a row
  off the ends.
- Degenerate rows, each decided on purpose: a question with no responses prints
  an em dash on both sides and draws nothing (that is NOT the empty status,
  which means the survey has no questions); under "opinionated" a question where
  everyone was neutral has an empty base and reads "All neutral" rather than a
  bar drawn from zero; a question swept by one answer pushes the shared axis to
  ±100% for every other row, which is the shared scale being honest.
- Refuse rather than mislead: a negative, NaN or infinite count, a duplicate
  question id, a duplicate scale key, two neutrals or an out-of-order scale all
  render the error branch naming the offending row — a negative count could only
  be drawn as length pointing across the axis, where it reads as the other
  side's data.

Rendering & styling
- Layout is one grid per row with container queries on the card, not the
  viewport: below @md the question wraps full width on its own line with the
  track underneath; above it the columns are label / negative total / track /
  positive total. Question text NEVER truncates — it is the question.
- Segments are absolutely positioned inside the track by percent, mapped from
  the signed span [from, to] onto [0%, 100%] around the centre. Ordering is from
  the axis outward, so the mildest step touches the centre and the most extreme
  one carries the end of the bar and the round cap.
- COLOUR IS THE THIRD CHANNEL, NOT THE FIRST. Position (which side of the axis)
  and the ordinal ramp (heavier outward) carry the meaning; the percentage
  printed inside each segment carries the value. That matters because adjacent
  steps of one ramp sit ~1.4:1 apart, nowhere near enough to be the only cue.
- The diverging pair has to be symmetric in WEIGHT, and a five-token chart
  palette is a lightness ladder — pairing any two raw tokens draws one side
  twice as heavy as the other. Build the two bases to land on the same contrast
  instead: negative = color-mix(in oklab, var(--chart-3) 80%, var(--card)),
  positive = color-mix(in oklab, var(--foreground) 12%, var(--chart-1)).
  Measured against the card: 4.59 vs 4.58 in light, 5.33 vs 5.65 in dark. Blue
  against amber is also the diverging pair that survives red-green colour vision
  deficiency.
- Steps get heavier outward by mixing toward var(--foreground) — never lighter
  toward the card. Mixing toward the foreground raises contrast in BOTH themes
  (it darkens on white, lightens on black), so "more extreme reads heavier"
  survives the theme flip and every step stays above 4.5:1 against the card,
  which is what lets one single label colour (text-card) sit on all of them.
  Measured ramp: 4.59 / 6.66 / 9.69 light, 5.33 / 7.22 / 9.58 dark.
- Neutral is var(--muted-foreground) plus a 45-degree hatch. The hatch is not
  decoration: on this palette neutral lands within 1.08:1 of the mildest
  coloured step, so a greyscale printout could not otherwise separate them.
- In-segment labels are gated by a container query ON THE SEGMENT — the only box
  that knows how wide that share came out — with thresholds taken from the
  MEASURED width of each label at text-[10px] with tabular-nums plus 2px of air
  (19.6px for "9%", 25.1px for "10%", 30.7px for "100%", 33.5px for "10.3%").
  Do not compute those from a character count; a hand-written width estimator is
  wrong by tens of percent. Below its threshold a number is not drawn clipped,
  it is not drawn at all, and it is still in the row totals and the data table.
  The neutral band never carries an in-band number: the axis line runs through
  its middle and would slice the digits in half.
- The axis is a 1.5px line in var(--card) painted OVER the segments, so it reads
  wherever a bar covers it (every fill clears 4.5:1 against the card) and a
  dashed muted line underneath marks the centre of an empty track. Gridlines at
  ±axisMax/2 sit under the bars; five tick labels (±max, ±half, 0) sit in the
  track column, the outer two pulled fully inside instead of hanging out.
- Accessibility in two layers, because recharts-style path soup reads as
  nothing: an sr-only paragraph states the finding (question count, scale ends,
  the base, the neutral policy, the shared range, pooled totals, the most and
  least positive question), and the exact numbers live in a real sr-only table
  with a row header per question and a column per scale point. The visual plot
  is aria-hidden so nothing is announced twice. Put the sr-only class on a
  wrapping div, never on the table: for a table box a CSS width is only a
  minimum, so .sr-only cannot shrink it and the full-width table pushes the page
  into horizontal scroll.
- Motion is decorative: segment left/width transition when the base or the sort
  changes, the skeleton pulses, both switch off under motion-reduce. Nothing
  about reading the chart depends on either. No timers, no rAF, no
  ResizeObserver — responsiveness is percentages and container queries, so the
  first paint is already right.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
  text-destructive, ring, text-card for the in-bar numbers, var(--chart-1),
  var(--chart-3), var(--muted-foreground) and color-mix over --foreground /
  --card. No hex, no raw oklch.

Customization levers
- neutralMode: "split" when the audience wants each bar to be a complete 100%
  of the sample, "aside" when the comparison that matters is where disagreement
  ends. Nothing else about the chart changes with it, and the explanatory line
  under the plot rewrites itself either way.
- base + showBaseToggle: leave the toggle on for an exploratory dashboard; pin
  a base and drop the toggle for a printed report. Pass `base` to hoist the
  choice into the host (to keep several charts in step) and it becomes a
  controlled prop with onBaseChange.
- sort: "given" respects questionnaire order — often the honest default, since
  section order is data. "net-desc" turns the same card into a ranked "what is
  working" readout.
- decimals: 0 for a summary, 1 when small tails matter (it is the apportionment
  grid too, so the row still totals exactly 100.0%).
- Density: barHeight 10-12 with segmentLabels={false} fits twice as many
  questions in a deck; 18-24 with labels is the reading layout. The grid
  templates (9rem / 14rem label column) are the first thing to widen for long
  questions.
- Palette: re-point the two bases at any two --chart-* tokens, but re-measure —
  the pairing is chosen for equal contrast against the card, not for taste, and
  keep the ramp mixing toward --foreground so it survives the theme flip.
- Scale shape: the same contract draws a four-point forced choice (no midpoint —
  the base toggle hides itself), a five-point agreement scale or a seven-point
  one; nothing is hard-coded to five. For a separate "don't know" bucket, fold
  it into the neutral point or drop it upstream — two neutrals have no
  unambiguous split.
- Add-ons: wire onClick on a row to drill into a question, or lift the row order
  out through a callback to keep a sibling table in the same order.

Concepts

  • Diverge around the neutral point — the axis is not a zero, it is the middle of an ordinal scale. Options are stacked outward from it, so the mildest step always touches the centre and the most extreme one always ends the bar; a reader gets direction from which side a colour sits on and strength from how far it reaches, before reading a single number.
  • Split versus aside — the one genuine argument this chart type has. Splitting the neutral band keeps every bar a whole 100% but moves the disagree/agree boundary half a band off the axis, differently in every row; lifting it out of the bar makes the axis exactly that boundary but leaves bars that no longer add up. The component takes a prop and prints the consequence under the plot rather than choosing for you.
  • Two denominators, one picture — "of everybody" and "of those with an opinion" are different questions, and switching between them can move a row's net by tens of points. The neutral count stays printed beside the question in both, because a base that quietly removes a third of the sample is how a chart lies without a single wrong number.
  • Apportioned numbers drive the pixels — shares are rounded by largest remainder so a row totals exactly 100, and the bar is drawn from those same rounded numbers, so the printed total and the drawn extent can never drift apart. The trade is visible at zero decimals: four identical tiny slices print as 1 / 0 / 0 / 0.
  • One shared symmetric axis — every row is measured against the same ±max, rounded up from the widest extent on either side. Symmetric, or equal shares on the two sides draw unequal lengths; shared, or the rows are six unrelated pictures. One unanimous question legitimately pushes the whole chart to ±100%.
  • Ramp that survives the theme — steps get heavier outward by mixing toward the foreground, which raises contrast on a white card and on a black one alike. That keeps "more extreme reads heavier" true in both themes and keeps every step above 4.5:1 against the card, so one label colour works on all of them.
  • The number lives where it fits — each segment is its own container query, so a share is printed inside it exactly when its own width can hold the measured label, never clipped to fit. Everything left out stays in the row totals and in the screen-reader table.

On This Page