
Data Visualizer
- 82 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
data-visualizer is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- data-visualizer
- AI & Agent Building
- AI-coding skill
Data Visualizer by the numbers
- 82 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,148 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill data-visualizerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Data Visualizer
Build charts, dashboards, and interactive data visualizations using modern libraries. Covers Recharts (React), Chart.js (framework-agnostic), and D3.js (custom/advanced).
Library Selection
| Library | Best For | React | Custom | Bundle (gzip) |
|---|---|---|---|---|
| Recharts | Quick React charts, standard types | Native | Limited | ~139KB |
| Chart.js | Framework-agnostic, simple API | Wrapper | Moderate | ~68KB (tree-shakeable) |
| D3.js | Custom visualizations, full control | Manual | Full | ~13-30KB per module |
Chart Type Selection
| Chart | Best For | Avoid When |
|---|---|---|
| Line | Trends over time, continuous data | Categorical data |
| Bar | Comparing categories, rankings | Continuous data |
| Pie/Donut | Part-to-whole (max 5-7 slices) | >7 categories, precise comparison |
| Area | Volume over time, stacked categories | Too many overlapping series |
| Scatter | Correlation, outliers, distribution | Time series |
| Heatmap | Intensity across two dimensions | Simple comparisons |
Dashboard Patterns
| Pattern | Use Case | Key Feature |
|---|---|---|
| KPI Dashboard | Executive metrics | Trend indicators, card grid |
| Real-Time | Live monitoring | SSE/WebSocket, animation disabled |
| Interactive Filters | Exploratory analysis | Period/region selects, drill-down |
| Drill-Down | Hierarchical data | Click to zoom (year → month → day) |
Responsive Design
| Approach | Implementation |
|---|---|
| Container-based sizing | <ResponsiveContainer width="100%" height={300}> |
| Aspect ratio mode | <ResponsiveContainer width="100%" aspect={2}> |
| Mobile-aware | Reduce strokeWidth, height on small screens |
| Label simplification | Fewer axis labels on mobile |
Common Mistakes
| Mistake | Fix |
|---|---|
| Fixed width/height | Use ResponsiveContainer |
| Animation on real-time data | Set isAnimationActive={false} |
| Too many pie slices | Max 5-7 slices, group rest as "Other" |
| Non-accessible colors | Use WCAG AA compliant, colorblind-safe palette |
| Loading entire chart library | Lazy load with dynamic imports |
| No data formatting | Use Intl.NumberFormat for currency/percent |
Delegation
- Explore data shape and available fields before choosing chart type: Use
Exploreagent - Build a complete multi-chart dashboard from requirements: Use
Taskagent - Plan visualization architecture for large-scale analytics pages: Use
Planagent
References
- Library Selection — Recharts, Chart.js, D3.js setup and examples
- Chart Types — line, bar, pie, area, scatter, heatmap with use cases
- Dashboard Patterns — KPI, real-time SSE, interactive filters, drill-down
- Design and Accessibility — color schemes, colorblind-safe palettes, responsive, animation
- Data Formatting and Export — number formatters, CSV export, PNG export
- Performance — lazy loading, virtualization for large datasets
- Storytelling and Annotation — data narratives, annotations, color strategy, edge states
Chart Types
Line Chart
Best for: Trends over time, continuous data (stock prices, temperature, traffic).
<LineChart data={data}>
<Line type="monotone" dataKey="value" stroke="#8884d8" />
</LineChart>Use for multiple data series comparison. Add <CartesianGrid>, <Tooltip>, <Legend> for readability.
Bar Chart
Best for: Comparing categories, rankings (sales by product, users by country).
<BarChart data={data}>
<Bar dataKey="value" fill="#8884d8" />
</BarChart>Pie / Donut Chart
Best for: Part-to-whole relationships (market share, budget allocation).
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" fill="#8884d8" />
</PieChart>Limit to 5-7 slices maximum. For precise comparison, use a bar chart instead.
Area Chart
Best for: Volume over time, stacked categories, cumulative totals.
<AreaChart data={data}>
<Area type="monotone" dataKey="value" fill="#8884d8" />
</AreaChart>Scatter Plot
Best for: Correlation between variables, outlier detection, distribution analysis.
<ScatterChart>
<Scatter data={data} fill="#8884d8" />
</ScatterChart>Heatmap
Best for: Intensity across two dimensions (time patterns, geographic data, matrix data).
const colorScale = d3
.scaleSequential(d3.interpolateBlues)
.domain([0, d3.max(data)]);
svg
.selectAll('rect')
.data(data)
.join('rect')
.attr('fill', (d) => colorScale(d.value));Selection Guide
| Data Type | Recommended Chart |
|---|---|
| Time series | Line or Area |
| Category comparison | Bar (horizontal for long labels) |
| Proportions | Pie/Donut (max 7 slices) |
| Correlation | Scatter |
| Distribution | Histogram or Box plot |
| Geographic | Heatmap or Choropleth |
| Hierarchy | Treemap or Sunburst |
| Flow | Sankey diagram |
Dashboard Patterns
KPI Dashboard
Executive dashboard with key metrics and trend indicators:
interface KPICardProps {
title: string;
value: string | number;
change: number;
trend: 'up' | 'down';
}
function KPICard({ title, value, change, trend }: KPICardProps) {
const trendColor = trend === 'up' ? 'text-green-600' : 'text-red-600';
const trendIcon = trend === 'up' ? '\u2191' : '\u2193';
return (
<Card className="p-6">
<h3 className="text-sm font-medium text-gray-600">{title}</h3>
<div className="mt-2 flex items-baseline">
<p className="text-3xl font-semibold">{value}</p>
<span className={`ml-2 text-sm ${trendColor}`}>
{trendIcon} {Math.abs(change)}%
</span>
</div>
</Card>
);
}
export default function Dashboard() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<KPICard title="Total Revenue" value="$45,231" change={12.5} trend="up" />
<KPICard title="Active Users" value="2,350" change={-5.2} trend="down" />
<KPICard title="Conversion Rate" value="3.24%" change={8.1} trend="up" />
<KPICard title="Avg Order Value" value="$158" change={2.3} trend="up" />
</div>
);
}Real-Time Dashboard with SSE
Live data monitoring using Server-Sent Events:
'use client';
import { useEffect, useState } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
interface DataPoint {
time: string;
value: number;
}
export default function RealtimeDashboard() {
const [data, setData] = useState<DataPoint[]>([]);
useEffect(() => {
fetch('/api/metrics/realtime')
.then((res) => res.json())
.then(setData);
const eventSource = new EventSource('/api/metrics/stream');
eventSource.onmessage = (event) => {
const newDataPoint = JSON.parse(event.data);
setData((prev) => {
const updated = [...prev, newDataPoint];
return updated.slice(-20);
});
};
return () => eventSource.close();
}, []);
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="value"
stroke="#8884d8"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}SSE API route:
export async function GET(req: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const interval = setInterval(async () => {
const value = Math.floor(Math.random() * 100);
const time = new Date().toLocaleTimeString();
const data = `data: ${JSON.stringify({ time, value })}\n\n`;
controller.enqueue(encoder.encode(data));
}, 1000);
req.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}Interactive Filters
Dashboard with period and region filtering:
'use client';
import { useState, useEffect } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
type Period = '7d' | '30d' | '90d';
type Region = 'all' | 'us' | 'eu' | 'asia';
export default function SalesDashboard() {
const [period, setPeriod] = useState<Period>('30d');
const [region, setRegion] = useState<Region>('all');
const { data, loading } = useSalesData({ period, region });
return (
<div className="space-y-6">
<div className="flex gap-4">
<select
value={period}
onChange={(e) => setPeriod(e.target.value as Period)}
className="px-4 py-2 border rounded"
>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select
value={region}
onChange={(e) => setRegion(e.target.value as Region)}
className="px-4 py-2 border rounded"
>
<option value="all">All Regions</option>
<option value="us">United States</option>
<option value="eu">Europe</option>
<option value="asia">Asia</option>
</select>
</div>
{loading ? (
<div>Loading...</div>
) : (
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Bar dataKey="sales" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
)}
</div>
);
}Drill-Down Chart
Click bars to zoom from year → month → day:
'use client';
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis } from 'recharts';
export default function DrillDownChart() {
const [level, setLevel] = useState<'year' | 'month' | 'day'>('year');
const [selectedYear, setSelectedYear] = useState<number | null>(null);
const handleBarClick = (data: any) => {
if (level === 'year') {
setSelectedYear(data.year);
setLevel('month');
} else if (level === 'month') {
setLevel('day');
}
};
const goBack = () => {
if (level === 'day') setLevel('month');
else if (level === 'month') {
setLevel('year');
setSelectedYear(null);
}
};
return (
<div>
{level !== 'year' && (
<button onClick={goBack} className="mb-4">
Back
</button>
)}
<BarChart data={getData(level, selectedYear)} width={600} height={300}>
<Bar dataKey="value" fill="#8884d8" onClick={handleBarClick} />
<XAxis dataKey="name" />
<YAxis />
</BarChart>
</div>
);
}Data Formatting and Export
Number Formatting
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
export function formatPercent(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value / 100);
}
export function formatNumber(value: number): string {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`;
}
return value.toFixed(0);
}Usage in Recharts:
<YAxis tickFormatter={formatCurrency} />Export Chart as PNG
'use client';
import html2canvas from 'html2canvas';
export function ExportableChart({ children }) {
const chartRef = useRef<HTMLDivElement>(null);
const exportToPNG = async () => {
if (!chartRef.current) return;
const canvas = await html2canvas(chartRef.current);
const link = document.createElement('a');
link.download = 'chart.png';
link.href = canvas.toDataURL();
link.click();
};
return (
<div>
<button
onClick={exportToPNG}
className="mb-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Export as PNG
</button>
<div ref={chartRef}>{children}</div>
</div>
);
}Export Data as CSV
export function exportToCSV(data: Record<string, unknown>[], filename: string) {
const headers = Object.keys(data[0]);
const csv = [
headers.join(','),
...data.map((row) => headers.map((h) => row[h]).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const link = document.createElement('a');
link.download = `${filename}.csv`;
link.href = URL.createObjectURL(blob);
link.click();
}Usage:
<button onClick={() => exportToCSV(data, 'sales-data')}>Export to CSV</button>Design and Accessibility
Accessible Color Palette
export const chartColors = {
primary: '#0066CC',
success: '#007A3D',
warning: '#C87000',
danger: '#D32F2F',
series: [
'#0066CC', // Blue
'#CC6600', // Orange
'#7A00CC', // Purple
'#00CC66', // Green
'#CC0066', // Magenta
],
};Colorblind-Safe Palette
const colorblindSafe = [
'#000000', // Black
'#E69F00', // Orange
'#56B4E9', // Sky Blue
'#009E73', // Green
'#F0E442', // Yellow
];All colors should meet WCAG AA contrast requirements (4.5:1 minimum for text). Test with browser colorblind simulation tools.
Responsive Charts
'use client';
import { useEffect, useState } from 'react';
import { LineChart, Line, ResponsiveContainer } from 'recharts';
export default function ResponsiveChart({ data }) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return (
<ResponsiveContainer width="100%" height={isMobile ? 200 : 400}>
<LineChart data={data}>
<Line dataKey="value" stroke="#8884d8" strokeWidth={isMobile ? 1 : 2} />
</LineChart>
</ResponsiveContainer>
);
}Animation Best Practices
Smooth transitions for standard charts:
<Line
type="monotone"
dataKey="value"
stroke="#8884d8"
animationDuration={500}
animationEasing="ease-in-out"
/>Disable animation for real-time data:
<Line dataKey="value" isAnimationActive={false} />| Scenario | Animation |
|---|---|
| Initial load | Enable (500ms ease-in-out) |
| Real-time updates | Disable |
| User interaction | Enable (300ms) |
| Print/export | Disable |
Library Selection
Recharts (Recommended for React)
Declarative React components built on D3. Best for quick, standard chart types in React projects.
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
const data = [
{ month: 'Jan', revenue: 4000, expenses: 2400 },
{ month: 'Feb', revenue: 3000, expenses: 1398 },
{ month: 'Mar', revenue: 2000, expenses: 9800 },
];
function RevenueChart() {
return (
<LineChart width={600} height={300} data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="revenue" stroke="#8884d8" />
<Line type="monotone" dataKey="expenses" stroke="#82ca9d" />
</LineChart>
);
}Chart.js (Recommended for Vue/Angular)
Canvas-based, framework-agnostic. Simple API with good defaults.
import { Chart } from 'chart.js/auto';
const ctx = document.getElementById('myChart');
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [
{
label: 'Sales',
data: [12, 19, 3, 5, 2, 3],
backgroundColor: 'rgba(54, 162, 235, 0.5)',
},
],
},
options: {
responsive: true,
plugins: {
legend: { position: 'top' },
title: { display: true, text: 'Monthly Sales' },
},
},
});D3.js (Advanced/Custom)
Low-level data-driven document manipulation. Full control over rendering. Use when you need custom chart types, complex interactions, or publication-quality graphics.
import * as d3 from 'd3';
function createBarChart(data: Array<{ name: string; value: number }>) {
const width = 600;
const height = 400;
const margin = { top: 20, right: 20, bottom: 30, left: 40 };
const svg = d3
.select('#chart')
.append('svg')
.attr('width', width)
.attr('height', height);
const x = d3
.scaleBand()
.domain(data.map((d) => d.name))
.range([margin.left, width - margin.right])
.padding(0.1);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value)])
.range([height - margin.bottom, margin.top]);
svg
.selectAll('rect')
.data(data)
.join('rect')
.attr('x', (d) => x(d.name))
.attr('y', (d) => y(d.value))
.attr('height', (d) => y(0) - y(d.value))
.attr('width', x.bandwidth())
.attr('fill', 'steelblue');
svg
.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x));
svg
.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(y));
}ResponsiveContainer Advanced Props
Recharts ResponsiveContainer supports aspect ratio mode and resize callbacks:
<ResponsiveContainer width="100%" aspect={2}>
<LineChart data={data}>
<Line dataKey="value" stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
<ResponsiveContainer
width="100%"
height={400}
debounce={300}
onResize={(width, height) => console.log(`${width}x${height}`)}
>
<BarChart data={data}>
<Bar dataKey="value" fill="#82ca9d" />
</BarChart>
</ResponsiveContainer>| Prop | Type | Purpose |
|---|---|---|
width | string | Percentage or number ("100%", 500) |
height | number | Fixed height in pixels |
aspect | number | Width-to-height ratio (alternative to height) |
debounce | number | Debounce resize events in ms |
onResize | function | Callback with (width, height) on resize |
Comparison
| Feature | Recharts | Chart.js | D3.js |
|---|---|---|---|
| Rendering | SVG | Canvas | SVG/Canvas |
| React integration | Native | Wrapper | Manual |
| Customization | Moderate | Moderate | Full |
| Learning curve | Low | Low | High |
| Bundle size (gz) | ~139KB | ~68KB (tree-shakeable) | ~13-30KB per module |
| Best for | React dashboards | Multi-framework | Custom viz |
Performance
Lazy Loading Charts
Reduce initial bundle by loading chart libraries on demand:
import dynamic from 'next/dynamic';
const LineChart = dynamic(
() => import('recharts').then((mod) => mod.LineChart),
{ ssr: false },
);
export default function ChartPage() {
return <LineChart data={data} />;
}Virtualization for Large Datasets
For tables or lists with thousands of rows:
import { useVirtualizer } from '@tanstack/react-virtual';
export function LargeDataTable({ data }: { data: any[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} className="h-96 overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.index} className="py-2 border-b">
{data[virtualRow.index].name}: {data[virtualRow.index].value}
</div>
))}
</div>
</div>
);
}General Guidelines
| Technique | When to Use |
|---|---|
| Lazy loading | Chart not visible on initial load |
ssr: false | Canvas/SVG charts that need DOM |
| Virtualization | >1000 data points in tables/lists |
| Data sampling | >10K points in scatter/line charts |
| Canvas rendering (Chart.js) | Very large datasets (>50K points) |
| Web Workers | Heavy data transformations |
Storytelling and Annotation
Data Storytelling Principles
| Principle | Description |
|---|---|
| Data-ink ratio | Maximize ink used for data, minimize decorative elements |
| Progressive disclosure | Show summary first, let users drill into detail |
| Narrative arc | Context (setup) → tension (insight) → resolution (recommendation) |
| One chart, one message | Each visualization should communicate a single clear insight |
High data-ink ratio means removing gridlines, borders, and backgrounds that add no information. Every pixel should earn its place.
Chart Titles That Tell the Story
Titles are the most-read element on any chart. Make them carry the insight.
| Weak (Descriptive) | Strong (Narrative) |
|---|---|
| "Sales Data" | "Sales doubled after the Q4 campaign" |
| "Monthly Active Users" | "User growth stalled in March" |
| "Revenue by Region" | "APAC now drives 40% of total revenue" |
| "Error Rate" | "Error rate spiked 3x after deployment" |
<LineChart data={data}>
<text x="50%" y={20} textAnchor="middle" className="text-lg font-semibold">
Sales doubled after the Q4 campaign
</text>
<text x="50%" y={40} textAnchor="middle" className="text-sm text-gray-500">
Monthly revenue, Jan–Dec 2024
</text>
<Line type="monotone" dataKey="revenue" stroke="#0066CC" />
</LineChart>Reference Lines
Mark thresholds, targets, and averages to give data context.
import {
LineChart,
Line,
ReferenceLine,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
export function RevenueWithTarget({
data,
}: {
data: { month: string; revenue: number }[];
}) {
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<ReferenceLine
y={50000}
stroke="#D32F2F"
strokeDasharray="5 5"
label="Target"
/>
<ReferenceLine
x="Jun"
stroke="#999"
strokeDasharray="3 3"
label="Campaign Launch"
/>
<Line
type="monotone"
dataKey="revenue"
stroke="#0066CC"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
);
}Highlight Regions with ReferenceArea
Draw attention to specific time periods or value ranges.
import {
AreaChart,
Area,
ReferenceArea,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
export function HighlightedPeriod({
data,
}: {
data: { date: string; value: number }[];
}) {
return (
<ResponsiveContainer width="100%" height={400}>
<AreaChart data={data}>
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<ReferenceArea
x1="2024-03-01"
x2="2024-03-31"
fill="#FF000020"
label="Outage Window"
/>
<Area
type="monotone"
dataKey="value"
stroke="#0066CC"
fill="#0066CC20"
/>
</AreaChart>
</ResponsiveContainer>
);
}Custom Callout Labels
Use Recharts customizedLabel to annotate specific data points.
import {
LineChart,
Line,
XAxis,
YAxis,
ResponsiveContainer,
type LabelProps,
} from 'recharts';
function PeakLabel({ x, y, value }: LabelProps) {
if (typeof x !== 'number' || typeof y !== 'number') return null;
if (Number(value) < 100000) return null;
return (
<g>
<circle cx={x} cy={y} r={6} fill="#D32F2F" />
<text
x={x}
y={y - 15}
textAnchor="middle"
fill="#D32F2F"
fontSize={12}
fontWeight="bold"
>
Peak: {Number(value).toLocaleString()}
</text>
</g>
);
}
export function AnnotatedChart({
data,
}: {
data: { month: string; users: number }[];
}) {
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<XAxis dataKey="month" />
<YAxis />
<Line
type="monotone"
dataKey="users"
stroke="#0066CC"
strokeWidth={2}
label={<PeakLabel />}
/>
</LineChart>
</ResponsiveContainer>
);
}Color as Narrative
Use color intentionally to direct attention, not just differentiate series.
| Strategy | When to Use | Implementation |
|---|---|---|
| Highlight vs mute | One series matters most | Bold color for focus, gray for context |
| Sequential palette | Ordered data (low → high) | Single hue, varying lightness |
| Diverging palette | Data with a meaningful center | Two hues from a neutral midpoint |
| Categorical | Unrelated groups (max 5-7 colors) | Distinct, accessible hues |
| Semantic | Status or sentiment (good vs bad) | Green/red with accessible alternatives |
const HIGHLIGHT_COLOR = '#0066CC';
const MUTED_COLOR = '#C0C0C0';
export function FocusedComparison({
data,
focusSeries,
}: {
data: Record<string, unknown>[];
focusSeries: string;
}) {
const allSeries = ['productA', 'productB', 'productC'];
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
{allSeries.map((key) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={key === focusSeries ? HIGHLIGHT_COLOR : MUTED_COLOR}
strokeWidth={key === focusSeries ? 3 : 1}
dot={key === focusSeries}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}Dashboard Narrative Flow
| Principle | Description |
|---|---|
| Z-pattern reading | Place most important content top-left, call-to-action bottom |
| Information funnel | KPIs at top → trends in middle → detail tables at bottom |
| Consistent time | All charts on a dashboard should share the same time range |
| Visual grouping | Related metrics share a row or card group |
┌──────────────────────────────────────────────┐
│ KPI Cards (revenue, users, conversion, NPS) │ ← Scan first
├────────────────────┬─────────────────────────┤
│ Trend Line Chart │ Breakdown Bar Chart │ ← Understand trends
├────────────────────┴─────────────────────────┤
│ Detail Table with Filters │ ← Drill into specifics
└──────────────────────────────────────────────┘KPI card pattern with trend indicator:
type KPIProps = {
label: string;
value: string;
change: number;
};
export function KPICard({ label, value, change }: KPIProps) {
const isPositive = change >= 0;
return (
<div className="rounded-lg border p-4">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-bold">{value}</p>
<p className={isPositive ? 'text-green-600' : 'text-red-600'}>
{isPositive ? '↑' : '↓'} {Math.abs(change)}%
<span className="text-gray-400"> vs last period</span>
</p>
</div>
);
}Small Multiples
Compare the same chart across categories or time periods. Shared axes make comparison immediate.
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer } from 'recharts';
type RegionData = {
region: string;
data: { month: string; revenue: number }[];
};
export function SmallMultiples({ regions }: { regions: RegionData[] }) {
const globalMax = Math.max(
...regions.flatMap((r) => r.data.map((d) => d.revenue)),
);
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{regions.map((region) => (
<div key={region.region}>
<h3 className="mb-2 text-sm font-semibold">{region.region}</h3>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={region.data}>
<XAxis dataKey="month" tick={false} />
<YAxis domain={[0, globalMax]} hide />
<Line
type="monotone"
dataKey="revenue"
stroke="#0066CC"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
))}
</div>
);
}Shared Y-axis domain (globalMax) ensures each panel uses the same scale for honest comparison.
Edge Cases in Visualizations
Empty State
export function ChartEmptyState({
message = 'No data available',
}: {
message?: string;
}) {
return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border border-dashed">
<svg
className="mb-2 h-12 w-12 text-gray-300"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" />
</svg>
<p className="text-sm text-gray-500">{message}</p>
</div>
);
}Loading State
export function ChartSkeleton({ height = 300 }: { height?: number }) {
return (
<div
className="animate-pulse rounded-lg bg-gray-100"
style={{ height }}
role="status"
>
<span className="sr-only">Loading chart...</span>
</div>
);
}Error State
export function ChartError({
error,
onRetry,
}: {
error: string;
onRetry?: () => void;
}) {
return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50">
<p className="text-sm text-red-600">{error}</p>
{onRetry ? (
<button
onClick={onRetry}
className="mt-2 text-sm text-red-600 underline"
>
Retry
</button>
) : null}
</div>
);
}Conditional Rendering Pattern
type ChartWrapperProps = {
data: unknown[] | undefined;
isLoading: boolean;
error: string | null;
onRetry?: () => void;
children: React.ReactNode;
};
export function ChartWrapper({
data,
isLoading,
error,
onRetry,
children,
}: ChartWrapperProps) {
if (isLoading) return <ChartSkeleton />;
if (error) return <ChartError error={error} onRetry={onRetry} />;
if (!data?.length) return <ChartEmptyState />;
return <>{children}</>;
}Accessibility in Storytelling
Charts must convey their narrative through text and structure, not just visuals.
| Technique | Implementation |
|---|---|
| Descriptive title | Chart title states the insight, not just the metric |
| Text summary | Add a sentence below the chart summarizing the key takeaway |
| Accessible label on SVG | <svg role="img" aria-label="..."> |
| Data table fallback | Provide a hidden or expandable table with the raw data |
| Pattern fills | Supplement color with patterns for colorblind users |
| Keyboard-navigable tooltip | Recharts Tooltip supports cursor for keyboard focus |
export function AccessibleChart({
data,
title,
summary,
}: {
data: { month: string; value: number }[];
title: string;
summary: string;
}) {
return (
<figure role="group" aria-label={title}>
<figcaption>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-gray-500">{summary}</p>
</figcaption>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data} role="img" aria-label={summary}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="#0066CC" />
</LineChart>
</ResponsiveContainer>
<details className="mt-2">
<summary className="cursor-pointer text-sm text-blue-600">
View data table
</summary>
<table className="mt-1 w-full text-sm">
<thead>
<tr>
<th className="text-left">Month</th>
<th className="text-right">Value</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.month}>
<td>{row.month}</td>
<td className="text-right">{row.value.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
);
}