VibeWeek
Home/Grow/Charts & Data Visualization

Charts & Data Visualization

⬅️ Day 6: Grow Overview

Most B2B SaaS products end up needing charts: usage dashboards, revenue analytics, funnel visualization, time-series metrics. The naive approach: drop in Chart.js, ship a default-styled bar chart, move on. The structured approach: pick a chart library that matches your needs (Recharts / visx / Apache ECharts / D3 / Tremor), implement accessibility, handle empty states + loading, lazy-load heavy bundles, and choose chart types that match the data story. This guide covers the implementation craft — not the data-strategy side (see customer-analytics-dashboards-chat.md for that).

1. Pick the right charting library

Help me choose a charting library for a [STACK] B2B SaaS in 2026.

Tier 1: React-first managed libraries
- Recharts (declarative React; SVG; ~50KB; good for line/bar/area)
- visx (Airbnb; D3 + React; modular; SVG; more control)
- Tremor (built on Recharts; opinionated dashboard components; uses Tailwind)
- Nivo (responsive D3 React; rich gallery; bigger bundle ~80KB+)

Tier 2: Framework-agnostic
- Apache ECharts (huge feature set; Canvas; ~500KB but feature-dense; great for complex viz)
- Chart.js (Canvas; simple; ~70KB; v4 with tree-shaking)
- ApexCharts (modern feature set; SVG; ~200KB)

Tier 3: Power tools
- D3.js (low-level toolkit; build anything; steepest learning curve)
- Plotly (scientific charting; interactive; large bundle)

Tier 4: Specialized
- TanStack Table for tabular data (not charts but sister UX)
- React-financial-charts for OHLC / candlestick
- Deck.gl for geo / 3D / large datasets

Decision factors:
1. Stack: React-first vs framework-agnostic
2. Bundle size sensitivity: <100KB → Recharts/visx; OK with 200KB → ECharts
3. Chart variety: standard (line/bar/pie) → Recharts; complex (heatmaps/sankey/network) → ECharts/D3
4. Customization needs: high → visx/D3; low → Tremor/Recharts
5. Performance: large datasets (>10K points) → Canvas (ECharts/Chart.js) over SVG

Recommendation tree:
- React + standard charts + tight bundle → Recharts
- React + dashboard + Tailwind → Tremor (ships charts + KPI cards + filters)
- React + complex viz + control → visx or D3
- Vue/Svelte/multi-framework → ECharts
- Scientific / OHLC / 3D / huge → specialty

Output:
1. Recommended library
2. Bundle-size impact (gzip)
3. Sample component
4. Accessibility status (out-of-box)
5. Server-side rendering compatibility

The 2026 shift: Tremor ships an opinionated dashboard system on top of Recharts. If you're building a customer-facing analytics dashboard, Tremor saves a week of styling work.

2. Choose the right chart for the data

The single highest-leverage decision in dataviz: chart type. A wrong type makes the data harder to read, even if the chart is "pretty."

Pick the right chart type for a data story.

Comparison (categorical):
- Bar chart (vertical or horizontal) — default for category comparison
- Don't use pie/donut for >5 categories — it becomes unreadable
- Stacked bar for parts-of-whole across categories

Time series:
- Line chart — default for trends over time
- Area chart — line with fill (emphasizes magnitude)
- Stacked area — multiple series cumulative
- Don't use pie for time data; never

Distribution:
- Histogram — single variable distribution
- Box plot — quartiles + outliers
- Violin plot — denser distributions
- Density / kernel density — smooth distribution

Correlation:
- Scatter plot — two variables
- Bubble chart — three variables (x, y, size)
- Heatmap — matrix correlation

Composition:
- Stacked bar / area — sum + parts
- Treemap — hierarchical proportions
- Sunburst — hierarchical with radial layers

Funnel / flow:
- Funnel chart — sequential drop-off (caution: misleading proportions)
- Sankey diagram — flow between states
- Bar chart often works better than funnel chart

Geographic:
- Choropleth — region color-coded
- Symbol map — points sized/colored
- Heatmap on map

Anti-patterns:
- 3D charts (perspective distorts comparison)
- Pie chart with >5 slices
- Dual-axis line chart (often misleading)
- Funnel chart when bar chart suffices

For my data [DESCRIBE], recommend:
1. Best chart type
2. Chart type to AVOID for this data
3. Specific library implementation
4. Color choices for the data domain

The "stop pie-charting" rule: pie charts only work for 2-5 categories where parts of a whole matter. Almost everything else is better as a bar chart. Most products would improve their dashboards by replacing every pie chart with a horizontal bar chart.

3. Color — accessible + meaningful palettes

Default chart colors are usually wrong. Plan color intentionally.

Build accessible chart color palette.

Categorical (qualitative) — distinct categories with no order:
- Use 6-10 hues from ColorBrewer Set2 or Tableau 10
- Avoid red+green (colorblind issues — use red+blue or orange+blue)
- Tailwind color palette: blue-500, emerald-500, amber-500, violet-500, etc.

Sequential (ordinal) — quantity / intensity:
- Light → dark in single hue (Blues, Greens, Reds)
- ColorBrewer "Blues" 9-step is gold standard
- Avoid rainbow / spectral (perceptually misleading)

Diverging (signed) — values around midpoint:
- Two-hue ramp (red ← white → blue)
- ColorBrewer RdBu, RdYlBu, BrBG
- Center color = neutral midpoint

Accessibility requirements:
- WCAG AA: 4.5:1 contrast for text on chart elements
- Colorblind-safe: use Coblis simulator to check
- Don't rely on color alone: add patterns, labels, or shapes
- Dark mode: separate palette (don't just invert)

Brand alignment:
- Use brand color for primary metric only
- Secondary colors from ColorBrewer / Tailwind
- Don't make every chart "branded" — readability suffers

Library tools:
- d3-scale-chromatic for ColorBrewer ramps
- chroma.js for color manipulation
- Tremor / Tailwind tokens

Output:
1. Categorical 8-color palette (light + dark mode)
2. Sequential 9-step palette
3. Diverging 11-step palette
4. Accessibility-checked combinations (axe-core / Coblis verified)
5. Tailwind config snippet for custom palette

The colorblind defense: ~8% of men and 0.5% of women have some color-vision deficiency. Red/green color schemes for "good/bad" indicators fail them. Use Coblis (color blindness simulator) on every chart before shipping.

4. Empty states, loading, error states

Most chart components fail at the edges. The data isn't always there.

Build chart edge-case states.

Loading state:
- Skeleton placeholder with rough chart shape
- Same dimensions as final chart (no layout shift)
- Subtle shimmer animation
- Accessible: aria-busy="true" + announce "loading chart"

Empty state (no data):
- Don't render an empty chart with axis lines
- Show illustration or icon + helpful text: "No data yet. Try a different date range."
- Provide CTA if applicable: "Connect a data source" / "Run a campaign"

Partial data:
- Show what you have; gray-out missing series
- Tooltip explanation: "No data for this range"

Error state (API failure):
- Don't show last cached chart with stale data warning
- Show error message + Retry button
- Log error for monitoring

Insufficient data (e.g., 1 datapoint for a line chart):
- Switch chart type (single value → big number; 2-3 points → bar)
- Or show "Need at least N points" message

Stack: React + [LIBRARY: Recharts / Tremor / etc.].

Output:
1. Loading skeleton component
2. Empty-state component with illustration
3. Error-state with retry
4. Data-too-sparse fallback (auto chart-type switch)
5. Accessibility: aria-live for state changes

The data-sparsity edge case is silently broken in most products: a line chart with 1 datapoint renders as an empty grid. The fix is either auto-fallback to a number display or a "Need more data" empty state.

5. Tooltips, legends, axis formatting

Default chart tooltips show raw numbers. Polish them.

Polish chart tooltips, legends, and axis labels.

Tooltips:
- Show on hover (touch: tap-to-show; tap-elsewhere to dismiss)
- Display formatted values:
  - Numbers: 1,234,567 (thousands separator)
  - Currency: $1,234.56 with 2 decimals
  - Percentages: 12.3%
  - Dates: "Mar 15, 2026" not raw timestamps
  - Durations: "2h 15m" not "8100 seconds"
- Hover focus: highlight the data point or series
- Multi-series: show all series at hovered X

Legends:
- Click legend entry to toggle series visibility
- Show only relevant series (don't show "Hidden" placeholder series)
- Position: top or right (avoid bottom in dashboards)
- Truncate long labels with ellipsis + tooltip on hover

Axis formatting:
- Format large numbers: 1.2K, 3.4M, 5.6B (avoid 1234567)
- Date axis: smart tick intervals (day for 7d range, week for 30d, month for 1y)
- Rotate long category labels (45° avoid label collisions)
- Truncate long labels + tooltip on hover
- Don't show every tick — use 5-10 evenly spaced

Number formatting library:
- Intl.NumberFormat (built-in; localized)
- d3-format (more control)
- numeral.js (legacy)

Localization:
- Different locales: 1,234.56 (US) vs 1.234,56 (DE)
- Use Intl.NumberFormat with user locale
- Date formatting: same approach (Intl.DateTimeFormat)

Output:
1. Reusable tooltip formatter functions (number, currency, %, duration)
2. Legend component with click-to-hide
3. Smart axis tick formatter (auto K/M/B + dates)
4. Locale-aware number/date format
5. Accessibility: tooltip readable by screen reader

The micro-polish that separates "looks like a developer made it" from "looks like a designer made it": axis labels formatted as "1.2K" not "1234.56789". Two lines of code; visible difference.

6. Performance — virtualize, downsample, memoize

For 10K+ data points, a chart can drop frames during pan/zoom.

Optimize chart performance.

For <1K points: no special treatment needed; SVG works fine.

For 1K-10K points:
- Switch to Canvas-based library (Chart.js, ECharts)
- Or use react-window for series with many tooltips

For 10K+ points:
- Server-side downsampling (LTTB algorithm — Largest Triangle Three Buckets)
- Render aggregated buckets at zoom-out; raw points at zoom-in
- WebGL libraries (Deck.gl, regl) for million-point datasets

Specific techniques:
- Memoize data transforms (useMemo on aggregated data)
- Memoize chart props (don't pass new objects every render)
- Debounce chart updates on prop change (e.g., date range)
- Lazy-load chart library (dynamic import; no SSR for charts)
- Server-render aggregates, client-render details

LTTB downsampling:
- Reduces N points to M target points (e.g., 100K → 1K)
- Preserves visual shape
- npm: downsample-lttb or similar

Bundle size:
- Tree-shake D3 (don't import d3 wholesale; import d3-scale, d3-shape separately)
- Lazy-load heavy charts (dynamic import for dashboard pages)
- Use Recharts/visx (smaller) over Nivo (larger) when comparable

Monitoring:
- Web Vitals INP for chart interactions
- Page-load TBT (chart libs blocking main thread?)
- Frame budget during pan/zoom (60fps = 16ms/frame)

For [N data points] in [CHART TYPE], output:
1. Recommended rendering strategy
2. Library choice (SVG vs Canvas vs WebGL)
3. Downsampling code if applicable
4. Lazy-loading pattern
5. Performance budget + monitoring

The "100K points" red line: SVG charts above 100K points are sluggish. Canvas works to ~1M; WebGL beyond. Most B2B dashboards aggregate before display, so 100K isn't typical — but check.

7. Interactivity — zoom, pan, brush, drill-down

Static charts are 2010. Modern dashboards expect interactive exploration.

Add interactivity to charts.

Zoom / pan:
- Mouse wheel: zoom in/out
- Drag: pan
- Double-click: reset
- Touch: pinch-zoom + drag-pan
- Library support varies; ECharts and visx are best

Brush selection:
- Drag a horizontal range below time-series chart
- Selection updates main chart (focus + context pattern)
- Date-range filter outside chart syncs with brush

Click-to-drill-down:
- Click bar → navigate to filtered detail view
- Click point → show modal with breakdown
- URL state: filter context survives navigation

Crossfilter:
- Multi-chart dashboard where selecting in chart A filters chart B
- Implementation: shared state context (Zustand / Jotai / shared signal)
- Library: dc.js for crossfilter; or roll-your-own with React state

Real-time updates:
- WebSocket / SSE pushing new data points
- Smooth append (don't redraw whole chart on each tick)
- Decimate / sliding window (don't grow forever)

Output:
1. Interactive features for chart type [TYPE]
2. Library-specific implementation
3. URL-state for drill-down filters
4. Crossfilter implementation strategy
5. Real-time update pattern (animation + memory)

The brush-and-zoom pattern from financial charts (TradingView, Bloomberg) is widely valuable in B2B SaaS — let users pan through history without losing context. Most products skip it; adding it makes a dashboard feel premium.

8. Accessibility — screen readers, keyboard, contrast

Charts are notoriously inaccessible. Make yours not.

Make charts accessible.

Screen reader support:
- aria-label on chart container with summary ("Line chart showing revenue from Jan to Dec, peak in Q3")
- Hidden table fallback (visually hidden HTML table with the same data)
- aria-describedby pointing to summary text

Keyboard navigation:
- Tab into chart
- Arrow keys to navigate data points
- Enter / Space to drill down
- Library support: visx + react-charting offer keyboard nav out-of-box; Recharts requires custom

Color independence:
- Don't rely on color to differentiate series
- Use line patterns (dashed, dotted) or markers (circles, squares, triangles)
- High contrast mode: detect prefers-contrast and adjust

Animation respect:
- prefers-reduced-motion disables transitions
- Don't animate critical data changes (just appear)

Testing:
- VoiceOver (Mac) / NVDA (Windows)
- axe-core / Lighthouse audit
- Manual keyboard-only test
- Contrast: WCAG AA 4.5:1 for text; 3:1 for graphical objects

Common failures:
- Chart with no aria-label = screen-reader-invisible
- Color-coded legend with no alt indicator = colorblind-broken
- Animation that triggers seizures (flashing) = WCAG 2.3.1 violation

Output:
1. ARIA attributes for chart container
2. Hidden-table fallback markup
3. Keyboard navigation handlers
4. Color-independence strategy (patterns + markers)
5. Reduced-motion + prefers-contrast handling

The hidden-table pattern: render a <table> with class sr-only (visually hidden but screen-reader-readable) containing the chart's underlying data. Adds 5 lines of code; passes accessibility audits.

9. Server-side rendering and lazy loading

Heavy chart libraries hurt LCP and TTI. Plan SSR.

Optimize chart loading.

SSR considerations:
- Most chart libraries require window/document; break SSR
- Solutions:
  1. Dynamic import with ssr: false (Next.js)
  2. Render skeleton on server, hydrate chart client-side
  3. Generate static SVG server-side (visx supports; Recharts mostly client-side)

Lazy loading:
- Don't bundle charts in initial JS for landing pages
- Dynamic import on dashboard route (split bundle)
- React.lazy + Suspense fallback (skeleton)

Code splitting:
- Per-page or per-route chart bundles
- Tree-shake d3 (named imports only)

Image fallback:
- For email / PDF: server-render chart as PNG/SVG image
- libraries: visx server-rendering; or use a service like QuickChart

Output:
1. Dynamic import wrapper for chart components
2. Skeleton fallback during load
3. Server-render strategy (SVG / PNG / skeleton)
4. Bundle analysis (verify chart lib not in main bundle)
5. Email / PDF static export strategy

The lazy-load principle: dashboards are signed-in experiences. Marketing pages should not bundle Recharts. Use route-based code splitting; dashboards get Recharts, landing pages don't.

10. Building reusable chart primitives

The 4th time you copy/paste a chart wrapper, extract a primitive.

Build a reusable chart system.

Primitives:
- ChartContainer (handles loading, error, empty states + responsive sizing)
- ChartTitle (consistent typography)
- ChartTooltip (consistent format + accessibility)
- ChartLegend (consistent click-to-hide)
- ChartAxis (consistent number formatting)

Composition pattern (similar to shadcn/ui):
<ChartContainer
  data={data}
  loading={isLoading}
  error={error}
  title="Revenue"
>
  <LineChart data={data}>
    <LineChartAxis dataKey="date" type="time" />
    <LineChartAxis dataKey="revenue" type="currency" />
    <Line dataKey="revenue" stroke="emerald" />
    <ChartTooltip />
    <ChartLegend />
  </LineChart>
</ChartContainer>

Theme integration:
- Pull colors from theme tokens (Tailwind / CSS vars)
- Dark mode automatic
- Brand customization centralized

When to extract:
- 4+ chart instances with same wrapper
- Different chart types sharing format/accessibility logic
- Dashboard with 10+ charts

Output:
1. ChartContainer component
2. Reusable formatters (number, date, currency, %)
3. Theme-token integration
4. Composition example
5. Storybook / component library entry

The Tremor approach is this — they ship a set of dashboard primitives (Card, KPI, Chart) that compose. If your dashboard is bespoke enough that Tremor doesn't fit, build something Tremor-like for your domain.

What Done Looks Like

A v1 chart system for B2B SaaS in 2026:

  • Library chosen + bundle size budgeted (<200KB gzipped for chart code)
  • Chart types matched to data stories (no pie charts for 6-category data)
  • Color palette accessible + colorblind-safe
  • Loading / empty / error states for every chart
  • Tooltips with formatted numbers, dates, currency
  • Smart axis tick formatting (1.2K not 1234.56)
  • Legend with click-to-hide
  • Keyboard navigation + screen-reader support (hidden table)
  • prefers-reduced-motion respected
  • Charts lazy-loaded (not in main bundle)
  • Reusable ChartContainer primitive

Add later when product is mature:

  • Brush + zoom for time series
  • Drill-down with URL state
  • Real-time updates (WebSocket / SSE)
  • Server-rendered charts for email / PDF
  • Theme support (dark mode + brand colors)
  • Crossfilter dashboards

The mistake to avoid: shipping default-styled charts. Default Chart.js looks like 2014. Spend 1 hour on color palette + axis formatting; visible difference.

The second mistake: rendering 100K points in SVG. Switch to Canvas (ECharts / Chart.js) above 1K points, or downsample server-side.

The third mistake: inaccessible charts. Without aria-label and a hidden-table fallback, your charts are invisible to screen readers — a Section 508 / WCAG / EAA violation in many jurisdictions.

See Also