Data visualization
A chart is read by people and rendered by machines. Choose the form first, assign color by task, and check the palette with a tool instead of by eye before you draw.
Choose a chart form
Color comes last. First ask what the reader needs to do with the data: compare magnitudes, tell series apart, see positive and negative values, or read a single number?
| Task | Default form | Job of color | In Yugen |
|---|---|---|---|
| One current value (+ trend) | A stat tile, not a one-column chart | — | StatTile |
| Compare magnitudes | Columns; horizontal bars for many categories / long names | One color (slot 1) | BarChart |
| Trend over time | Line; area if there is only one series | One color or categorical | LineChart AreaChart |
| Tell several series apart | Grouped / stacked columns, several lines | Categorical | BarChart LineChart |
| One series is the focus | Highlight: one color, the rest gray | 1 color + gray | highlight |
| Above / below zero | Diverging columns | Diverging (2 poles + gray midpoint) | diverging |
| Part of a whole | Stacked columns; donut with ≤ 6 slices for a quick read | Categorical | stacked DonutChart |
| Relationship between two measures | Scatter; bubbles when there is a third dimension | One color; ≤ 3 categorical groups | ScatterChart |
| Magnitude on a 2D grid (hour × day, cohorts) | Heatmap | Sequential; diverging when there is a midpoint | Heatmap |
| More than ~7 equally important groups | Table (or table + chart) | — | Table |
Series count: with 1–3 series, color alone is enough; 4 series require direct labels; 5–6 is the soft limit (legend or small multiples); 7–8 is the token limit. A 9th series never gets a new color: merge it into “Other”, split into small multiples, or combine encodings.
The answer is one number
The story is one series
Palette & tokens
Each color does one job: categorical (identity), sequential (magnitude), diverging (sign), status (good/bad, reserved). Yugen's categorical palette starts with Sakura in a fixed order, and that order is what keeps it safe for color-blind readers.
Light · surface #FFFFFF
- 1Sakura 桜#C2347A5.16:1
- 2Ai 藍 · indigo#2F6FD04.88:1
- 3Kohaku 琥珀 · amber#B783003.35:1
- 4Fuji 藤 · wisteria#7A5AD64.93:1
- 5Matcha 抹茶 · jade#0E9F8A3.31:1
- 6Kaki 柿 · persimmon#D65A223.93:1
- 7Sora 空 · sky#0A8FB83.73:1
- 8Koke 苔 · moss#4E8A1E4.22:1
Dark · surface #292929
- 1Sakura 桜#E24A923.90:1
- 2Ai 藍 · indigo#4A8EEB4.41:1
- 3Kohaku 琥珀 · amber#C084004.53:1
- 4Fuji 藤 · wisteria#9079E64.20:1
- 5Matcha 抹茶 · jade#14A08A4.45:1
- 6Kaki 柿 · persimmon#E0662E4.23:1
- 7Sora 空 · sky#22A4C64.98:1
- 8Koke 苔 · moss#5FA0324.55:1
Dark is not an automatic inversion: the same 8 hues each get their own step for dark surfaces (OKLCH L 0.48–0.67) and are checked again on #292929. In the High contrast theme every mark turns white, and identity moves to 45° / 135° hatching (columns, slices) and dashes (lines).
| Check (validate_palette.js) | Light · #FFFFFF | Dark · #292929 |
|---|---|---|
| OKLCH lightness band | PASS: all 8 within 0.43–0.77 | PASS: all 8 within 0.48–0.67 |
| Chroma ≥ 0.10 | PASS | PASS |
| Red–green color blindness, adjacent pairs (ΔE ≥ 8) | PASS: worst 12.9 (Kaki ↔ Matcha, deutan) | PASS: worst 12.8 (Kaki ↔ Matcha, protan) |
| Normal vision, adjacent pairs (ΔE ≥ 15) | PASS: worst 19.8 (Koke ↔ Sora) | PASS: worst 19.0 (Koke ↔ Sora) |
| Contrast with the surface ≥ 3:1 | PASS: 3.31 … 5.16 | PASS: 3.90 … 4.98 |
| All pairs (scatter, maps) | First 3 colors: ΔE 14.4, so 3 series at most | First 3 colors: ΔE 13.8, so 3 series at most |
# From the dataviz skill folder: run once per mode, on the chart's actual surface
node scripts/validate_palette.js "#c2347a,#2f6fd0,#b78300,#7a5ad6,#0e9f8a,#d65a22,#0a8fb8,#4e8a1e" --mode light --surface "#ffffff"
node scripts/validate_palette.js "#e24a92,#4a8eeb,#c08400,#9079e6,#14a08a,#e0662e,#22a4c6,#5fa032" --mode dark --surface "#292929"
# "All pairs" forms (scatter, maps, small multiples): first 3 colors only
node scripts/validate_palette.js "#c2347a,#2f6fd0,#b78300" --mode light --surface "#ffffff" --pairs allLight
Dark (reversed anchor)
Sequential is a single Sakura hue from light to dark (in dark the anchor is reversed, so small values sink into the background). For discrete ranks (funnels, rankings), use only steps 3–7 so the lightest step stays at ≥ 2:1 (checked with --ordinal). Diverging runs Sakura ↔ Ai with a neutral gray midpoint, never a hue. Status colors (success / warning / danger) are not part of the categorical palette and always come with an icon and text.
/* src/styles/charts.css — use in SVG via style={{ fill: "var(--…)" }} */
--colorChartCategorical1 … 8 /* series identity, fixed order */
--colorChartSequential1 … 7 /* magnitude, a single Sakura hue (ordinal: steps 3–7 only) */
--colorChartDivergingNeg / Mid / Pos
--colorChartOther /* "Other", background series */
--colorChartSurface --colorChartGrid --colorChartAxis --colorChartCrosshair
--colorChartLabel --colorChartLabelStrong
--chartAreaOpacity --chartDimOpacityMarks & spacing
The quiet, deliberate look comes from a few fixed settings and two kinds of white space. Only the data gets to be loud.
| Mark | Spec |
|---|---|
| Column / bar | ≤ 24px (doesn't fill the slot), 4px rounding at the data end, square at the baseline, grows from a single baseline |
| Line | 2px, round caps and joins |
| Dot / end point | ≥ 8px, series color, 2px border in the surface color |
| Area | Series color at ~10% (dark 14%): a light wash, not a solid block |
| Grid / axis | Thin 1px, solid (never dashed), one step off the surface |
| Surface gap | 2px between all marks that touch: stacked segments, adjacent columns, donut slices. No outline around marks |
| Labels | Selective: end points, extremes, the focus series. Text always uses text tokens, never the series color |
Interaction
HTML/SVG charts are interactive by default. The tooltip adds detail; it is never the only way to read a value.
- Line / area: a vertical crosshair snaps to the nearest X value; one tooltip lists every series at that point.
- Column / slice: the mark itself is the hit area (wider than the mark, gap included); the selected mark stands out and the others dim.
- In the tooltip, the value is bold and comes first; the series name is secondary, keyed by a short stroke in the series color (not a filled square). The legend mirrors the mark: a square for columns / areas, a stroke for lines.
- On multi-series charts, legend items are toggle buttons; the remaining series keep their colors (color follows the entity, not its rank).
- Filters (date range, dimension) sit in a row above all the charts they affect, not inside a chart card.
| Key | Behavior |
|---|---|
| Tab | Enter the chart (a single tab stop); the tooltip shows for the focused point. |
| ←→↑↓ | Previous / next point (the plot area is always LTR, so → moves forward, even on RTL pages). |
| HomeEnd | First / last point. |
| Esc | Hide the tooltip. |
Accessibility
Identity never relies on color alone: there is always a legend with ≥ 2 series, direct labels with ≤ 4, and every chart has an equivalent data table.
- The frame is a
figurenamed by its title; the SVG plot area has anaria-labelthat summarizes the data; each focusable data point isrole="img"labelled “category, series: value”. - The Bảng dữ liệu (Data table) button switches to a
tablewith column and row headers, the WCAG-conformant equivalent of the chart. - The High contrast theme and
forced-colorsturn on hatching / dashes automatically; thetextureprop turns them on manually (print, complete color blindness). - Numbers and dates are formatted with Intl using
locale(default vi-VN): compact axis labels (1 N / 2,5 Tr in vi-VN, 1K / 2.5M in en-US), full values in tooltips and the table. - The plot area is always
dir="ltr"(the time axis does not flip); the title, legend and tooltip follow the page's text direction. - Entry animations (columns growing from the baseline, lines drawing in) are turned off entirely with
prefers-reduced-motion.
| Month | Online | In store |
|---|---|---|
| Jul | 510 | 325 |
| Aug | 488 | 340 |
| Sep | 560 | 352 |
| Oct | 615 | 360 |
Common mistakes
If a chart matches an item on the right, it is wrong. Fix it before you ship.
- Two measures on different scales → two charts, small multiples, or both indexed to a common base on one axis.
- Color by entity: when some series are filtered out, the rest keep their colors.
- The story is one number → StatTile; one series → highlight it and gray out the rest.
- Thin 1px solid grid lines and axes that stay in the background; thin columns with room around them.
- A label that doesn't fit inside the mark goes past the end of the column or into the tooltip; the value is still in the table.
- Run validate_palette.js on every new palette, in both light and dark.
- Dual Y-axis charts. They suggest correlations that don't exist.
- Generating a 9th color, or cycling colors past 8 series.
- A rainbow for sequential data; a hue at the diverging midpoint; two cool-toned poles.
- Status colors used for “series 4”, or series colors used to signal status.
- Donuts to compare close values, 2-slice pies, single-column charts.
- Numbers on every point, dashed grid lines, outlines around marks, a tooltip as the only way to read values.