Charts

Lollipop Chart

A four-state lollipop chart — one hairline and one dot per category on a shared scale, ranked, with an optional reference baseline and a second dimension carried by dot size or shade.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Bar, BarChart, CartesianGrid, Label, ReferenceLine, XAxis, YAxis } from "recharts"

import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import type {
  ChartLollipopData,
  ChartLollipopEncode,
  ChartLollipopItem,
  ChartLollipopSort,
} from "./chart-lollipop.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartLollipop" card on the shadcn chart
primitives (ChartContainer / ChartTooltip over recharts) with zod. One category
per row: a hairline from a baseline to the reading, and a dot at the reading.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string; caption?: string;
    valueLabel: string; unit?: string;
    weightLabel?: string; baseline?: number;      // baseline defaults to 0
    items: { id: string; label: string; value: number; weight?: number }[] }
- value is THE measurement — a lollipop has exactly one per category. weight is
  the optional second dimension the dot carries; omitting it means "not
  measured", which is not 0.
- Plain z.number() is enough for the finiteness guard ON ZOD 4: verified against
  4.4.3, NaN / Infinity / -Infinity are all refused with no extra refinement, and
  that matters because one infinite reading swallows the whole domain and
  flattens every other row onto the baseline. On zod 3 the same schema accepts
  Infinity and needs .refine(Number.isFinite). Either way the component keeps its
  own runtime filter, since props reach it without ever being parsed.
- superRefine refuses at parse time: "ready" with zero rows (that state is
  called "empty"), duplicate ids, and any item carrying `weight` while
  `weightLabel` is missing — an unnamed second dimension cannot be named in the
  legend, the tooltip or the data table, so the dot sizes would mean something
  only the author knows.
- Props = z.infer of the schema plus sort (default "desc"), encode
  ("none" | "size" | "color", default "none"), rowHeight (30, clamped 18-72),
  labelWidth (140, clamped 56-320), labelLines (1 | 2, default 1), formatValue,
  onRetry and className. Every numeric prop is clamped through one helper, so
  NaN / Infinity / a negative row height all land on a legal value instead of
  producing a zero-height chart.
- Export the model builder next to the component (buildLollipop) and the tick
  generator (niceTicks). ONE pass produces the order, the scale and both
  encodings, so the plot, the legend, the footnotes, the tooltip and the
  sr-only table all read the same array — there is no second sort that could
  disagree with the first.

Behavior
- SORT IS A FIRST-CLASS PROP, not a convenience. The reason to draw a line and a
  dot instead of a filled bar is that rank is read off *position in the list*,
  and that only works if the list is ordered on purpose. Modes: "desc"
  (default), "asc", "magnitude" (distance from the baseline, largest first),
  "label", "none" (contract order). Ties break on label then id, so two renders
  of one feed are identical and the printed ranks match the plot. On a chart
  with a non-zero baseline, "magnitude" is normally the right default: "desc"
  stacks everything above the reference at the top and buries the worst
  shortfall at the very bottom of the list.
- STEMS ARE RANGE BARS. Feed recharts `span: [baseline, value]` and a custom
  Bar `shape`. recharts turns a two-element value into x = scale(baseline) and a
  SIGNED width = scale(value) - scale(baseline), so `x + width` is the reading
  end whichever side of the baseline it falls on and the shape never has to
  infer a direction. A custom shape also stops recharts filtering out
  zero-dimension rectangles, which is what keeps a reading that sits exactly on
  the baseline drawn as a lone dot instead of vanishing.
- THE BASELINE RULE IS DRAWN ONLY WHEN IT SAYS SOMETHING. A baseline of 0 with
  every reading above it is already the "0" tick and the left end of every stem;
  a second dashed line plus a legend entry for it is furniture. Draw it when the
  baseline is non-zero, or when some reading falls below it. Note that a bounds
  test alone is not enough: the padded domain (below) puts 0 strictly inside on
  every ordinary chart, so it would print that furniture every time.
- SCALE. Fit [min, max] over every reading and the baseline; if they coincide,
  open a symmetric window instead of dividing by zero. Then pad the domain by
  (maxRadius + 2) px converted into data units through the MEASURED plot width,
  so a dot at either extreme is drawn whole instead of hard against the name
  lane. Ticks come from niceTicks over the UNPADDED range — smallest 1 / 2 / 5 x
  10^n step, generated as first + i * step, never by repeated addition, because
  accumulating a float step drifts and a gridline 4e-16 off its own label is a
  gridline in the wrong place.
- THE SECOND DIMENSION, two encodings, one rule: colour and size are for the
  SECONDARY measure only. Position stays the primary channel.
    size  — dot AREA is proportional to weight
            (r = sqrt(rMin^2 + t * (rMax^2 - rMin^2)), 3.5px to 9px). Scaling
            the radius linearly instead would quadruple the apparent difference.
    color — weight binned onto four steps of
            color-mix(in oklab, var(--chart-1) X%, var(--foreground)),
            X from 100 down to 55. This is a LIGHTNESS ramp inside one hue, not
            a walk across the five-hue palette: every step moves further from
            the card in both themes, so the order survives greyscale and the
            strongest step is never the least legible one. Measured against the
            card: 3.63 / 4.84 / 6.58 / 8.91 in light, 4.64 / 5.95 / 7.48 / 9.36
            in dark.
  A requested encoding with no weighted row falls back to "none" rather than
  printing a legend for a dimension that is not in the data.
- MISSING IS NOT ZERO. A row with no weight draws a HOLLOW ring at the neutral
  size (fill = card, stroke = chart-1), gets its own legend entry, prints "not
  measured" in the tooltip and the table, and is counted in a line under the
  chart. Sizing it to the minimum would say "smallest", which is a reading
  nobody took.
- NON-FINITE VALUES are dropped, counted and disclosed under the chart. Coercing
  them to 0 invents a reading; keeping them destroys the domain.
- FOUR STATES are first-class branches of one bg-card panel: a pulsing skeleton
  whose stems descend like a real ranking (so nothing shifts when the data
  lands), an empty state drawn from outlined lollipops, an error state with a
  "Try again" button only when onRetry exists, and ready. A "ready" payload
  whose rows were all unusable degrades to the empty branch.
- HEIGHT is rows x rowHeight plus the axis strip, and the category axis uses
  interval={0}. Rows are never dropped, thinned or hidden behind a scroll box:
  forty categories make a tall card, not a truncated one.

Rendering & styling
- CATEGORY NAMES ARE MEASURED BY THE BROWSER, never by a character count. Each
  name is laid out inside a foreignObject exactly one lane wide with CSS
  truncate (or line-clamp-2 at labelLines={2}). Measured on this component's own
  strings at 11px, a 7.2px-per-character estimator is 36% UNDER on a CJK run
  ("用户登录与身份验证服务": 123.7px measured, 79.2px estimated) and up to 44%
  OVER on ordinary latin prose ("Billing and invoices": 100.2px measured,
  144px estimated). The error is not even monotone by script, so it cannot be
  corrected for — only the browser can measure text.
- Two label details are load-bearing:
    * line-clamp only ellipsises at the LINE boundary, so a single unbreakable
      token (measured 247px against a 132px lane) overflows line 1 horizontally
      and is cut mid-glyph with NO ellipsis — the reader gets no sign the name
      was shortened. Add overflow-wrap:anywhere so it breaks across the two
      lines and the clamp has something to ellipsise.
    * a one-line name inside a two-line lane renders against the top of the box
      and sits half a line (measured 7.5px) above the stem it names. Centre the
      content inside the lane, not the lane itself.
  Alignment is a legibility choice and not a truncation one: measured in Edge,
  text-align right and left produce the same tail ellipsis and the same
  first-glyph offset. Whatever is cut is still whole in the tooltip, the title
  attribute and the data table.
- LANE WIDTH is labelWidth clamped against the MEASURED container so the plot
  never falls under 96px — a card narrow enough to force the choice is more
  useful with short names and a readable scale than with whole names and no
  scale. Measure with a ResizeObserver attached through a CALLBACK REF: the plot
  only exists in the ready branch, so an effect that ran once on mount would
  observe nothing on a card that starts in loading.
- Pass tickSize={0} on the category axis. recharts' default is 6 and it shifts
  the tick text even when the tick line is hidden, quietly eating 6px of lane.
- Semantic tokens only: bg-card, border, text-muted-foreground, stroke-muted-
  foreground for the stems (at 0.5 opacity, so the dot stays the loud mark),
  var(--card) for the dot's halo and the hollow fill, var(--chart-1) for the
  dot. No hex, no raw oklch, no invented hue. --chart-* is a fill here and never
  a text colour.
- ACCESSIBILITY: ChartContainer carries role="img" plus an aria-label naming the
  count, the ordering, the domain, the baseline, the highest and lowest rows
  with real numbers and the second dimension's range. BarChart sets
  accessibilityLayer={false}: recharts 3 would otherwise put a tabindex="0" svg
  inside a children-presentational subtree, i.e. a tab stop with no name. The
  real data goes in a table wrapped in a `div.sr-only` — never sr-only on the
  table itself, since CSS width is only a lower bound for a table box and it
  will drag a narrow page into horizontal scroll.
- MOTION: recharts animates in JS, so motion-reduce: classes cannot reach it.
  Read prefers-reduced-motion with useSyncExternalStore (server fallback false)
  and pass isAnimationActive={!reduced}. Verified: with reduced motion the dots
  are at their final coordinates on the first frame and identical to the settled
  animated ones. The skeleton pulse carries motion-reduce:animate-none.

Customization levers
- sort: "desc" for a ranking, "magnitude" whenever baseline is a real reference,
  "label" for a lookup list, "none" when the caller already ranked the rows. It
  only reorders — no number moves.
- baseline: 0 for a plain ranking; a target, an SLA, last quarter's figure or a
  fleet average turns the same component into a diverging chart where the sign
  of every row is readable without arithmetic.
- encode: "size" when the second measure is a magnitude (sample size, revenue,
  traffic); "color" when it is a rate or a score; "none" to keep the card quiet.
- rowHeight + labelWidth + labelLines: 30 / 140 / 1 is the comfortable default;
  36 / 140 / 2 buys long names a second line; 18 / 80 / 1 turns the card into a
  dense forty-row ranking.
- formatValue: currency, compact notation or a per-locale Intl.NumberFormat. It
  feeds the axis, the tooltip, the footnotes, the summary and the table
  together, so they can never disagree.
- Palette: re-point the dot token (--chart-2, --chart-4) and the size legend,
  the colour ramp and the tooltip swatch all follow, because the ramp is
  generated from that one token rather than enumerated.
- Density knobs that change nothing else: the 4-tick target, the 2px stem, the
  0.5 stem opacity, and the 3.5-9px dot radius range.

Concepts

  • Sort as a reading channel — a lollipop trades the filled area of a bar for a hairline, and what pays for that ink is the ordering: with the list ranked, "who is biggest" is answered by position alone and nobody has to compare two lengths. That is why sort is a contract-level idea here rather than a display option, and why magnitude exists — on a diverging chart, ordering by value hides the worst row at the bottom.
  • Baseline versus zero — stems always grow from baseline, but the dashed rule is only drawn when it carries information: a non-zero reference, or a reading that falls below it. Because the domain is padded for the dot radius, zero is always strictly inside the plot, so a naive "is it in range" test would print a redundant rule and legend entry on every ordinary chart.
  • Signed range bars — the stem is a recharts range bar fed [baseline, value]; recharts returns a signed width, so x + width is the reading end whether it lies left or right of the rule. Using a custom shape also opts out of recharts' zero-dimension filter, which is what lets a reading that sits exactly on the baseline paint as a lone dot rather than disappear.
  • Area, not radius — under encode="size" the dot's area is proportional to the second measure. Mapping the measure to the radius instead squares the difference, so a value twice as large would look four times as heavy; the sqrt keeps the ink honest.
  • A lightness ramp, not a hue walk — under encode="color" the four bins are one chart token mixed toward --foreground, so each step sits further from the card surface in both themes. An ordered measure needs an ordered ramp: walking across five different hues would encode "more" as "different" and collapse in greyscale.
  • Not measured is not zero — a row without a second reading draws a hollow ring at the neutral size, keeps its own legend entry, and is counted in a line under the chart. Sizing it to the minimum, or back-filling it with 0 upstream, is the usual way a two-dimensional dot chart starts lying.
  • The browser measures the text — every category name is laid out inside a foreignObject and truncated by CSS, because a character-count estimator measured 36% under on a CJK run and 44% over on latin prose, and the error does not go the same way twice. The lane is sized from the observed container so the plot keeps at least 96px; anything cut survives in the tooltip and the data table.

On This Page