
Data Viz 2025
- 234 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Create charts, dashboards, and exploratory plots from datasets during product builds, reports, or analytics features without guessing library APIs or visual design defaults.
About
Guides creation of modern data visualizations in 2025 stacks: choosing chart types, styling for clarity, wiring data to components, and avoiding misleading scales. Suited for SaaS metrics, content analytics, and API-backed reporting surfaces.
- Chart type selection from data shape
- Accessible color and labeling defaults
- Library-specific implementation patterns
- Dashboard layout guidance
- Export and responsive sizing tips
Data Viz 2025 by the numbers
- 234 all-time installs (skills.sh)
- Ranked #622 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill data-viz-2025Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 234 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Create charts, dashboards, and exploratory plots from datasets during product builds, reports, or analytics features without guessing library APIs or visual design defaults.
Files
Data Visualization 2025: The Art & Science of Visual Communication
Create visualizations that Seaborn users, Tufte readers, and everyone else will love. Marry NYT Graphics rigor with MoMA aesthetics, Nike energy, and On Kawara precision.
When to Use This Skill
✅ Use for:
- Building interactive charts, dashboards, and data stories
- Complex visualizations (chord diagrams, Sankey flows, network graphs)
- Real-time data displays with animations
- Mobile-responsive data components
- Accessible, tested visualizations for production
❌ NOT for:
- Static PNG/SVG exports without interaction (use design tools)
- Basic HTML tables (use semantic markup)
- Print-only graphics (different constraints)
- Simple icon displays (use icon libraries)
Core Philosophy: The Three Pillars
1. Clarity (Tufte's Data-Ink Ratio)
Every visual element must earn its place. Remove chart junk, maximize signal-to-noise.
2. Beauty (Aesthetic Standards)
Visualizations are art. Use spring physics, thoughtful color, and premium design systems.
3. Truth (Graphical Integrity)
Data representation must be honest. Test rigorously, document assumptions, preserve context.
Quick Decision Tree
What are you building?
├─ Exploratory analysis / many iterations
│ └─ → Observable Plot (grammar-of-graphics)
│
├─ Standard business charts (bars, lines, pies)
│ ├─ Simple React integration needed
│ │ └─ → Recharts (easiest, most popular)
│ └─ Premium aesthetics + theming
│ └─ → Nivo (beautiful out of the box)
│
├─ Custom, one-of-a-kind visualizations
│ ├─ Need low-level control
│ │ └─ → Visx (React + D3 primitives)
│ └─ Full D3 power
│ └─ → D3.js directly (steeper learning curve)
│
└─ Dashboard with Tailwind design system
├─ → Tremor (purpose-built for dashboards)
└─ → shadcn-ui Charts (Recharts + shadcn styling)The Data Viz Stack (2025)
Recommended Packages
{
"dependencies": {
"@observablehq/plot": "^0.6.0", // Exploratory, grammar-of-graphics
"recharts": "^2.12.0", // React charts, simple & popular
"@nivo/core": "^0.87.0", // Beautiful, themeable charts
"@visx/visx": "^3.10.0", // Low-level D3 + React primitives
"d3": "^7.9.0", // Direct D3 for custom work
"@tremor/react": "^3.15.0", // Tailwind dashboard components
"framer-motion": "^11.0.0" // Smooth animations
},
"devDependencies": {
"@percy/cli": "^1.29.0", // Visual regression testing
"@testing-library/react": "^14.2.0", // Component testing
"@storybook/react": "^7.6.0" // Component playground
}
}When to Use Each Library
Observable Plot - You want ggplot2/Vega-Lite in JavaScript
- Grammar-of-graphics approach (marks, scales, transforms)
- Perfect for rapid prototyping
- Great for notebooks and exploratory analysis
Recharts - You want it to "just work" in React
- Component-based (everything is a
<Component />) - Excellent documentation and community
- TypeScript support built-in
- Smallest learning curve
Nivo - You want visually stunning results
- 20+ chart types with beautiful defaults
- Canvas, SVG, and HTML rendering
- Server-side rendering support (unique feature)
- Extensive customization via props
Visx - You want maximum control with React patterns
- Low-level primitives (scales, axes, shapes)
- Compose your own chart types
- Airbnb's D3 + React toolkit
- Best for novel visualizations
D3.js - You want unlimited power (and responsibility)
- Full control over every pixel
- Steepest learning curve
- Best for advanced, custom work
- Use with
useEffectanduseRefin React
The Tufte Checklist
Before shipping any visualization, verify:
- [ ] Data-ink ratio maximized - Remove gridlines, decorations, 3D effects, shadows
- [ ] Graphical integrity - Visual representation proportional to data values
- [ ] Clear labeling - Direct labels on data (not legends requiring color matching)
- [ ] No chart junk - No unnecessary ornamentation or Moiré vibration
- [ ] Layered information - Use small multiples instead of overloaded single charts
- [ ] Show data variation, not design variation - Consistent visual encoding
Read references/tufte-principles.md for deep dive.
The NYT Graphics Workflow
The New York Times graphics team process:
1. Make 500 charts → Pick the one that displays information best 2. Simplify within reason → Remove noise and clutter 3. Annotate with insight → Words should highlight patterns, not just describe data 4. Test with real users → Watch people interact, identify confusion 5. Responsive by default → Mobile-first, progressive enhancement
Read references/nyt-workflow.md for case studies.
Animation & Micro-interactions
Data viz isn't static. Movement communicates:
When to Animate
- State transitions - Data updates, filter changes
- Draw attention - Highlight insights, guide the eye
- Show relationships - Morphing between views reveals structure
- Delight - Thoughtful motion = premium feel
Animation Principles
// Use spring physics, not linear easing
const springConfig = {
type: "spring",
stiffness: 300,
damping: 30
};
// Stagger for multiple elements
const staggerChildren = {
delayChildren: 0.1,
staggerChildren: 0.05
};
// Respect prefers-reduced-motion
const shouldAnimate = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;Read references/animation-patterns.md for complete patterns library.
Color: Beyond the Rainbow
Semantic Color Systems
// Qualitative (categorical data)
const categorical = [
"#d97706", "#7c3aed", "#059669", "#dc2626", "#2563eb"
];
// Sequential (ordered data, low to high)
const sequential = [
"#fef3c7", "#fcd34d", "#f59e0b", "#d97706", "#92400e"
];
// Diverging (data with meaningful center)
const diverging = [
"#dc2626", "#f87171", "#fef2f2", "#c7d2fe", "#6366f1"
];Accessibility Requirements
- Contrast ratio ≥4.5:1 for text on backgrounds
- Don't rely on color alone - Use shapes, patterns, labels
- Colorblind-safe palettes - Test with simulators
- Consider dark mode - Colors must work in both themes
Testing Data Visualizations
Visual Regression Testing
# Percy - Automated visual testing
npx percy snapshot ./storybook-static
# Chromatic - For Storybook
npx chromatic --project-token=<token>Data Accuracy Testing
// Verify rendered elements match data
test('bar chart renders correct number of bars', () => {
const data = [{ x: 'A', y: 10 }, { x: 'B', y: 20 }];
render(<BarChart data={data} />);
const bars = screen.getAllByTestId('bar');
expect(bars).toHaveLength(2);
});
// Verify scale accuracy
test('bar heights proportional to values', () => {
const data = [{ x: 'A', y: 10 }, { x: 'B', y: 20 }];
render(<BarChart data={data} />);
const bars = screen.getAllByTestId('bar');
const heights = bars.map(b => parseInt(b.style.height));
expect(heights[1]).toBe(heights[0] * 2); // B is 2x A
});Read references/testing-strategies.md for comprehensive test suites.
Responsive Design Patterns
Mobile-First Approach
// Desktop: Show everything
// Tablet: Simplify axes, reduce labels
// Mobile: Minimal chart, key insights only
const ChartResponsive = ({ data }: Props) => {
const isMobile = useMediaQuery('(max-width: 640px)');
return (
<ResponsiveContainer width="100%" height={isMobile ? 200 : 400}>
<LineChart data={data}>
{!isMobile && <CartesianGrid strokeDasharray="3 3" />}
<XAxis
dataKey="date"
tick={isMobile ? { fontSize: 10 } : undefined}
interval={isMobile ? 'preserveStartEnd' : 'auto'}
/>
<YAxis tick={isMobile ? false : undefined} />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="#d97706" />
</LineChart>
</ResponsiveContainer>
);
};Touch-Friendly Interactions
- Minimum touch target: 44×44px - Tooltips, buttons, interactive elements
- Swipe gestures - Navigate time series, change views
- Pinch-to-zoom - For dense charts (use carefully)
- Long-press context menus - Advanced actions
Data Storytelling
Every visualization tells a story. Follow the narrative arc:
1. Hook - What's the surprising insight? 2. Context - Why should we care? 3. Evidence - Show the data clearly 4. Conclusion - What should we do?
Narrative Techniques
- Scrollytelling - Charts animate as user scrolls
- Progressive disclosure - Start simple, reveal complexity
- Annotations - Point out the insight, don't make users hunt
- Comparison - Show before/after, us vs. them, expected vs. actual
Read references/data-storytelling.md for narrative frameworks.
Common Anti-Patterns
❌ The "Rainbow Vomit" Pie Chart
Problem: 12 colors, tiny slices, legend on the side Solution: Max 5 categories, direct labels, consider bar chart instead
❌ The "Misleading Axis" Bar Chart
Problem: Y-axis doesn't start at zero, exaggerates differences Solution: Always start at zero for bar charts (lines can vary)
❌ The "Dual-Axis Confusion" Line Chart
Problem: Two Y-axes with different scales mislead viewers Solution: Use separate charts or normalize to same scale
❌ The "3D Perspective" Lie
Problem: 3D effects distort data perception Solution: Stick to 2D, use color/size for third dimension
❌ The "Spinner of Death" Loading State
Problem: Empty screen with spinner for 2+ seconds Solution: Skeleton loading that shows chart structure immediately
Read references/antipatterns.md for exhaustive catalog.
Implementation Workflow
1. Explore Your Data
# Use Observable Plot for rapid iteration
npm install @observablehq/plot
# Create throwaway prototypes, iterate fast
# When you find the right chart, implement in production library2. Build Production Component
// Use Recharts for standard charts
// Use Nivo for beautiful, themeable charts
// Use Visx/D3 for custom visualizations
// Always wrap in error boundaries
// Always show skeleton loading state
// Always handle empty/loading/error states3. Test Thoroughly
# Visual regression testing
npx percy snapshot
# Component testing
npm test -- --coverage
# Accessibility testing
npx axe-core src/components/charts4. Document & Deploy
// Storybook for component playground
// Props documentation with TypeScript
// Usage examples for each chart typeAI-Enhanced Visualizations
When to Use Claude/Haiku
- Dynamic annotations - Generate insights from data
- Color palette suggestions - AI-powered color harmony
- Chart type recommendations - "What's the best way to show this?"
- Accessibility descriptions - Auto-generate alt text
Example: AI Annotation
const generateInsight = async (data: DataPoint[]) => {
const response = await fetch('/api/claude', {
method: 'POST',
body: JSON.stringify({
model: 'claude-haiku',
prompt: `Analyze this data and provide ONE key insight (max 15 words): ${JSON.stringify(data)}`
})
});
return response.text(); // "Sales peaked in Q3, driven by mobile conversions"
};Inspiration Galleries
Study these regularly:
- ObservableHQ Featured Notebooks
- Information is Beautiful Awards
- NYT Graphics on Twitter
- FlowingData
- Datawrapper River
- The Pudding
Performance Optimization
Bundle Size Management
// ❌ DON'T import entire library
import { LineChart } from 'recharts';
// ✅ DO tree-shake where possible
import LineChart from 'recharts/lib/chart/LineChart';
// Use dynamic imports for heavy charts
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Disable SSR for client-only charts
});Canvas vs SVG
- SVG - Better for < 1000 data points, accessibility, crisp at any scale
- Canvas - Better for > 1000 data points, animations, performance
- WebGL - Best for > 10,000 data points, 3D, gaming-level performance
Virtualization
For large datasets, render only visible portion:
// Use react-window or react-virtualized for long lists
// Aggregate/sample data for chart display
// Store full dataset separately for exportAccessibility Standards (WCAG AA)
Requirements
- Keyboard navigation - All interactive elements accessible via Tab
- Screen reader support - Provide data tables as alternative
- Focus indicators - Visible focus states for interactive elements
- Color contrast - ≥4.5:1 for small text, ≥3:1 for large text
- Reduced motion - Respect
prefers-reduced-motion: reduce
Implementation
<figure role="img" aria-labelledby="chart-title chart-desc">
<h2 id="chart-title">Sales Over Time</h2>
<p id="chart-desc">
Line chart showing sales increased 45% from Q1 to Q4,
peaking in November at $2.3M.
</p>
<LineChart data={data} />
{/* Provide data table alternative */}
<details>
<summary>View data table</summary>
<table>...</table>
</details>
</figure>Reference Materials
This skill includes comprehensive reference documentation:
- `references/tufte-principles.md` - Edward Tufte's data visualization principles with examples
- `references/library-comparison.md` - Deep dive on Observable Plot, Recharts, Nivo, Visx, D3
- `references/testing-strategies.md` - Visual regression, component testing, accessibility testing
- `references/animation-patterns.md` - Motion design patterns for charts
- `references/data-storytelling.md` - Narrative techniques and scrollytelling patterns
- `references/antipatterns.md` - Common mistakes and how to avoid them
- `references/nyt-workflow.md` - New York Times graphics team best practices
Utility Scripts
- `scripts/data-transform.ts` - Common data transformations (rollup, pivot, normalize)
- `scripts/chart-test-helpers.ts` - Testing utilities for verifying chart accuracy
- `scripts/color-palette-generator.ts` - Generate accessible color palettes
- `scripts/performance-benchmark.ts` - Benchmark chart rendering performance
Quick Start: Building Your First Chart
// 1. Install dependencies
// npm install recharts framer-motion
// 2. Create a simple line chart
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { motion } from 'framer-motion';
const data = [
{ month: 'Jan', value: 400 },
{ month: 'Feb', value: 300 },
{ month: 'Mar', value: 600 },
];
export const SalesChart = () => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="value"
stroke="#d97706"
strokeWidth={2}
dot={{ fill: '#d97706', r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</motion.div>
);
// 3. Test it
// 4. Ship it with confidence---
Remember: The best visualization is the one that makes the insight obvious. When in doubt, simplify. When confused, prototype 10 options. When shipping, test ruthlessly.
This skill guides: Chart selection | Library integration | Testing strategies | Animation patterns | Accessibility compliance | Performance optimization
/**
* Example: Production-Ready Animated Bar Chart
*
* This component demonstrates 2025 data viz best practices:
* - Tufte principles (high data-ink ratio)
* - Smooth animations with spring physics
* - Accessibility (keyboard nav, screen reader support)
* - Responsive design
* - Loading states (skeleton, not spinner)
* - Error handling
* - TypeScript types
*
* Stack: Recharts + Framer Motion + Tailwind CSS
*/
import { motion, useReducedMotion } from 'framer-motion';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer
} from 'recharts';
interface DataPoint {
category: string;
value: number;
insight?: string;
}
interface AnimatedBarChartProps {
data: DataPoint[];
title?: string;
description?: string;
highlightCategory?: string;
isLoading?: boolean;
error?: Error;
}
export function AnimatedBarChart({
data,
title = 'Data Visualization',
description,
highlightCategory,
isLoading = false,
error
}: AnimatedBarChartProps) {
const shouldReduceMotion = useReducedMotion();
// Loading State: Skeleton loader
if (isLoading) {
return <ChartSkeleton />;
}
// Error State
if (error) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-6"
role="alert"
>
<h3 className="font-semibold text-red-900">Error loading chart</h3>
<p className="text-sm text-red-700">{error.message}</p>
</div>
);
}
// Empty State
if (!data || data.length === 0) {
return (
<div
className="rounded-lg border border-gray-200 bg-gray-50 p-12 text-center"
data-testid="empty-state"
>
<p className="text-gray-600">No data to display</p>
</div>
);
}
return (
<figure
role="img"
aria-labelledby="chart-title"
aria-describedby="chart-desc"
className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm"
>
{/* Entrance animation */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: shouldReduceMotion ? undefined : 'spring',
duration: shouldReduceMotion ? 0 : 0.5,
stiffness: 300,
damping: 30
}}
>
{/* Title & Description */}
<div className="mb-6">
<h2 id="chart-title" className="text-2xl font-semibold text-gray-900">
{title}
</h2>
{description && (
<p id="chart-desc" className="mt-2 text-sm text-gray-600">
{description}
</p>
)}
</div>
{/* Chart */}
<ResponsiveContainer width="100%" height={400}>
<BarChart
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 60 }}
>
{/* Minimal gridlines (Tufte principle) */}
<XAxis
dataKey="category"
stroke="#d1d5db"
strokeWidth={1}
tick={{ fill: '#6b7280', fontSize: 12 }}
angle={-45}
textAnchor="end"
/>
<YAxis
stroke="#d1d5db"
strokeWidth={1}
tick={{ fill: '#6b7280', fontSize: 12 }}
/>
{/* Tooltip with insight */}
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: 'rgba(0, 0, 0, 0.05)' }}
/>
{/* Bars with conditional highlighting */}
<Bar
dataKey="value"
fill={(entry: DataPoint) =>
entry.category === highlightCategory
? '#d97706' // Ember orange for highlight
: '#9ca3af' // Gray for others
}
radius={[4, 4, 0, 0]}
data-testid="bar"
animationDuration={shouldReduceMotion ? 0 : 800}
animationEasing="ease-out"
/>
</BarChart>
</ResponsiveContainer>
{/* Provide data table alternative for screen readers */}
<details className="mt-6">
<summary className="cursor-pointer text-sm text-gray-600 hover:text-gray-900">
View data table
</summary>
<table className="mt-4 w-full border-collapse text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="py-2 text-left font-semibold">Category</th>
<th className="py-2 text-right font-semibold">Value</th>
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.category} className="border-b border-gray-100">
<td className="py-2">{row.category}</td>
<td className="py-2 text-right">
{row.value.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</details>
</motion.div>
</figure>
);
}
/**
* Custom Tooltip with Insight
*/
function CustomTooltip({ active, payload }: any) {
if (!active || !payload || !payload.length) return null;
const data: DataPoint = payload[0].payload;
return (
<motion.div
className="rounded-lg border border-gray-200 bg-white p-3 shadow-lg"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
>
<p className="font-semibold text-gray-900">{data.category}</p>
<p className="text-2xl font-bold text-ember-600">
{data.value.toLocaleString()}
</p>
{data.insight && (
<p className="mt-1 text-xs text-gray-600">{data.insight}</p>
)}
</motion.div>
);
}
/**
* Skeleton Loader (Tufte-approved: shows structure, not spinner)
*/
function ChartSkeleton() {
return (
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
{/* Title skeleton */}
<div className="mb-6">
<div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
<div className="mt-2 h-4 w-96 animate-pulse rounded bg-gray-100" />
</div>
{/* Chart skeleton */}
<div className="flex h-[400px] items-end justify-around gap-4">
{[60, 80, 45, 90, 70, 55].map((height, i) => (
<motion.div
key={i}
className="w-full rounded-t bg-gray-200"
style={{ height: `${height}%` }}
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{
duration: 1.5,
repeat: Infinity,
delay: i * 0.1
}}
/>
))}
</div>
{/* Axis labels skeleton */}
<div className="mt-4 flex justify-around">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-3 w-12 animate-pulse rounded bg-gray-100" />
))}
</div>
</div>
);
}
/**
* Usage Example:
*
* const salesData = [
* { category: 'Mobile', value: 1200000, insight: '+68% YoY' },
* { category: 'Desktop', value: 800000, insight: '-12% YoY' },
* { category: 'Tablet', value: 400000, insight: 'Stable' }
* ];
*
* <AnimatedBarChart
* data={salesData}
* title="Q4 Sales by Channel"
* description="Mobile now drives 60% of revenue"
* highlightCategory="Mobile"
* />
*/
Data Viz 2025: State-of-the-Art Data Visualization Skill
Create visualizations that Seaborn users, Tufte readers, and everyone else will love.
A comprehensive skill for building production-ready data visualizations in React/Next.js/TypeScript with Tailwind CSS. Combines NYT Graphics rigor, Tufte principles, and modern animation patterns.
What This Skill Provides
Core Philosophy
- Clarity (Tufte's Data-Ink Ratio) - Every visual element must earn its place
- Beauty (Aesthetic Standards) - Visualizations are art, use premium design
- Truth (Graphical Integrity) - Data representation must be honest and tested
Complete Coverage
1. Library Selection - Decision trees for Observable Plot, Recharts, Nivo, Visx, D3.js 2. Design Principles - Tufte's data-ink ratio, graphical integrity, chartjunk removal 3. Testing Strategies - Visual regression, data accuracy, accessibility, performance 4. Animation Patterns - Spring physics, micro-interactions, scrollytelling 5. Data Storytelling - Narrative techniques, progressive disclosure, annotated insights 6. Utility Scripts - Data transformation and chart testing helpers 7. Production Examples - Battle-tested components with accessibility built-in
Quick Start
Installation
# Add to your Claude skills directory
cp -r data-viz-2025 ~/.claude/skills/
# Or use the Skill tool in Claude Code:
# "Use the data-viz-2025 skill to create a chart"Basic Usage
// 1. Choose your library (see SKILL.md decision tree)
npm install recharts framer-motion
// 2. Import utilities
import { rollup, normalize } from './scripts/data-transform';
import { verifyScaleAccuracy } from './scripts/chart-test-helpers';
// 3. Build your chart
import { AnimatedBarChart } from './assets/example-chart';
<AnimatedBarChart
data={salesData}
title="Q4 Sales by Channel"
highlightCategory="Mobile"
/>File Structure
data-viz-2025/
├── SKILL.md # Core skill instructions
├── README.md # This file
│
├── references/ # Deep-dive documentation
│ ├── tufte-principles.md # Data-ink ratio, graphical integrity
│ ├── library-comparison.md # Observable Plot vs Recharts vs Nivo vs Visx vs D3
│ ├── testing-strategies.md # Visual regression, data accuracy, a11y
│ ├── animation-patterns.md # Motion design for charts
│ └── data-storytelling.md # Narrative techniques, scrollytelling
│
├── scripts/ # Utility functions
│ ├── data-transform.ts # groupBy, rollup, normalize, pivot, etc.
│ └── chart-test-helpers.ts # verifyScaleAccuracy, checkContrast, etc.
│
└── assets/ # Production examples
└── example-chart.tsx # Complete animated bar chartKey Features
Decision Trees for Library Selection
Need to make 50 prototypes fast? → Observable Plot Building standard business charts? → Recharts Want beautiful defaults? → Nivo Need maximum control? → Visx or D3.js
See SKILL.md for complete decision tree.
Tufte Principles Built-In
- ✅ Maximize data-ink ratio
- ✅ Ensure graphical integrity
- ✅ Remove chartjunk
- ✅ Use small multiples for comparison
- ✅ Direct labels (not legends)
See references/tufte-principles.md for examples.
Testing at Every Level
// Data accuracy
verifyScaleAccuracy(bars, dataValues, 'height');
// Visual regression (Percy)
npx percy storybook ./storybook-static
// Accessibility
const results = await axe(container);
expect(results).toHaveNoViolations();
// Performance
const time = benchmarkRender(() => render(<Chart />));
expect(time).toBeLessThan(500);See references/testing-strategies.md for comprehensive test suites.
Animation Patterns
// Spring physics (not linear easing)
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
// Stagger children
transition={{ staggerChildren: 0.1 }}
// Respect reduced motion
const shouldReduceMotion = useReducedMotion();See references/animation-patterns.md for complete patterns library.
Data Storytelling Framework
Every visualization should tell a story: 1. Hook - What's the surprising insight? 2. Context - Why should we care? 3. Evidence - Show the data clearly 4. Conclusion - What should we do?
See references/data-storytelling.md for narrative techniques.
Common Workflows
Building a Chart from Scratch
1. Research your data (use Observable Plot for prototyping)
2. Choose production library (Recharts for most cases)
3. Implement with Tufte principles (high data-ink ratio)
4. Add animations (spring physics, stagger)
5. Write tests (data accuracy + visual regression)
6. Add accessibility (ARIA labels, data table alternative)
7. Ship with confidenceImproving an Existing Chart
1. Run through Tufte checklist (see SKILL.md)
2. Remove chartjunk (gridlines, 3D, shadows)
3. Add direct labels (replace legends)
4. Verify accessibility (axe, contrast ratios)
5. Add micro-interactions (hover states)
6. Test on mobile
7. Take Percy snapshotUtility Functions
Data Transformations
import { groupBy, rollup, normalize, pivot } from './scripts/data-transform';
// Group by category
const grouped = groupBy(data, 'category');
// Aggregate
const totals = rollup(data, 'category', 'value', 'sum');
// Normalize to 0-1
const normalized = normalize([10, 20, 30]);
// Pivot table
const pivoted = pivot(data, 'date', 'category', 'value');See scripts/data-transform.ts for 20+ utility functions.
Chart Testing
import { verifyScaleAccuracy, checkContrast } from './scripts/chart-test-helpers';
// Verify bars are proportional
verifyScaleAccuracy(bars, dataValues, 'height');
// Check WCAG contrast
checkContrast(bar, '#ffffff'); // true if ≥3:1
// Benchmark performance
const time = benchmarkRender(() => render(<Chart />));See scripts/chart-test-helpers.ts for complete testing toolkit.
Best Practices
DO ✅
- Start with Observable Plot for prototyping
- Use Recharts for standard business charts
- Maximize data-ink ratio (remove gridlines, borders)
- Provide data table alternative for accessibility
- Use spring physics for animations
- Respect
prefers-reduced-motion - Test with Percy or Chromatic
- Use skeleton loaders (not spinners)
DON'T ❌
- Use 3D effects or gradients (distort perception)
- Start Y-axis at non-zero for bar charts (misleading)
- Rely on color alone (use shapes, labels too)
- Use rainbow color schemes (hard to interpret)
- Animate without purpose (decoration)
- Skip accessibility testing
- Use
<Loader2 className="animate-spin" />
Inspiration Sources
Reference Materials
| File | Contents |
|---|---|
references/tufte-principles.md | Edward Tufte's foundational principles |
references/library-comparison.md | Observable Plot vs Recharts vs Nivo vs Visx vs D3 |
references/testing-strategies.md | Visual regression, data accuracy, a11y |
references/animation-patterns.md | Spring physics, micro-interactions, scrollytelling |
references/data-storytelling.md | Narrative techniques, progressive disclosure |
Success Criteria
Before shipping a chart, verify:
- [ ] High data-ink ratio (gridlines removed or minimal)
- [ ] Graphical integrity (proportional representation)
- [ ] Direct labels (not legends requiring color matching)
- [ ] Accessibility (WCAG AA, keyboard nav, screen reader support)
- [ ] Responsive (mobile-first, touch targets ≥44px)
- [ ] Tested (data accuracy + visual regression)
- [ ] Animated (spring physics, respects reduced motion)
- [ ] Loading state (skeleton, not spinner)
- [ ] Error handling (graceful degradation)
- [ ] Empty state (helpful message)
Contributing
Found a pattern that works well? Add it!
1. Document in appropriate reference file 2. Add utility function if reusable 3. Update SKILL.md if changes core workflow 4. Test thoroughly 5. Share examples in assets/
Sources
This skill synthesizes best practices from:
- Edward Tufte - "The Visual Display of Quantitative Information" (1983)
- New York Times Graphics Team
- ObservableHQ community
- Modern React visualization libraries
- WCAG 2.1 accessibility standards
- 2025 web performance best practices
Version
1.0.0 (January 2026)
Initial release covering:
- Library selection framework
- Tufte principles
- Testing strategies
- Animation patterns
- Data storytelling
- Utility scripts
- Production examples
---
Remember: The best visualization is the one that makes the insight obvious. When in doubt, simplify.
Animation Patterns for Data Visualizations
Motion communicates. In data visualizations, animations guide attention, reveal relationships, and delight users. This guide covers modern animation patterns for 2025.
Animation Philosophy
Why Animate Data Viz?
1. Draw Attention - Guide the eye to insights 2. Show Relationships - Morphing between views reveals structure 3. Reduce Cognitive Load - Smooth transitions help users track changes 4. Premium Feel - Thoughtful motion = professional application
When NOT to Animate
- ❌ Print visualizations - Static medium
- ❌ Accessibility concern - When
prefers-reduced-motion: reduce - ❌ Performance critical - Large datasets (>10K points)
- ❌ Just decoration - Motion without purpose
Spring Physics vs. Easing Curves
Linear Easing (❌ Never Use)
// ❌ BAD - Feels robotic
transition: 'all 0.3s linear'Ease-Out (⚠️ Acceptable)
// ⚠️ OK - But not exciting
transition: 'all 0.3s ease-out'Spring Physics (✅ Best)
// ✅ GOOD - Natural, bouncy, delightful
import { motion } from 'framer-motion';
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30
}}
>
{chart}
</motion.div>Why springs feel better:
- Mimic real-world physics
- No artificial "stopping point" (ease curves feel abrupt)
- Automatically adjust to interruptions (user interrupts animation mid-way)
Essential Animation Patterns
1. Entrance Animations (Draw-In)
Line Chart Drawing In:
import { motion } from 'framer-motion';
const LineChart = ({ data }) => {
return (
<svg width={640} height={400}>
<motion.path
d={generatePath(data)}
fill="none"
stroke="#d97706"
strokeWidth={2}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.5, ease: "easeInOut" }}
/>
</svg>
);
};Bars Growing Up:
const BarChart = ({ data }) => {
return (
<svg width={640} height={400}>
{data.map((d, i) => (
<motion.rect
key={d.category}
x={i * 40}
y={400 - d.value}
width={30}
height={d.value}
fill="#d97706"
initial={{ height: 0, y: 400 }}
animate={{ height: d.value, y: 400 - d.value }}
transition={{
type: "spring",
stiffness: 200,
damping: 20,
delay: i * 0.1 // Stagger
}}
/>
))}
</svg>
);
};2. Staggered Animations
Animate multiple elements with a delay between each.
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
delayChildren: 0.1,
staggerChildren: 0.05 // 50ms between each child
}
}
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
};
<motion.div variants={container} initial="hidden" animate="show">
{data.map(d => (
<motion.div key={d.id} variants={item}>
{d.value}
</motion.div>
))}
</motion.div>3. Data Update Transitions
Smooth Value Changes:
import { animate } from 'framer-motion';
import { useEffect, useState } from 'react';
const AnimatedNumber = ({ value }: { value: number }) => {
const [displayValue, setDisplayValue] = useState(value);
useEffect(() => {
const controls = animate(displayValue, value, {
duration: 0.5,
onUpdate: v => setDisplayValue(Math.round(v))
});
return controls.stop;
}, [value]);
return <span>{displayValue.toLocaleString()}</span>;
};Morphing Bars:
const Bar = ({ height, y }: Props) => (
<motion.rect
animate={{ height, y }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
);4. Tooltip Animations
Fade + Scale:
<motion.div
className="tooltip"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
>
{content}
</motion.div>Slide From Side:
<motion.div
className="tooltip"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
{content}
</motion.div>5. Hover Micro-interactions
Scale on Hover:
<motion.rect
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>Glow Effect:
<motion.circle
whileHover={{
boxShadow: "0 0 20px rgba(217, 119, 6, 0.6)"
}}
/>Lift Effect (Chart Cards):
<motion.div
className="chart-card"
whileHover={{
y: -4,
boxShadow: "0 20px 40px rgba(0,0,0,0.1)"
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{chart}
</motion.div>6. Loading States (Skeleton Loaders)
Shimmer Effect:
<motion.rect
x={0}
y={0}
width={200}
height={300}
fill="url(#shimmer)"
rx={4}
/>
<defs>
<linearGradient id="shimmer">
<stop offset="0%" stopColor="#f0f0f0" />
<motion.stop
offset="50%"
stopColor="#e0e0e0"
animate={{
offset: ["0%", "100%"]
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "linear"
}}
/>
<stop offset="100%" stopColor="#f0f0f0" />
</linearGradient>
</defs>Pulse:
<motion.div
className="skeleton-bar"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut"
}}
/>7. Filter/Sort Animations
Layout Animations (Auto-animate):
import { motion, AnimatePresence } from 'framer-motion';
<AnimatePresence>
{filteredData.map(item => (
<motion.div
key={item.id}
layout // Auto-animate position changes
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring" }}
>
{item.content}
</motion.div>
))}
</AnimatePresence>8. Scrollytelling Animations
Animate on Scroll:
import { motion, useScroll, useTransform } from 'framer-motion';
const ScrollChart = () => {
const { scrollYProgress } = useScroll();
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
return (
<motion.div style={{ opacity, scale }}>
{chart}
</motion.div>
);
};Reveal Data Points as User Scrolls:
const DataPoint = ({ value, scrollProgress }: Props) => {
const opacity = useTransform(scrollProgress, [0.3, 0.5], [0, 1]);
const y = useTransform(scrollProgress, [0.3, 0.5], [20, 0]);
return (
<motion.circle
style={{ opacity, y }}
cx={x}
cy={y}
r={5}
/>
);
};Advanced Patterns
9. Morphing Between Chart Types
Transform a bar chart into a line chart:
import { motion } from 'framer-motion';
const MorphingChart = ({ type }: { type: 'bar' | 'line' }) => {
return (
<svg width={640} height={400}>
<AnimatePresence mode="wait">
{type === 'bar' ? (
<motion.g
key="bars"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Bar elements */}
</motion.g>
) : (
<motion.g
key="line"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Line element */}
</motion.g>
)}
</AnimatePresence>
</svg>
);
};10. Particle Effects
Celebrate milestones with confetti:
import confetti from 'canvas-confetti';
const celebrate = () => {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
});
};
<button onClick={() => {
updateData();
celebrate(); // Visual reward for hitting goal
}}>
Update Chart
</button>11. Path Animations (Complex Shapes)
Animate along a path:
<motion.circle
cx={0}
cy={0}
r={5}
fill="#d97706"
>
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M 0 0 L 100 100 L 200 50 L 300 150"
/>
</motion.circle>Accessibility: Respecting Reduced Motion
Always check `prefers-reduced-motion`:
import { useReducedMotion } from 'framer-motion';
const Chart = () => {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.5,
type: shouldReduceMotion ? undefined : "spring"
}}
>
{chart}
</motion.div>
);
};CSS Media Query:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}Performance Optimization
Use will-change for GPU Acceleration
.animated-chart {
will-change: transform, opacity;
}Limit Concurrent Animations
// ❌ BAD - Animating 1000 elements at once
{data.map((d, i) => (
<motion.rect animate={{ ... }} />
))}
// ✅ GOOD - Animate in batches or use Canvas
{data.length < 100 ? (
data.map(d => <motion.rect animate={{ ... }} />)
) : (
<CanvasChart data={data} /> // No individual SVG elements
)}Debounce Rapid Updates
import { useDebouncedValue } from '@mantine/hooks';
const Chart = ({ data }: Props) => {
const [debouncedData] = useDebouncedValue(data, 200);
return <AnimatedChart data={debouncedData} />;
};Animation Timing Reference
| Duration | Use Case |
|---|---|
| 0-100ms | Micro-interactions (hover, tap feedback) |
| 100-300ms | Small transitions (tooltip appear, button states) |
| 300-500ms | Medium transitions (data updates, filters) |
| 500ms-1s | Large transitions (chart type changes, page transitions) |
| 1-2s | Entrance animations (first render, data loading complete) |
| 2s+ | Scrollytelling, storytelling sequences |
Common Mistakes
❌ Over-Animation
// Too much motion - distracting
<motion.div
animate={{
scale: [1, 1.2, 0.8, 1.1, 0.9, 1],
rotate: [0, 10, -10, 5, -5, 0],
x: [0, 50, -50, 0]
}}
transition={{ duration: 0.5 }}
/>❌ Inconsistent Timing
// Different durations for similar elements - jarring
<motion.rect transition={{ duration: 0.3 }} />
<motion.rect transition={{ duration: 0.8 }} />
<motion.rect transition={{ duration: 0.5 }} />❌ Animations Without Purpose
// Why is this rotating? No semantic meaning.
<motion.text animate={{ rotate: 360 }} />Best Practices Summary
1. Use spring physics - More natural than easing curves 2. Stagger for multiple elements - Don't animate all at once 3. Respect `prefers-reduced-motion` - Accessibility requirement 4. Keep durations consistent - Similar elements = similar timing 5. Purpose over decoration - Every animation should communicate 6. Test performance - Large datasets may need Canvas or no animation 7. Loading states > spinners - Skeleton loaders are better UX
Framer Motion Cheat Sheet
// Basic animation
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
/>
// Spring physics
transition={{ type: "spring", stiffness: 300, damping: 30 }}
// Stagger children
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.1 }
}
};
// Hover/Tap
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
/>
// Layout animations (auto-animate position)
<motion.div layout />
// Scroll-driven animations
const { scrollYProgress } = useScroll();
const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);
// Reduced motion
const shouldReduceMotion = useReducedMotion();Resources
- Framer Motion Documentation
- React Spring
- Theater.js - For complex animation sequences
- Lottie - For After Effects animations
- GSAP - Industry-standard animation library
Example: Complete Animated Dashboard Card
import { motion, useReducedMotion } from 'framer-motion';
export const DashboardCard = ({ title, value, trend, data }) => {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
className="card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: shouldReduceMotion ? undefined : "spring",
duration: shouldReduceMotion ? 0 : 0.5
}}
whileHover={shouldReduceMotion ? undefined : {
y: -4,
boxShadow: "0 20px 40px rgba(0,0,0,0.1)"
}}
>
<h3>{title}</h3>
<motion.div
className="value"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<AnimatedNumber value={value} />
</motion.div>
<motion.div
className="trend"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
{trend > 0 ? '↑' : '↓'} {Math.abs(trend)}%
</motion.div>
<svg width="100%" height={80}>
<motion.path
d={generateSparkline(data)}
fill="none"
stroke="#d97706"
strokeWidth={2}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1, delay: 0.4 }}
/>
</svg>
</motion.div>
);
};Remember: Motion with purpose > Motion for motion's sake
Data Storytelling: Turning Charts into Narratives
Data alone doesn't persuade. Stories persuade. This guide teaches you how to transform visualizations into compelling narratives that drive action.
The Narrative Arc for Data
Every data story follows a structure:
1. HOOK → What's the surprising insight?
2. CONTEXT → Why should we care?
3. EVIDENCE → Show the data clearly
4. CONCLUSION → What should we do?Example: Bad vs. Good
❌ Bad (Just Data):
"Here's our Q4 sales data in a bar chart."
✅ Good (Data Story):
"Our mobile sales doubled in Q4, now accounting for 60% of revenue. This shift caught us off guard—our mobile experience isn't optimized for this volume. We need to prioritize mobile checkout improvements in Q1."
Core Storytelling Techniques
1. Start with the Insight, Not the Data
❌ Data-First Approach:
<BarChart data={sales} title="Q4 Sales by Channel" />User must discover the insight themselves
✅ Insight-First Approach:
<div className="insight-card">
<h2>Mobile Sales Doubled in Q4</h2>
<p className="insight">
Mobile now drives 60% of revenue, up from 30% last quarter
</p>
<BarChart
data={sales}
highlightCategory="Mobile"
annotation="2x growth"
/>
</div>Insight is immediate, chart provides evidence
2. Use Annotations to Guide the Eye
Don't make users hunt for insights. Point them out.
<LineChart data={revenue}>
<ReferenceLine
x="2020-03-15"
stroke="#dc2626"
label="Pandemic Declared"
/>
<ReferenceLine
y={1000000}
stroke="#059669"
label="$1M Milestone"
strokeDasharray="5 5"
/>
<ReferenceArea
x1="2020-06"
x2="2020-09"
fill="#fef3c7"
fillOpacity={0.3}
label="Recovery Period"
/>
</LineChart>3. Comparison Over Absolute Values
❌ Hard to Interpret:
"We had 45,832 visitors in January and 52,193 in February."
✅ Easy to Grasp:
"Visitors increased 14% month-over-month (from 45K to 52K)."
<div className="comparison-card">
<div className="metric">
<span className="value">52K</span>
<span className="change positive">+14% ↑</span>
</div>
<div className="vs">vs. 45K last month</div>
</div>4. Progressive Disclosure
Reveal complexity gradually. Start simple, allow drilling down.
// Level 1: Simple headline number
<div className="kpi">
<span className="value">$2.3M</span>
<span className="label">Q4 Revenue</span>
</div>
// Level 2: Expand to show trend
<details>
<summary>View trend</summary>
<LineChart data={quarterlyRevenue} height={150} />
</details>
// Level 3: Full breakdown
<details>
<summary>View breakdown by channel</summary>
<BarChart data={revenueByChannel} height={300} />
</details>Scrollytelling: Data Stories That Unfold
Animate visualizations as the user scrolls through a narrative.
Basic Scrollytelling Pattern
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
export const ScrollytellingChart = () => {
const containerRef = useRef(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end start"]
});
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0.8, 1, 1, 0.8]);
return (
<div ref={containerRef} className="min-h-screen flex items-center">
<motion.div style={{ opacity, scale }}>
<LineChart data={data} />
</motion.div>
</div>
);
};Advanced: Reveal Data Points as User Scrolls
export const RevealingChart = () => {
const { scrollYProgress } = useScroll();
const visibleDataPoints = useTransform(
scrollYProgress,
[0, 1],
[0, data.length]
);
return (
<div className="min-h-[200vh]">
<div className="sticky top-0 h-screen flex items-center">
<Chart
data={data.slice(0, visibleDataPoints.get())}
/>
</div>
<div className="narrative">
<section>In January, sales started slow...</section>
<section>But by March, growth accelerated...</section>
<section>Q2 saw explosive growth...</section>
</div>
</div>
);
};Libraries for Scrollytelling
- Framer Motion -
useScroll()hook for scroll-driven animations - react-scroll-parallax - Parallax effects
- Intersection Observer API - Trigger animations when elements enter viewport
The "Before & After" Pattern
Show the impact of changes by comparing states.
<div className="before-after">
<div className="before">
<h3>Before Optimization</h3>
<BarChart data={beforeData} color="#dc2626" />
<p className="metric">Avg Load Time: 3.2s</p>
</div>
<div className="arrow">→</div>
<div className="after">
<h3>After Optimization</h3>
<BarChart data={afterData} color="#059669" />
<p className="metric">Avg Load Time: 0.8s</p>
<p className="improvement">75% faster ✓</p>
</div>
</div>The "Small Multiples" Narrative
Use Tufte's small multiples to tell a comparative story.
<div className="grid grid-cols-3 gap-4">
{regions.map(region => (
<div key={region} className="region-card">
<h4>{region}</h4>
<Sparkline data={salesByRegion[region]} />
<p className="insight">
{generateInsight(salesByRegion[region])}
</p>
</div>
))}
</div>Insight Generation:
const generateInsight = (data: DataPoint[]) => {
const trend = calculateTrend(data);
const peak = findPeak(data);
if (trend > 20) return `Strong growth (+${trend}%)`;
if (trend < -20) return `Declining (${trend}%)`;
if (peak.recent) return `Recent peak in ${peak.month}`;
return `Stable performance`;
};Color as Narrative Device
Use color to reinforce your story.
Semantic Colors
const colors = {
good: '#059669', // Green for positive
bad: '#dc2626', // Red for negative
neutral: '#6b7280', // Gray for context
highlight: '#d97706' // Orange for focus
};
// Highlight the important bar
<BarChart
data={data}
colors={data.map(d =>
d.category === 'Mobile' ? colors.highlight : colors.neutral
)}
/>Diverging Color Scales
Show "better than" vs "worse than" with diverging colors.
<Heatmap
data={performance}
colorScale={{
type: 'diverging',
domain: [-100, 0, 100],
colors: ['#dc2626', '#f3f4f6', '#059669']
}}
/>Interactive Storytelling
Let users explore the data themselves.
Filters That Tell Stories
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
<div className="interactive-story">
<h2>Explore Sales by Category</h2>
<div className="filters">
{categories.map(cat => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={selectedCategory === cat ? 'active' : ''}
>
{cat}
</button>
))}
</div>
<AnimatePresence mode="wait">
<motion.div
key={selectedCategory || 'all'}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<LineChart
data={filterData(data, selectedCategory)}
/>
<Insight category={selectedCategory} />
</motion.div>
</AnimatePresence>
</div>Tooltips as Micro-Stories
<Tooltip
content={
<div className="tooltip-story">
<h4>{dataPoint.label}</h4>
<p className="value">${dataPoint.value.toLocaleString()}</p>
<p className="comparison">
{dataPoint.changePercent > 0 ? '↑' : '↓'}
{Math.abs(dataPoint.changePercent)}% vs last period
</p>
<p className="insight">{dataPoint.insight}</p>
</div>
}
/>The Dashboard as a Story
Arrange dashboard widgets to tell a story from top to bottom.
Story Structure for Dashboards
<div className="dashboard">
{/* 1. HOOK - Most important insight */}
<section className="hero-insight">
<h1>Revenue Up 32% This Quarter</h1>
<BigNumber value={revenue} change={+32} />
</section>
{/* 2. CONTEXT - What's driving this? */}
<section className="drivers">
<h2>Growth Drivers</h2>
<div className="grid grid-cols-3 gap-4">
<InsightCard title="Mobile Sales" change={+68} />
<InsightCard title="New Customers" change={+45} />
<InsightCard title="Avg Order Value" change={+12} />
</div>
</section>
{/* 3. EVIDENCE - Detailed charts */}
<section className="details">
<h2>Detailed Performance</h2>
<LineChart data={revenueOverTime} />
<BarChart data={revenueByChannel} />
</section>
{/* 4. CONCLUSION - What's next? */}
<section className="action-items">
<h2>Recommended Actions</h2>
<ActionCard priority="high">
Optimize mobile checkout (60% of traffic)
</ActionCard>
<ActionCard priority="medium">
Expand successful campaigns to new regions
</ActionCard>
</section>
</div>Narrative Voice: Writing for Data Viz
Active Voice > Passive Voice
❌ Passive:
"A 45% increase in mobile traffic was observed."
✅ Active:
"Mobile traffic increased 45%."
Specific > Vague
❌ Vague:
"Sales improved significantly."
✅ Specific:
"Sales increased 32%, from $1.8M to $2.4M."
Implications > Just Facts
❌ Just Facts:
"Desktop traffic decreased 20%."
✅ With Implications:
"Desktop traffic decreased 20%, signaling a need to prioritize mobile UX investments."
AI-Enhanced Storytelling
Use Claude Haiku to generate dynamic insights.
const generateInsight = async (data: DataPoint[]) => {
const response = await fetch('/api/claude', {
method: 'POST',
body: JSON.stringify({
model: 'claude-haiku',
prompt: `
Analyze this sales data and provide ONE key insight (max 20 words).
Be specific with numbers. Suggest one action.
Data: ${JSON.stringify(data)}
`
})
});
return response.text();
// Example output: "Mobile sales surged 68% to $1.4M. Optimize mobile checkout to capitalize on this trend."
};Case Study: NYT "How the Pandemic Reshaped the Economy"
The New York Times' COVID economic impact story exemplifies data storytelling:
1. Hook: "The pandemic upended the economy overnight." 2. Scrollytelling: Charts animate as you scroll through narrative 3. Annotations: Key dates labeled (lockdowns, stimulus, reopening) 4. Small multiples: Compare different industries side-by-side 5. Interactive filters: Explore by sector, region, demographic 6. Conclusion: "Recovery is uneven—service workers still struggling"
Implementation Pattern
<ScrollytellingStory>
<Section>
<Narrative>
<h2>The pandemic upended the economy overnight.</h2>
<p>In March 2020, unemployment spiked faster than any recession in history.</p>
</Narrative>
<Chart>
<LineChart
data={unemployment}
highlightRange={['2020-03', '2020-04']}
annotation="33M jobs lost in 2 months"
/>
</Chart>
</Section>
<Section>
<Narrative>
<h2>Tech workers recovered quickly...</h2>
<p>By Q3 2020, tech sector employment returned to pre-pandemic levels.</p>
</Narrative>
<Chart>
<LineChart
data={techEmployment}
compareBaseline="2020-02"
/>
</Chart>
</Section>
<Section>
<Narrative>
<h2>...but service workers are still behind.</h2>
<p>Restaurant and hospitality jobs remain 15% below pre-pandemic levels.</p>
</Narrative>
<Chart>
<LineChart
data={serviceEmployment}
highlightGap="15% below baseline"
/>
</Chart>
</Section>
</ScrollytellingStory>Accessibility in Data Stories
Provide Text Alternatives
<figure>
<Chart data={data} />
<figcaption className="sr-only">
{generateTextDescription(data)}
</figcaption>
</figure>Generate Accessible Descriptions
const generateTextDescription = (data: DataPoint[]) => {
const trend = calculateTrend(data);
const max = Math.max(...data.map(d => d.value));
const maxMonth = data.find(d => d.value === max)?.month;
return `
Line chart showing ${data.length} months of sales data.
Overall trend is ${trend > 0 ? 'increasing' : 'decreasing'} by ${Math.abs(trend)}%.
Peak occurred in ${maxMonth} with ${max.toLocaleString()} sales.
`;
};Checklist: Is Your Chart Telling a Story?
- [ ] Clear headline - Insight stated upfront, not buried
- [ ] Context provided - Why should we care?
- [ ] Key points annotated - Don't make users hunt
- [ ] Comparison included - Better than what? Worse than when?
- [ ] Action suggested - So what should we do?
- [ ] Accessible - Text alternative for screen readers
- [ ] Tested - Real users understand the story
Resources
- The Pudding - Masters of visual storytelling
- FlowingData - Narrative-driven data viz
- NYT Graphics - Industry standard
- Observable - Interactive data stories
Summary
Data storytelling transforms charts from "here's data" to "here's what it means and what we should do."
The Formula: 1. Lead with the insight 2. Provide context 3. Show evidence (the chart) 4. Suggest action
The Tools:
- Annotations to guide the eye
- Color to reinforce meaning
- Progressive disclosure to manage complexity
- Scrollytelling for narrative arc
- Micro-interactions for engagement
Remember: Your job isn't to show data. It's to change minds.
React Data Visualization Libraries: Comprehensive Comparison (2025)
A deep dive into the top 5 data visualization libraries for React/Next.js/TypeScript applications.
Executive Summary
| Library | Best For | Bundle Size | Learning Curve | TypeScript | SSR Support |
|---|---|---|---|---|---|
| Observable Plot | Exploratory analysis, notebooks | 📦 ~180KB | ⚡ Low | ✅ Excellent | ⚠️ Partial |
| Recharts | Standard business charts | 📦 ~380KB | ⚡⚡ Very Low | ✅ Built-in | ✅ Yes |
| Nivo | Beautiful, themed dashboards | 📦 ~420KB | ⚡⚡ Low | ✅ Good | ✅ Yes (unique!) |
| Visx | Custom, bespoke visualizations | 📦 ~450KB | ⚡⚡⚡ Medium | ✅ Excellent | ✅ Yes |
| D3.js | Maximum control & flexibility | 📦 ~240KB | ⚡⚡⚡⚡ High | ⚠️ Requires @types | ⚠️ Complex |
Observable Plot
What it is: A JavaScript library for exploratory data visualization implementing a layered grammar of graphics (inspired by ggplot2, Vega-Lite).
Created by: Observable (the team behind D3.js)
Philosophy: Plot doesn't have "chart types." Instead, it has geometric marks (bars, dots, lines, areas) that you compose with scales and transforms.
Strengths
- Fastest prototyping - Make 50 charts in an hour to find the right one
- Declarative syntax - Describe what you want, not how to draw it
- Powerful transforms - Built-in binning, grouping, stacking, dodging
- Excellent for notebooks - Perfect for Observable, Jupyter, or local experiments
- Highly composable - Mix marks, facets, and annotations easily
Weaknesses
- Less React-friendly - Requires
useEffect+useRefpattern - Newer library - Smaller community than Recharts
- Limited theming - Less control over styling than Nivo
- SSR requires workarounds - Use
documentoption for React SSR
When to Choose Observable Plot
✅ You're doing exploratory data analysis ✅ You want a ggplot2-like experience in JavaScript ✅ You're prototyping 10 different chart styles quickly ✅ You're working in Observable notebooks ❌ You need pixel-perfect custom styling ❌ You're building a production dashboard with complex theming
Code Example
import * as Plot from "@observablehq/plot";
import { useEffect, useRef } from "react";
export function SalesChart({ data }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!data || !containerRef.current) return;
const plot = Plot.plot({
marks: [
Plot.lineY(data, { x: "date", y: "sales", stroke: "#d97706" }),
Plot.ruleY([0])
],
width: 640,
height: 400,
marginLeft: 50
});
containerRef.current.append(plot);
return () => plot.remove();
}, [data]);
return <div ref={containerRef} />;
}Best Resources
Recharts
What it is: A composable charting library built with React and D3. The most popular React charting library (24.8K GitHub stars).
Created by: Community-driven open source project
Philosophy: Everything is a React component. Build charts by composing <LineChart>, <XAxis>, <Tooltip>, etc.
Strengths
- Easiest learning curve - If you know React, you know Recharts
- Excellent documentation - Clear examples, interactive playground
- Huge community - Most questions already answered on StackOverflow
- TypeScript first-class - Built-in types, excellent IDE support
- Responsive by default -
<ResponsiveContainer>handles sizing - Batteries included - Tooltips, legends, animations out of the box
Weaknesses
- SVG only - No Canvas option for large datasets (>1000 points)
- Limited mobile gestures - No built-in swipe/pinch support
- Styling can be verbose - Many props to customize appearance
- Bundle size - Larger than Plot, though still reasonable
When to Choose Recharts
✅ You want the simplest React integration ✅ You're building standard business charts (bars, lines, areas, pies) ✅ You need excellent documentation and community support ✅ You're new to data visualization libraries ✅ You prioritize developer experience over visual polish ❌ You need Canvas rendering for huge datasets ❌ You want stunning defaults without customization
Code Example
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
export function SalesChart({ data }) {
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="sales"
stroke="#d97706"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
);
}Best Resources
Nivo
What it is: A React charting library providing rich components with beautiful defaults, themes, and animations.
Created by: Raphaël Benitte
Philosophy: Beauty and flexibility out of the box. Extensive props for customization, but sensible defaults look great.
Strengths
- Gorgeous defaults - Best-looking charts without customization
- Multiple rendering modes - SVG, Canvas, or HTML
- Server-side rendering - Unique feature! Generate charts on the server
- HTTP rendering API - Generate chart images via API calls
- Extensive chart types - 20+ chart types including advanced ones
- Motion/animations - Smooth transitions powered by React Spring
- Storybook integration - Interactive component playground
Weaknesses
- Larger bundle size - Most features = more bytes
- Different API style - Single component with many props (not composable like Recharts)
- Can feel heavyweight - Overkill for simple charts
- Learning curve - Many props to understand
When to Choose Nivo
✅ You want visually stunning charts immediately ✅ You need server-side rendering support ✅ You're building a dashboard with consistent theming ✅ You need both SVG and Canvas rendering options ✅ You want advanced chart types (Sankey, Chord, Network) ❌ Bundle size is critical (use Recharts instead) ❌ You prefer composable API (use Recharts instead)
Code Example
import { ResponsiveLine } from '@nivo/line';
export function SalesChart({ data }) {
// Nivo expects different data format
const nivoData = [
{
id: "sales",
data: data.map(d => ({ x: d.date, y: d.sales }))
}
];
return (
<div style={{ height: 400 }}>
<ResponsiveLine
data={nivoData}
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
xScale={{ type: 'point' }}
yScale={{ type: 'linear', min: 'auto', max: 'auto' }}
curve="monotoneX"
axisBottom={{
tickRotation: 0,
legend: 'Date',
legendOffset: 36
}}
axisLeft={{
tickRotation: 0,
legend: 'Sales',
legendOffset: -40
}}
colors="#d97706"
pointSize={8}
pointColor="#fff"
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enableGridX={false}
enableGridY={true}
useMesh={true}
theme={{
axis: {
ticks: {
line: { stroke: '#e5e7eb' },
text: { fill: '#6b7280' }
}
},
grid: {
line: { stroke: '#f3f4f6', strokeWidth: 1 }
}
}}
/>
</div>
);
}Best Resources
- Official Documentation
- Component Playground - Interactive prop tweaking
- Storybook
Visx
What it is: A collection of low-level visualization primitives for React (created by Airbnb).
Created by: Airbnb
Philosophy: Provide React components for D3 primitives (scales, axes, shapes) but let you compose them however you want.
Strengths
- Maximum flexibility - Build exactly what you need
- React patterns - Hooks, composition, props (not D3's imperative style)
- Modular - Import only what you need, tree-shake aggressively
- Performance - Low-level control for optimization
- Great for custom viz - When standard charts aren't enough
- Excellent TypeScript - First-class types throughout
Weaknesses
- More code required - No pre-built chart components
- Steeper learning curve - Must understand both D3 concepts and React
- Less documentation - Fewer examples than Recharts/Nivo
- Bare-bones defaults - You build everything from scratch
When to Choose Visx
✅ You're building a unique, custom visualization ✅ You need fine-grained control over every pixel ✅ You want React patterns, not D3's imperative approach ✅ You're comfortable with D3 concepts (scales, generators, etc.) ✅ Performance optimization is critical ❌ You want pre-built chart components ❌ You're new to data visualization
Code Example
import { Group } from '@visx/group';
import { LinePath } from '@visx/shape';
import { scaleTime, scaleLinear } from '@visx/scale';
import { AxisBottom, AxisLeft } from '@visx/axis';
import { GridRows } from '@visx/grid';
export function SalesChart({ data, width = 640, height = 400 }) {
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const xMax = width - margin.left - margin.right;
const yMax = height - margin.top - margin.bottom;
const xScale = scaleTime({
domain: [Math.min(...data.map(d => d.date)), Math.max(...data.map(d => d.date))],
range: [0, xMax]
});
const yScale = scaleLinear({
domain: [0, Math.max(...data.map(d => d.sales))],
range: [yMax, 0],
nice: true
});
return (
<svg width={width} height={height}>
<Group left={margin.left} top={margin.top}>
<GridRows scale={yScale} width={xMax} stroke="#f3f4f6" />
<AxisBottom top={yMax} scale={xScale} />
<AxisLeft scale={yScale} />
<LinePath
data={data}
x={d => xScale(d.date)}
y={d => yScale(d.sales)}
stroke="#d97706"
strokeWidth={2}
/>
</Group>
</svg>
);
}Best Resources
D3.js
What it is: The foundational data visualization library for the web. Everything else is built on top of D3.
Created by: Mike Bostock (now maintains Observable)
Philosophy: Data-driven documents. Bind data to DOM elements, apply transformations, handle updates.
Strengths
- Unlimited power - Can build literally anything
- Industry standard - Most examples, tutorials, and resources
- Mature ecosystem - 14+ years of development
- Modular - Use just what you need (d3-scale, d3-shape, etc.)
- Smaller bundle - Importing only needed modules keeps size down
- Advanced features - Force simulations, geo projections, hierarchies
Weaknesses
- Steepest learning curve - Imperative API, different mental model
- Not React-friendly - Direct DOM manipulation conflicts with React
- Verbose - More code for simple charts
- Requires workarounds - Need
useEffect+useRefin React - TypeScript requires extra work - Must install
@types/d3
When to Choose D3.js
✅ You need maximum control and flexibility ✅ You're building something completely custom ✅ You're already comfortable with D3 ✅ You need advanced features (force-directed graphs, geo maps) ✅ You want the smallest possible bundle (cherry-pick modules) ❌ You're new to data visualization ❌ You want React-friendly components ❌ You need something working quickly
Code Example
import * as d3 from 'd3';
import { useEffect, useRef } from 'react';
export function SalesChart({ data }) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!data || !svgRef.current) return;
const svg = d3.select(svgRef.current);
const margin = { top: 20, right: 20, bottom: 40, left: 50 };
const width = 640 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
svg.selectAll("*").remove(); // Clear previous render
const g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const x = d3.scaleTime()
.domain(d3.extent(data, d => d.date) as [Date, Date])
.range([0, width]);
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.sales) as number])
.range([height, 0]);
const line = d3.line<DataPoint>()
.x(d => x(d.date))
.y(d => y(d.sales))
.curve(d3.curveMonotoneX);
g.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x));
g.append("g")
.call(d3.axisLeft(y));
g.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "#d97706")
.attr("stroke-width", 2)
.attr("d", line);
}, [data]);
return <svg ref={svgRef} width={640} height={400} />;
}Best Resources
Comparison Matrix
Performance
| Library | Small Dataset (<100) | Medium (100-1K) | Large (1K-10K) | Huge (>10K) |
|---|---|---|---|---|
| Observable Plot | ⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡ | ⚡ |
| Recharts | ⚡⚡⚡ | ⚡⚡ | ⚡ | ❌ (SVG only) |
| Nivo | ⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡ (Canvas) | ⚡ (Canvas) |
| Visx | ⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡ | ⚡ (with optimization) |
| D3.js | ⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡⚡ (Canvas) | ⚡⚡ (WebGL possible) |
Feature Comparison
| Feature | Observable Plot | Recharts | Nivo | Visx | D3.js |
|---|---|---|---|---|---|
| React Integration | Manual | Native | Native | Native | Manual |
| TypeScript | ✅ Built-in | ✅ Built-in | ✅ Built-in | ✅ Built-in | ⚠️ Requires @types |
| SSR Support | ⚠️ Workaround | ✅ Yes | ✅ Yes (unique!) | ✅ Yes | ⚠️ Complex |
| Responsive | Manual | ✅ Easy | ✅ Easy | ✅ Easy | Manual |
| Animations | Limited | ✅ Built-in | ✅ Excellent | Manual | ✅ Transitions |
| Tooltips | Manual | ✅ Built-in | ✅ Built-in | Manual | Manual |
| Themes | Limited | ⚠️ Via props | ✅ Excellent | Manual | Manual |
| Canvas Rendering | ❌ No | ❌ No | ✅ Yes | ✅ Manual | ✅ Yes |
| Accessibility | Manual | ⚠️ Basic | ⚠️ Basic | Manual | Manual |
Chart Types Support
| Chart Type | Observable Plot | Recharts | Nivo | Visx | D3.js |
|---|---|---|---|---|---|
| Bar | ✅ | ✅ | ✅ | ✅ | ✅ |
| Line | ✅ | ✅ | ✅ | ✅ | ✅ |
| Area | ✅ | ✅ | ✅ | ✅ | ✅ |
| Pie/Donut | ✅ | ✅ | ✅ | ✅ | ✅ |
| Scatter | ✅ | ✅ | ✅ | ✅ | ✅ |
| Heatmap | ✅ | ❌ | ✅ | ✅ | ✅ |
| Sankey | ❌ | ❌ | ✅ | ✅ | ✅ |
| Chord | ❌ | ❌ | ✅ | ✅ | ✅ |
| Network | ❌ | ❌ | ✅ | ✅ | ✅ |
| Treemap | ❌ | ❌ | ✅ | ✅ | ✅ |
| Sunburst | ❌ | ❌ | ✅ | ✅ | ✅ |
| Calendar | ❌ | ❌ | ✅ | ✅ | ✅ |
Decision Framework
Use Observable Plot when:
- 🔬 Doing exploratory data analysis
- 📊 Need to prototype 10+ chart variations quickly
- 🎓 Prefer grammar-of-graphics (ggplot2-style)
- 📓 Working in notebooks (Observable, Jupyter)
Use Recharts when:
- 🚀 Want fastest time to first chart
- 📱 Building responsive business dashboards
- 👥 Large team needs simple, well-documented library
- 🎯 Standard chart types are sufficient
- 💚 New to data visualization
Use Nivo when:
- 🎨 Visual polish is critical
- 🖥️ Need server-side rendering
- 🎭 Building themed dashboards
- 📊 Need advanced chart types (Sankey, Chord)
- 🎬 Want smooth animations out of the box
Use Visx when:
- 🎯 Building custom, unique visualizations
- ⚙️ Need fine-grained control
- 🏗️ Prefer React patterns over D3 imperative style
- 📦 Want modular, tree-shakeable imports
- ⚡ Performance optimization is critical
Use D3.js when:
- 🔓 Need maximum control and flexibility
- 🎨 Building completely novel visualizations
- 🌍 Need advanced features (force, geo, hierarchy)
- 📚 Already comfortable with D3
- 💎 Want smallest possible bundle (cherry-pick modules)
Hybrid Approaches
You don't have to choose just one library. Mix and match:
Recharts + D3 Scales
import { scaleLog } from 'd3-scale';
import { LineChart, Line, YAxis } from 'recharts';
// Use D3's log scale with Recharts
const logScale = scaleLog().domain([1, 1000000]).range([0, 400]);
<LineChart data={data}>
<YAxis scale={logScale} />
<Line dataKey="value" />
</LineChart>Nivo + Custom SVG
import { ResponsiveLine } from '@nivo/line';
<ResponsiveLine
data={data}
layers={[
'grid',
'markers',
'axes',
'areas',
'lines',
'points',
({ innerWidth, innerHeight }) => (
// Custom SVG layer
<circle cx={innerWidth / 2} cy={innerHeight / 2} r={20} fill="red" />
)
]}
/>Sources
- Nivo vs Recharts Comparison | Speakeasy
- Best React Chart Libraries 2025 | Creole Studios
- Comparison of Data Visualization Libraries for React | Capital One
- Best React Chart Libraries 2025 | Embeddable
- Best React Chart Libraries 2025 | LogRocket
- Observable Plot Documentation
- Observable's 2025 Year in Review
Testing Data Visualizations: Comprehensive Strategies
Data visualizations are notoriously difficult to test. They combine visual appearance, data accuracy, interactivity, and accessibility. This guide covers all testing approaches.
Testing Philosophy
Three Dimensions of Testing
1. Data Accuracy - Does the chart represent data correctly? 2. Visual Appearance - Does it look right? (Regression testing) 3. Interaction & Accessibility - Can users interact with it? Is it accessible?
The Testing Pyramid for Data Viz
/\
/ \ E2E Visual Tests (Percy, Chromatic)
/────\ ← Expensive, slow, comprehensive
/ \
/ Unit & \ Component + Integration Tests
/ Component\ ← Fast, focused, data accuracy
/────────────\
/ \ Property-based & Snapshot Tests
/________________\ ← Cheapest, fastest, catches regressions1. Data Accuracy Testing
Verify Rendered Elements Match Data
import { render, screen } from '@testing-library/react';
import { BarChart } from './BarChart';
describe('BarChart - Data Accuracy', () => {
const mockData = [
{ category: 'A', value: 10 },
{ category: 'B', value: 20 },
{ category: 'C', value: 15 }
];
test('renders correct number of bars', () => {
render(<BarChart data={mockData} />);
const bars = screen.getAllByTestId('bar');
expect(bars).toHaveLength(3);
});
test('bar heights are proportional to values', () => {
render(<BarChart data={mockData} />);
const bars = screen.getAllByTestId('bar');
const heights = bars.map(b =>
parseInt(b.getAttribute('height') || '0')
);
// B should be 2x A (20 vs 10)
expect(heights[1]).toBe(heights[0] * 2);
// C should be 1.5x A (15 vs 10)
expect(heights[2]).toBe(heights[0] * 1.5);
});
test('labels match data categories', () => {
render(<BarChart data={mockData} />);
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});Test Scale Calculations
describe('BarChart - Scale Accuracy', () => {
test('y-axis scale is correct', () => {
const data = [
{ x: 'A', y: 0 },
{ x: 'B', y: 50 },
{ x: 'C', y: 100 }
];
render(<BarChart data={data} height={200} />);
const bars = screen.getAllByTestId('bar');
// First bar (y=0) should be at bottom (height 200)
expect(bars[0].getAttribute('y')).toBe('200');
// Middle bar (y=50) should be at middle (height 100)
expect(bars[1].getAttribute('y')).toBe('100');
// Last bar (y=100) should be at top (height 0)
expect(bars[2].getAttribute('y')).toBe('0');
});
});Test Edge Cases
describe('BarChart - Edge Cases', () => {
test('handles empty data gracefully', () => {
render(<BarChart data={[]} />);
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
test('handles single data point', () => {
render(<BarChart data={[{ x: 'A', y: 10 }]} />);
const bars = screen.getAllByTestId('bar');
expect(bars).toHaveLength(1);
});
test('handles negative values', () => {
const data = [
{ x: 'A', y: -10 },
{ x: 'B', y: 20 }
];
render(<BarChart data={data} />);
// Verify baseline is drawn at zero
expect(screen.getByTestId('baseline')).toBeInTheDocument();
});
test('handles very large values without overflow', () => {
const data = [{ x: 'A', y: 1000000 }];
render(<BarChart data={data} />);
const bar = screen.getByTestId('bar');
const height = parseInt(bar.getAttribute('height') || '0');
// Should fit within chart bounds
expect(height).toBeLessThanOrEqual(400);
});
});2. Visual Regression Testing
Percy (Recommended for Most Projects)
Percy takes screenshots of your components and diffs them against baseline images.
Setup:
npm install --save-dev @percy/cli @percy/storybookStorybook Stories:
// BarChart.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { BarChart } from './BarChart';
const meta: Meta<typeof BarChart> = {
title: 'Charts/BarChart',
component: BarChart,
parameters: {
percy: {
skip: false,
widths: [375, 768, 1280] // Mobile, tablet, desktop
}
}
};
export default meta;
type Story = StoryObj<typeof BarChart>;
export const Default: Story = {
args: {
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 20 },
{ x: 'Mar', y: 15 }
]
}
};
export const Empty: Story = {
args: {
data: []
}
};
export const LargeDataset: Story = {
args: {
data: Array.from({ length: 50 }, (_, i) => ({
x: `Item ${i}`,
y: Math.random() * 100
}))
}
};
export const DarkMode: Story = {
args: {
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 20 }
]
},
parameters: {
backgrounds: { default: 'dark' }
}
};Run Percy:
# Build Storybook
npm run build-storybook
# Take snapshots
npx percy storybook ./storybook-staticCI Integration (GitHub Actions):
# .github/workflows/visual-tests.yml
name: Visual Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run build-storybook
- run: npx percy storybook ./storybook-static
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}Chromatic (Best for Storybook-First Teams)
Chromatic is built specifically for Storybook with tighter integration.
Setup:
npm install --save-dev chromaticRun:
npx chromatic --project-token=<your-token>Benefits over Percy:
- Automatic Storybook detection
- Component-level testing
- Interaction testing support
- TurboSnap (only tests changed components)
Playwright Visual Comparisons (For E2E Tests)
// chart.visual.spec.ts
import { test, expect } from '@playwright/test';
test('bar chart renders correctly', async ({ page }) => {
await page.goto('/charts/bar');
// Wait for chart to render
await page.waitForSelector('[data-testid="bar-chart"]');
// Take screenshot
await expect(page).toHaveScreenshot('bar-chart-default.png', {
maxDiffPixels: 100 // Allow small differences
});
});
test('chart updates on filter change', async ({ page }) => {
await page.goto('/dashboard');
// Initial state
await expect(page).toHaveScreenshot('chart-before-filter.png');
// Apply filter
await page.click('[data-testid="filter-button"]');
await page.waitForTimeout(500); // Wait for animation
// After filter
await expect(page).toHaveScreenshot('chart-after-filter.png');
});3. Interaction Testing
User Interactions
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('BarChart - Interactions', () => {
test('shows tooltip on hover', async () => {
const user = userEvent.setup();
render(<BarChart data={mockData} />);
const bar = screen.getAllByTestId('bar')[0];
// Hover over bar
await user.hover(bar);
// Tooltip appears
await waitFor(() => {
expect(screen.getByRole('tooltip')).toBeInTheDocument();
expect(screen.getByText(/value: 10/i)).toBeInTheDocument();
});
});
test('hides tooltip on mouse leave', async () => {
const user = userEvent.setup();
render(<BarChart data={mockData} />);
const bar = screen.getAllByTestId('bar')[0];
await user.hover(bar);
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument());
await user.unhover(bar);
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
});
test('calls onClick handler when bar is clicked', async () => {
const handleClick = jest.fn();
render(<BarChart data={mockData} onBarClick={handleClick} />);
const bar = screen.getAllByTestId('bar')[1];
fireEvent.click(bar);
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({ category: 'B', value: 20 })
);
});
});Keyboard Navigation
describe('BarChart - Keyboard Navigation', () => {
test('bars are focusable via Tab key', async () => {
const user = userEvent.setup();
render(<BarChart data={mockData} />);
// Tab to first bar
await user.tab();
expect(screen.getAllByTestId('bar')[0]).toHaveFocus();
// Tab to second bar
await user.tab();
expect(screen.getAllByTestId('bar')[1]).toHaveFocus();
});
test('Enter key activates bar', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<BarChart data={mockData} onBarClick={handleClick} />);
await user.tab(); // Focus first bar
await user.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({ category: 'A', value: 10 })
);
});
});4. Accessibility Testing
Automated Accessibility Tests
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('BarChart - Accessibility', () => {
test('has no axe violations', async () => {
const { container } = render(<BarChart data={mockData} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('has proper ARIA labels', () => {
render(<BarChart data={mockData} title="Sales by Month" />);
// Chart container has role
expect(screen.getByRole('img')).toBeInTheDocument();
// Title is associated
expect(screen.getByLabelText(/sales by month/i)).toBeInTheDocument();
});
test('provides text alternative for screen readers', () => {
render(<BarChart data={mockData} />);
// Data table alternative is available
expect(screen.getByRole('table', { hidden: true })).toBeInTheDocument();
});
});Color Contrast Verification
test('meets color contrast requirements', () => {
render(<BarChart data={mockData} />);
const bar = screen.getAllByTestId('bar')[0];
const barColor = window.getComputedStyle(bar).fill;
// Use color-contrast library or manual calculation
const contrastRatio = calculateContrastRatio(barColor, '#ffffff');
// WCAG AA requires 3:1 for large text/graphics
expect(contrastRatio).toBeGreaterThanOrEqual(3);
});5. Performance Testing
Rendering Performance
describe('BarChart - Performance', () => {
test('renders large dataset in < 500ms', () => {
const largeData = Array.from({ length: 1000 }, (_, i) => ({
x: `Item ${i}`,
y: Math.random() * 100
}));
const start = performance.now();
render(<BarChart data={largeData} />);
const end = performance.now();
expect(end - start).toBeLessThan(500);
});
test('does not re-render on unrelated prop changes', () => {
const { rerender } = render(
<BarChart data={mockData} unrelatedProp="value1" />
);
const renderSpy = jest.spyOn(React, 'createElement');
// Change unrelated prop
rerender(<BarChart data={mockData} unrelatedProp="value2" />);
// Chart should be memoized and not re-render
expect(renderSpy).toHaveBeenCalledTimes(0);
});
});Memory Leak Detection
test('cleans up event listeners on unmount', () => {
const { unmount } = render(<BarChart data={mockData} />);
const initialListeners = getEventListeners(window).length;
unmount();
const finalListeners = getEventListeners(window).length;
// Should remove all listeners
expect(finalListeners).toBeLessThanOrEqual(initialListeners);
});6. Snapshot Testing
Use snapshots to catch unintended changes in SVG output.
import { render } from '@testing-library/react';
describe('BarChart - Snapshots', () => {
test('matches snapshot', () => {
const { container } = render(<BarChart data={mockData} />);
expect(container.firstChild).toMatchSnapshot();
});
test('matches snapshot with custom colors', () => {
const { container } = render(
<BarChart
data={mockData}
colors={['#d97706', '#7c3aed', '#059669']}
/>
);
expect(container.firstChild).toMatchSnapshot();
});
});Note: Snapshots are brittle. Use sparingly and update intentionally.
7. Cross-Browser Testing
BrowserStack/Sauce Labs
// wdio.conf.js
exports.config = {
services: ['browserstack'],
capabilities: [
{
browserName: 'chrome',
'bstack:options': {
os: 'Windows',
osVersion: '11'
}
},
{
browserName: 'safari',
'bstack:options': {
os: 'OS X',
osVersion: 'Ventura'
}
}
],
specs: ['./test/charts/**/*.spec.ts']
};8. Property-Based Testing
Test with randomly generated data to find edge cases.
import { fc } from 'fast-check';
describe('BarChart - Property-Based Tests', () => {
test('handles any array of valid data points', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
x: fc.string(),
y: fc.integer({ min: 0, max: 1000 })
}),
{ minLength: 1, maxLength: 100 }
),
(data) => {
const { container } = render(<BarChart data={data} />);
// Should render without crashing
expect(container.querySelector('svg')).toBeInTheDocument();
// Number of bars should match data length
const bars = container.querySelectorAll('[data-testid="bar"]');
expect(bars.length).toBe(data.length);
}
)
);
});
});Testing Checklist
Before shipping a chart component:
Data Accuracy
- [ ] Correct number of elements rendered
- [ ] Visual encoding proportional to data values
- [ ] Labels match data
- [ ] Scales are accurate
- [ ] Edge cases handled (empty, single point, negatives, huge values)
Visual Appearance
- [ ] Percy/Chromatic snapshots passing
- [ ] Renders correctly on mobile, tablet, desktop
- [ ] Dark mode support tested
- [ ] Animations don't break visual tests
Interactions
- [ ] Hover states work
- [ ] Click handlers fire correctly
- [ ] Keyboard navigation functional
- [ ] Touch gestures work on mobile
Accessibility
- [ ] No axe violations
- [ ] Proper ARIA roles and labels
- [ ] Keyboard accessible
- [ ] Screen reader compatible
- [ ] Color contrast ≥3:1
- [ ] Respects prefers-reduced-motion
- [ ] Provides data table alternative
Performance
- [ ] Renders large datasets in <500ms
- [ ] No unnecessary re-renders
- [ ] No memory leaks
- [ ] Bundle size reasonable
Cross-Browser
- [ ] Works in Chrome, Firefox, Safari, Edge
- [ ] Works on iOS Safari, Android Chrome
- [ ] No console errors
Continuous Integration Example
# .github/workflows/chart-tests.yml
name: Chart Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm test -- --coverage
- uses: codecov/codecov-action@v3
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run build-storybook
- run: npx percy storybook ./storybook-static
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
accessibility-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run test:a11y
performance-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run test:perfSources
Edward Tufte's Data Visualization Principles
Edward Tufte is Professor Emeritus of Political Science, Statistics, and Computer Science at Yale University. The New York Times described him as the "Leonardo da Vinci of data," and Bloomberg as the "Galileo of graphics."
His work, particularly "The Visual Display of Quantitative Information" (1983), is foundational to modern data visualization.
Core Principle: Maximize Data-Ink Ratio
Data-ink ratio = (Data-ink) / (Total ink used in graphic)
Where:
- Data-ink - Ink that represents actual data values
- Non-data-ink - Decorative elements, gridlines, borders, backgrounds
Goal: Approach 1.0
Every drop of ink should represent data. Remove everything else.
Examples
❌ Low Data-Ink Ratio (Bad):
┌─────────────────────────────────┐
│ ╔═════════════════════════════╗ │
│ ║ SALES PERFORMANCE ║ │ ← Heavy borders
│ ╚═════════════════════════════╝ │
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
│ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░ ┃ │ ← Background pattern
│ ┃ ┃ ▓▓ ▓▓▓ ▓▓ ▓▓▓▓ ┃ │ ← 3D bars with gradients
│ ┃ ┃ ▓▓ ▓▓▓ ▓▓ ▓▓▓▓ ┃ │
│ ┃ ┃ ▓▓ ▓▓▓ ▓▓ ▓▓▓▓ ┃ │
│ ┃ └──┴────┴───┴───┴──┴───── ┃ │ ← Heavy gridlines
│ ┃ Q1 Q2 Q3 Q4 ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
└─────────────────────────────────┘
Data-ink ratio: ~0.2 (80% is decoration)✅ High Data-Ink Ratio (Good):
Sales
250─ ╭──╮
200─ │ │ ╭──╮
150─ │ │ │ │
100─╭─│ │ │ │╭─╮
50─│ │ │ │ ││ │
0─┴─┴──┴──┴──┴┴─┴
Q1 Q2 Q3 Q4
Data-ink ratio: ~0.85 (most ink is data)Practical Implementation
// ❌ BAD - Unnecessary decorations
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.5} /> {/* Remove */}
<XAxis stroke="#666" strokeWidth={2} /> {/* Too heavy */}
<YAxis stroke="#666" strokeWidth={2} />
<Bar dataKey="value" fill="#d97706">
<LabelList position="top" /> {/* Redundant with axis */}
</Bar>
<Tooltip {/* Can stay - provides info */}
contentStyle={{
border: '2px solid #000', {/* Remove heavy border */}
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)' {/* Remove shadow */}
}}
/>
</BarChart>
// ✅ GOOD - Data-focused
<BarChart data={data}>
<XAxis stroke="#d1d5db" strokeWidth={1} /> {/* Subtle, 1px */}
<YAxis stroke="#d1d5db" strokeWidth={1} />
<Bar dataKey="value" fill="#d97706" />
<Tooltip {/* Minimal styling */}
contentStyle={{
backgroundColor: 'white',
border: '1px solid #e5e7eb'
}}
/>
</BarChart>Six Principles of Graphical Integrity
1. Proportional Representation
The representation of numbers should be directly proportional to the numerical quantities represented.
❌ Bad Example: Misleading Area
// Using radius instead of area for circles
// 2x the data = 2x the radius = 4x the visual area!
<Circle r={value} /> // WRONG✅ Good Example: Correct Proportions
// 2x the data = 2x the area
<Circle r={Math.sqrt(value / Math.PI)} /> // CORRECT
// Or just use bars (linear representation)
<Bar height={value} />2. Clear Labeling
Clear, detailed, and thorough labeling defeats graphical distortion and ambiguity.
❌ Bad: Legend Matching Required
Sales by Region
─────────────
╭──╮
╭─│ │
──│ │ │──
┴─┴──┴
Q1 Q2 Q3
Legend:
■ North ■ South ■ East ■ WestUser must match colors to legend, slows comprehension
✅ Good: Direct Labels
Sales by Region
─────────────
╭──╮
╭─│ │ East $2.3M
──│ │ │──
┴─┴──┴
North South
$1.8M $2.1MImmediate understanding, no legend required
3. Show Data Variation, Not Design Variation
Visual elements should encode data values, not arbitrary design choices.
❌ Bad: Inconsistent Visual Encoding
- Using different chart types for same data
- Varying bar widths randomly
- Changing colors without meaning
✅ Good: Consistent Visual Grammar
- Same chart type for comparable data
- Uniform bar widths (only height varies)
- Color encodes consistent categorical dimension
4. Context Preservation
Graphics must not quote data out of context.
❌ Bad: Cherry-Picked Time Range
Stock Price SOARS! 📈
$100 ─ ╭─
$95 ─ ╭───╯
$90 ─ ╭───╯
─────┴───┴───
Mon Tue Wed
Missing: Stock was $200 last year, down 50%✅ Good: Full Context
Stock Price Recovery (Still Down YoY)
$200 ─╮
$150 ─ ╰─╮
$100 ─ ╰────╮ ╭─
$50 ─ ╰────╯
─┴───┴───┴────┴──
Jan Jun Dec Mar
2024 2025
Shows recovery in context of larger decline5. Show Cause and Effect
The number of dimensions in graphics should match the number of dimensions in data.
❌ Bad: 3D for 2D Data
Using 3D pie chart for simple percentages
→ Perspective distorts slice sizes
→ Back slices appear smaller
→ Adds dimension that doesn't exist in data✅ Good: 2D for 2D Data
Simple 2D pie or (better) horizontal bar chart
→ Accurate size perception
→ Easy comparison6. Label Important Events
Include labels for context-critical events in the data timeline.
<LineChart data={data}>
<ReferenceLine
x="2020-03-15"
stroke="#dc2626"
label="Pandemic Declared"
/>
<ReferenceLine
x="2021-12-01"
stroke="#059669"
label="Vaccine Rollout"
/>
{/* These annotations provide crucial context */}
</LineChart>Chartjunk: What to Remove
Common Chartjunk Elements
1. Heavy gridlines - If needed at all, make them subtle (stroke: 1px, opacity: 0.1) 2. 3D effects - Serve no purpose except confusion 3. Gradients in bars - Suggest variation that doesn't exist 4. Decorative borders - Waste space and ink 5. Background patterns - Distract from data 6. Drop shadows - Add visual weight without meaning 7. Redundant labels - If axis shows value, don't also label each bar 8. Moiré vibration - High-frequency patterns that vibrate visually
Implementation Checklist
Before shipping a chart:
- [ ] Remove all gridlines (or make them nearly invisible)
- [ ] Remove 3D effects, gradients, shadows
- [ ] Remove decorative borders and backgrounds
- [ ] Use direct labels instead of legends where possible
- [ ] Ensure Y-axis starts at zero for bar charts
- [ ] Check that visual area is proportional to data values
- [ ] Add annotations for important events or thresholds
- [ ] Verify color contrast meets accessibility standards
- [ ] Test on mobile - does it still work?
Small Multiples: Tufte's Favorite Technique
Instead of one overloaded chart, create many small charts arranged in a grid.
Why Small Multiples Work
1. Comparison is easy - Same axes, different subsets 2. Scales better - 20 categories → 20 small charts, not one chaos chart 3. Reduces cognitive load - Each chart is simple 4. Reveals patterns - Macro trends emerge from arrangement
Example: Sparklines
Tufte championed "sparklines" - tiny line charts embedded in text.
// Word-sized charts in a table
<table>
<tr>
<td>Product A</td>
<td>$2.3M</td>
<td><Sparkline data={productA} width={50} height={20} /></td>
</tr>
<tr>
<td>Product B</td>
<td>$1.8M</td>
<td><Sparkline data={productB} width={50} height={20} /></td>
</tr>
</table>Example: Faceted Charts
// Instead of one line chart with 12 overlapping lines (one per month)
// Create 12 small charts (one per month) arranged in a 3×4 grid
<div className="grid grid-cols-4 gap-2">
{months.map(month => (
<div key={month} className="border border-gray-200 p-2">
<h3 className="text-xs font-medium">{month}</h3>
<LineChart data={dataForMonth[month]} width={120} height={80}>
<Line dataKey="value" stroke="#d97706" dot={false} />
</LineChart>
</div>
))}
</div>Color Theory (Tufte Approach)
Use Color Sparingly
"Color should be used with restraint and purpose, not decoration."
Color Guidelines
1. Muted palette - Avoid neon, use desaturated colors 2. Highlight with color - Gray for context, color for focus 3. Sequential data - Use gradations of one hue 4. Categorical data - Use maximally distinct hues 5. Diverging data - Use two hues meeting at neutral center
// ✅ Good: Muted with one accent
const colors = {
background: '#fafafa',
grid: '#e5e7eb', // Barely visible
bars: '#9ca3af', // Gray for most bars
highlight: '#d97706' // Orange for important bar
};
// ❌ Bad: Rainbow vomit
const colors = [
'#ff0000', '#ff7700', '#ffff00', '#00ff00',
'#0000ff', '#4b0082', '#9400d3', '#ff00ff'
];Real-World Examples
Before & After: Sales Dashboard
❌ Before (Chartjunk):
- Heavy borders around everything
- 3D bar charts with gradients
- Gridlines every 10 units (very visible)
- Legend at bottom (requires color matching)
- Background pattern behind charts
- Drop shadows on all elements
- Six different colors for six regions
✅ After (Tufte Principles):
- No borders, cards on white background
- 2D bars, solid color
- No gridlines (or invisible ones)
- Direct labels on bars
- Clean white background
- No shadows
- Gray for all bars except top performer (orange)
Result: 3x faster comprehension in user testing, 40% more insights remembered.
Further Reading
- "The Visual Display of Quantitative Information" (1983) - The foundational text
- "Envisioning Information" (1990) - Multi-dimensional data
- "Visual Explanations" (1997) - Cause and effect
- "Beautiful Evidence" (2006) - Making analytical presentations
Sources
/**
* Chart Testing Utilities
*
* Helper functions for testing data visualizations:
* - Scale accuracy verification
* - Data-visual correspondence checks
* - Accessibility testing helpers
* - Performance benchmarking
*
* Usage:
* import { verifyScaleAccuracy, checkContrast } from './chart-test-helpers';
*/
export interface ChartElement {
getAttribute(name: string): string | null;
getBoundingClientRect(): DOMRect;
style: CSSStyleDeclaration;
}
/**
* Verify that visual heights are proportional to data values
*
* @example
* const bars = screen.getAllByTestId('bar');
* const data = [10, 20, 30];
* verifyScaleAccuracy(bars, data, 'height')
* // Returns true if heights are proportional
*/
export function verifyScaleAccuracy(
elements: ChartElement[],
dataValues: number[],
dimension: 'height' | 'width' | 'r' // r for circles
): boolean {
if (elements.length !== dataValues.length) {
throw new Error('Number of elements must match number of data values');
}
if (elements.length < 2) {
return true; // Can't verify proportions with < 2 elements
}
const visualValues = elements.map(el => {
const value = el.getAttribute(dimension);
return value ? parseFloat(value) : 0;
});
// Check if ratios between consecutive elements match data ratios
for (let i = 1; i < elements.length; i++) {
const dataRatio = dataValues[i] / dataValues[i - 1];
const visualRatio = visualValues[i] / visualValues[i - 1];
// Allow 5% tolerance for rounding errors
const tolerance = 0.05;
const diff = Math.abs(dataRatio - visualRatio);
if (diff > tolerance * dataRatio) {
console.error(
`Scale mismatch at index ${i}: data ratio ${dataRatio}, visual ratio ${visualRatio}`
);
return false;
}
}
return true;
}
/**
* Verify bar chart Y-axis starts at zero
*
* @example
* verifyBarChartBaselineAtZero(bars, chartHeight)
*/
export function verifyBarChartBaselineAtZero(
bars: ChartElement[],
chartHeight: number
): boolean {
// All bars should have y + height = chartHeight (bottom of chart)
return bars.every(bar => {
const y = parseFloat(bar.getAttribute('y') || '0');
const height = parseFloat(bar.getAttribute('height') || '0');
const bottom = y + height;
// Allow 1px tolerance for rounding
return Math.abs(bottom - chartHeight) <= 1;
});
}
/**
* Check color contrast ratio (WCAG AA)
*
* @example
* const bar = screen.getByTestId('bar');
* const backgroundColor = '#ffffff';
* checkContrast(bar, backgroundColor)
* // Returns true if contrast ≥ 3:1
*/
export function checkContrast(
element: ChartElement,
backgroundColor: string
): boolean {
const foregroundColor = element.style.fill || element.style.color;
if (!foregroundColor) {
console.warn('No foreground color found on element');
return false;
}
const ratio = calculateContrastRatio(foregroundColor, backgroundColor);
// WCAG AA requires 3:1 for large text/graphics
return ratio >= 3;
}
/**
* Calculate contrast ratio between two colors
*
* @example
* calculateContrastRatio('#000000', '#ffffff')
* // 21 (maximum contrast)
*/
export function calculateContrastRatio(
color1: string,
color2: string
): number {
const luminance1 = getRelativeLuminance(color1);
const luminance2 = getRelativeLuminance(color2);
const lighter = Math.max(luminance1, luminance2);
const darker = Math.min(luminance1, luminance2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Get relative luminance of a color (WCAG formula)
*/
function getRelativeLuminance(color: string): number {
// Convert color to RGB
const rgb = hexToRgb(color);
if (!rgb) return 0;
// Convert to sRGB
const rsRGB = rgb.r / 255;
const gsRGB = rgb.g / 255;
const bsRGB = rgb.b / 255;
// Apply gamma correction
const r =
rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4);
const g =
gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4);
const b =
bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4);
// Calculate luminance
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* Convert hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null;
}
/**
* Benchmark chart rendering performance
*
* @example
* const time = benchmarkRender(() => render(<BarChart data={largeDataset} />))
* expect(time).toBeLessThan(500) // < 500ms
*/
export function benchmarkRender(renderFn: () => void): number {
const start = performance.now();
renderFn();
const end = performance.now();
return end - start;
}
/**
* Verify all data points are rendered
*
* @example
* verifyDataPointCount(screen.getAllByTestId('bar'), mockData)
*/
export function verifyDataPointCount(
elements: ChartElement[],
data: any[]
): boolean {
return elements.length === data.length;
}
/**
* Verify labels match data
*
* @example
* const labels = screen.getAllByTestId('axis-label');
* verifyLabels(labels, ['Jan', 'Feb', 'Mar'])
*/
export function verifyLabels(
labelElements: Array<{ textContent: string | null }>,
expectedLabels: string[]
): boolean {
if (labelElements.length !== expectedLabels.length) {
console.error(
`Label count mismatch: ${labelElements.length} vs ${expectedLabels.length}`
);
return false;
}
return labelElements.every((el, i) => {
const actual = el.textContent?.trim();
const expected = expectedLabels[i];
if (actual !== expected) {
console.error(`Label mismatch at index ${i}: "${actual}" vs "${expected}"`);
return false;
}
return true;
});
}
/**
* Check if tooltip appears on hover
*
* @example
* await verifyTooltipBehavior(
* screen.getByTestId('bar'),
* () => screen.queryByRole('tooltip')
* )
*/
export async function verifyTooltipBehavior(
element: HTMLElement,
getTooltip: () => HTMLElement | null,
userEvent: any // @testing-library/user-event
): Promise<boolean> {
// Initially, no tooltip
if (getTooltip()) {
console.error('Tooltip is visible before hover');
return false;
}
// Hover - tooltip appears
await userEvent.hover(element);
await new Promise(resolve => setTimeout(resolve, 100)); // Wait for animation
if (!getTooltip()) {
console.error('Tooltip did not appear on hover');
return false;
}
// Unhover - tooltip disappears
await userEvent.unhover(element);
await new Promise(resolve => setTimeout(resolve, 100));
if (getTooltip()) {
console.error('Tooltip did not disappear on unhover');
return false;
}
return true;
}
/**
* Verify chart is responsive
*
* @example
* verifyResponsive(chartContainer, [375, 768, 1024])
*/
export function verifyResponsive(
container: HTMLElement,
breakpoints: number[]
): boolean {
const originalWidth = container.offsetWidth;
return breakpoints.every(width => {
// Resize container
container.style.width = `${width}px`;
// Force reflow
container.offsetHeight; // eslint-disable-line no-unused-expressions
// Check if chart adapted
const svg = container.querySelector('svg');
if (!svg) return false;
const svgWidth = svg.getBoundingClientRect().width;
// SVG should fill container (with some tolerance)
const widthMatches = Math.abs(svgWidth - width) < 5;
if (!widthMatches) {
console.error(
`Chart did not resize properly at ${width}px: SVG is ${svgWidth}px`
);
}
return widthMatches;
});
// Restore original width
container.style.width = `${originalWidth}px`;
}
/**
* Verify chart handles empty data gracefully
*
* @example
* verifyEmptyState(() => render(<Chart data={[]} />))
*/
export function verifyEmptyState(
renderFn: () => { container: HTMLElement }
): boolean {
const { container } = renderFn();
// Should show empty state message
const emptyMessage = container.querySelector('[data-testid="empty-state"]');
if (!emptyMessage) {
console.error('No empty state message found');
return false;
}
// Should not show chart elements
const chartElements = container.querySelectorAll('[data-testid*="bar"]');
if (chartElements.length > 0) {
console.error('Chart elements rendered with empty data');
return false;
}
return true;
}
/**
* Measure memory usage before and after render
*
* @example
* const leak = detectMemoryLeak(() => {
* const { unmount } = render(<Chart data={data} />);
* unmount();
* })
*/
export function detectMemoryLeak(
renderAndUnmountFn: () => void,
iterations: number = 100
): boolean {
if (!performance.memory) {
console.warn('performance.memory not available in this environment');
return true;
}
const initialMemory = (performance as any).memory.usedJSHeapSize;
// Run render/unmount cycle multiple times
for (let i = 0; i < iterations; i++) {
renderAndUnmountFn();
}
// Force garbage collection if available (only in tests with --expose-gc)
if (global.gc) {
global.gc();
}
const finalMemory = (performance as any).memory.usedJSHeapSize;
const memoryIncrease = finalMemory - initialMemory;
// Allow 10MB increase (some memory growth is normal)
const threshold = 10 * 1024 * 1024;
if (memoryIncrease > threshold) {
console.error(
`Potential memory leak: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB increase`
);
return false;
}
return true;
}
/**
* Generate snapshot-friendly chart data
* (Removes timestamps, random values, etc.)
*
* @example
* expect(sanitizeForSnapshot(chartData)).toMatchSnapshot()
*/
export function sanitizeForSnapshot(data: any): any {
if (Array.isArray(data)) {
return data.map(sanitizeForSnapshot);
}
if (typeof data === 'object' && data !== null) {
const sanitized: any = {};
for (const [key, value] of Object.entries(data)) {
// Remove timestamps
if (key.includes('timestamp') || key.includes('time')) {
sanitized[key] = '[TIMESTAMP]';
}
// Remove IDs
else if (key === 'id' || key.endsWith('Id')) {
sanitized[key] = '[ID]';
}
// Recursively sanitize nested objects
else {
sanitized[key] = sanitizeForSnapshot(value);
}
}
return sanitized;
}
return data;
}
/**
* Data Transformation Utilities for Data Visualization
*
* Common transformations needed for chart data:
* - Aggregation (sum, average, count)
* - Grouping and pivoting
* - Normalization and scaling
* - Time series operations
* - Statistical calculations
*
* Usage:
* import { groupBy, rollup, normalize } from './data-transform';
*/
export interface DataPoint {
[key: string]: any;
}
/**
* Group data by a key
*
* @example
* const data = [
* { category: 'A', value: 10 },
* { category: 'A', value: 20 },
* { category: 'B', value: 30 }
* ];
* groupBy(data, 'category')
* // { A: [...], B: [...] }
*/
export function groupBy<T extends DataPoint>(
data: T[],
key: keyof T
): Record<string, T[]> {
return data.reduce((acc, item) => {
const groupKey = String(item[key]);
if (!acc[groupKey]) {
acc[groupKey] = [];
}
acc[groupKey].push(item);
return acc;
}, {} as Record<string, T[]>);
}
/**
* Aggregate grouped data
*
* @example
* const data = [
* { category: 'A', value: 10 },
* { category: 'A', value: 20 },
* { category: 'B', value: 30 }
* ];
* rollup(data, 'category', 'value', 'sum')
* // [{ category: 'A', value: 30 }, { category: 'B', value: 30 }]
*/
export function rollup<T extends DataPoint>(
data: T[],
groupKey: keyof T,
valueKey: keyof T,
aggregation: 'sum' | 'avg' | 'count' | 'min' | 'max'
): Array<{ [K in keyof T]: T[K] }> {
const grouped = groupBy(data, groupKey);
return Object.entries(grouped).map(([key, items]) => {
let value: number;
switch (aggregation) {
case 'sum':
value = items.reduce((sum, item) => sum + Number(item[valueKey]), 0);
break;
case 'avg':
value =
items.reduce((sum, item) => sum + Number(item[valueKey]), 0) /
items.length;
break;
case 'count':
value = items.length;
break;
case 'min':
value = Math.min(...items.map(item => Number(item[valueKey])));
break;
case 'max':
value = Math.max(...items.map(item => Number(item[valueKey])));
break;
}
return {
[groupKey]: key,
[valueKey]: value
} as { [K in keyof T]: T[K] };
});
}
/**
* Normalize values to 0-1 range
*
* @example
* normalize([10, 20, 30, 40])
* // [0, 0.333, 0.666, 1]
*/
export function normalize(values: number[]): number[] {
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min;
if (range === 0) return values.map(() => 0);
return values.map(v => (v - min) / range);
}
/**
* Standardize values (z-score)
*
* @example
* standardize([10, 20, 30, 40])
* // [-1.161, -0.387, 0.387, 1.161]
*/
export function standardize(values: number[]): number[] {
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance =
values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
const stdDev = Math.sqrt(variance);
if (stdDev === 0) return values.map(() => 0);
return values.map(v => (v - mean) / stdDev);
}
/**
* Calculate moving average
*
* @example
* movingAverage([1, 2, 3, 4, 5], 3)
* // [2, 3, 4] (averages of [1,2,3], [2,3,4], [3,4,5])
*/
export function movingAverage(values: number[], window: number): number[] {
if (window > values.length) {
throw new Error('Window size cannot exceed array length');
}
const result: number[] = [];
for (let i = 0; i <= values.length - window; i++) {
const slice = values.slice(i, i + window);
const avg = slice.reduce((sum, v) => sum + v, 0) / window;
result.push(avg);
}
return result;
}
/**
* Calculate percentage change
*
* @example
* percentageChange(100, 150)
* // 50 (increased by 50%)
*/
export function percentageChange(oldValue: number, newValue: number): number {
if (oldValue === 0) return newValue === 0 ? 0 : Infinity;
return ((newValue - oldValue) / Math.abs(oldValue)) * 100;
}
/**
* Bin continuous data into discrete ranges
*
* @example
* bin([1, 5, 10, 15, 20, 25], 3)
* // [
* // { range: '1-9', count: 2, values: [1, 5] },
* // { range: '10-18', count: 2, values: [10, 15] },
* // { range: '19-27', count: 2, values: [20, 25] }
* // ]
*/
export function bin(
values: number[],
numBins: number
): Array<{ range: string; count: number; values: number[] }> {
const min = Math.min(...values);
const max = Math.max(...values);
const binSize = (max - min) / numBins;
const bins: Array<{ range: string; count: number; values: number[] }> = [];
for (let i = 0; i < numBins; i++) {
const binMin = min + i * binSize;
const binMax = min + (i + 1) * binSize;
const binValues = values.filter(
v => v >= binMin && (i === numBins - 1 ? v <= binMax : v < binMax)
);
bins.push({
range: `${Math.round(binMin)}-${Math.round(binMax)}`,
count: binValues.length,
values: binValues
});
}
return bins;
}
/**
* Pivot data (rows to columns)
*
* @example
* const data = [
* { date: '2024-01', category: 'A', value: 10 },
* { date: '2024-01', category: 'B', value: 20 },
* { date: '2024-02', category: 'A', value: 15 }
* ];
* pivot(data, 'date', 'category', 'value')
* // [
* // { date: '2024-01', A: 10, B: 20 },
* // { date: '2024-02', A: 15, B: null }
* // ]
*/
export function pivot<T extends DataPoint>(
data: T[],
rowKey: keyof T,
colKey: keyof T,
valueKey: keyof T
): DataPoint[] {
const rows = new Map<string, DataPoint>();
data.forEach(item => {
const row = String(item[rowKey]);
const col = String(item[colKey]);
const value = item[valueKey];
if (!rows.has(row)) {
rows.set(row, { [rowKey]: row });
}
rows.get(row)![col] = value;
});
return Array.from(rows.values());
}
/**
* Calculate cumulative sum
*
* @example
* cumulativeSum([1, 2, 3, 4])
* // [1, 3, 6, 10]
*/
export function cumulativeSum(values: number[]): number[] {
let sum = 0;
return values.map(v => {
sum += v;
return sum;
});
}
/**
* Sort data by multiple keys
*
* @example
* const data = [
* { category: 'B', value: 20 },
* { category: 'A', value: 10 },
* { category: 'A', value: 30 }
* ];
* sortBy(data, ['category', 'asc'], ['value', 'desc'])
* // [
* // { category: 'A', value: 30 },
* // { category: 'A', value: 10 },
* // { category: 'B', value: 20 }
* // ]
*/
export function sortBy<T extends DataPoint>(
data: T[],
...sortKeys: Array<[keyof T, 'asc' | 'desc']>
): T[] {
return [...data].sort((a, b) => {
for (const [key, direction] of sortKeys) {
const aVal = a[key];
const bVal = b[key];
let comparison = 0;
if (aVal < bVal) comparison = -1;
if (aVal > bVal) comparison = 1;
if (comparison !== 0) {
return direction === 'asc' ? comparison : -comparison;
}
}
return 0;
});
}
/**
* Fill missing dates in time series
*
* @example
* const data = [
* { date: new Date('2024-01-01'), value: 10 },
* { date: new Date('2024-01-03'), value: 30 }
* ];
* fillMissingDates(data, 'day', 0)
* // [
* // { date: new Date('2024-01-01'), value: 10 },
* // { date: new Date('2024-01-02'), value: 0 },
* // { date: new Date('2024-01-03'), value: 30 }
* // ]
*/
export function fillMissingDates<T extends { date: Date; value: number }>(
data: T[],
interval: 'day' | 'week' | 'month',
fillValue: number = 0
): T[] {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const start = sorted[0].date;
const end = sorted[sorted.length - 1].date;
const result: T[] = [];
const existingDates = new Set(sorted.map(d => d.date.toISOString()));
let current = new Date(start);
while (current <= end) {
const dateStr = current.toISOString();
if (existingDates.has(dateStr)) {
result.push(sorted.find(d => d.date.toISOString() === dateStr)!);
} else {
result.push({
date: new Date(current),
value: fillValue
} as T);
}
// Increment date based on interval
switch (interval) {
case 'day':
current.setDate(current.getDate() + 1);
break;
case 'week':
current.setDate(current.getDate() + 7);
break;
case 'month':
current.setMonth(current.getMonth() + 1);
break;
}
}
return result;
}
/**
* Calculate trend (linear regression slope)
*
* @example
* calculateTrend([10, 20, 30, 40])
* // 10 (increasing by 10 per period)
*/
export function calculateTrend(values: number[]): number {
const n = values.length;
const xMean = (n - 1) / 2; // 0, 1, 2, ... n-1
const yMean = values.reduce((sum, v) => sum + v, 0) / n;
let numerator = 0;
let denominator = 0;
for (let i = 0; i < n; i++) {
numerator += (i - xMean) * (values[i] - yMean);
denominator += Math.pow(i - xMean, 2);
}
return denominator === 0 ? 0 : numerator / denominator;
}
/**
* Detect outliers using IQR method
*
* @example
* detectOutliers([1, 2, 3, 4, 5, 100])
* // [100]
*/
export function detectOutliers(values: number[]): number[] {
const sorted = [...values].sort((a, b) => a - b);
const q1Index = Math.floor(sorted.length * 0.25);
const q3Index = Math.floor(sorted.length * 0.75);
const q1 = sorted[q1Index];
const q3 = sorted[q3Index];
const iqr = q3 - q1;
const lowerBound = q1 - 1.5 * iqr;
const upperBound = q3 + 1.5 * iqr;
return values.filter(v => v < lowerBound || v > upperBound);
}
/**
* Sample data (for performance with large datasets)
*
* @example
* sample(largeDataset, 100, 'random')
* // Returns 100 random points
*/
export function sample<T>(
data: T[],
size: number,
method: 'random' | 'systematic' = 'random'
): T[] {
if (size >= data.length) return data;
if (method === 'random') {
const shuffled = [...data].sort(() => Math.random() - 0.5);
return shuffled.slice(0, size);
}
// Systematic sampling (evenly spaced)
const step = Math.floor(data.length / size);
return data.filter((_, i) => i % step === 0).slice(0, size);
}