
Building Webapp Data Visualization
- 4 installs
- 787 repo stars
- Updated August 5, 2026
- forcedotcom/afv-library
Adds charts, stat cards, and KPI metrics to React pages using Recharts, choosing the right visualization type for the data.
About
Adds data visualization components (donut, pie, bar, line, area charts, and KPI stat cards) to React pages using Recharts. A developer uses it when building dashboards or analytics views in a Salesforce React web app.
- Guides selecting the right chart type for the data
- Builds Recharts charts and KPI stat cards for dashboards
Building Webapp Data Visualization by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,817 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/forcedotcom/afv-library --skill building-webapp-data-visualizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 787 |
| Last updated | August 5, 2026 |
| Repository | forcedotcom/afv-library ↗ |
What it does
Adds charts, stat cards, and KPI metrics to React pages using Recharts, choosing the right visualization type for the data.
Files
Data Visualization
When to Use
Use this skill when:
- Adding charts (donut, pie, bar, line, area) to a dashboard or analytics page
- Displaying KPI/metric stat cards with trend indicators
- Building a dashboard layout with mixed chart types and summary cards
---
Step 1 — Determine the visualization type
Identify what the user needs:
- Donut / pie chart — categorical breakdown (e.g. issue types, status distribution)
- Bar chart — comparison across categories or time periods
- Line / area chart — trends over time
- Stat card — single KPI metric with optional trend indicator
- Combined dashboard — stat cards + one or more charts
If unclear, ask:
"What data should the chart display, and would a donut chart, bar chart, line chart, or stat cards work best?"
---
Step 2 — Install dependencies
All chart types in this skill use recharts. Install once from the web app directory:
npm install rechartsRecharts is built on D3 and provides declarative React components. No additional CSS is needed.
---
Step 3 — Choose implementation path
Read the corresponding guide:
- Bar chart — read
implementation/bar-line-chart.md(categorical data) - Line / area chart — read
implementation/bar-line-chart.md(time-series data) - Donut / pie chart — read
implementation/donut-chart.md - Stat card with trend — read
implementation/stat-card.md - Dashboard layout — read
implementation/dashboard-layout.md
---
Verification
Before completing:
1. Chart renders with correct data and colors. 2. Chart is responsive (resizes with container). 3. Legend labels match the data categories. 4. Stat card trends display correct positive/negative indicators. 5. Run from the web app directory:
cd force-app/main/default/webapplications/<appName> && npm run lint && npm run build- Lint: MUST result in 0 errors.
- Build: MUST succeed.
Bar & Line / Area Chart — Implementation Guide
Requires recharts (install from the web app directory; see SKILL.md Step 2).
---
Data shapes
Time-series (line / area chart)
Use when data represents a trend over time or ordered sequence.
interface TimeSeriesDataPoint {
x: string; // date or label on the x-axis
y: number; // numeric value
}Map raw fields to this shape: e.g. date → x, revenue → y.
Categorical (bar chart)
Use when data compares discrete categories.
interface CategoricalDataPoint {
name: string; // category label
value: number; // numeric value
}Map raw fields to this shape: e.g. product → name, sales → value.
How to decide
| Signal | Type |
|---|---|
| "over time", "trend", date-like keys | Time-series → line chart |
| "by category", "by X", label-like keys | Categorical → bar chart |
---
Theme colors
Pick a theme based on the data's sentiment:
| Theme | Stroke / Fill | When to use |
|---|---|---|
green | #22c55e | Growth, gain, positive trend |
red | #ef4444 | Decline, loss, negative trend |
neutral | #6366f1 | Default or mixed data |
Define colors as constants — do not use inline hex values.
const THEME_COLORS = {
red: "#ef4444",
green: "#22c55e",
neutral: "#6366f1",
} as const;
type ChartTheme = keyof typeof THEME_COLORS;---
Line chart component
Create at components/LineChart.tsx (or colocate with the page):
import React from "react";
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
const THEME_COLORS = {
red: "#ef4444",
green: "#22c55e",
neutral: "#6366f1",
} as const;
type ChartTheme = keyof typeof THEME_COLORS;
interface TimeSeriesDataPoint {
x: string;
y: number;
}
interface TimeSeriesChartProps {
data: TimeSeriesDataPoint[];
theme?: ChartTheme;
title?: string;
className?: string;
}
export function TimeSeriesChart({
data,
theme = "neutral",
title,
className = "",
}: TimeSeriesChartProps) {
if (data.length === 0) {
return <p className="text-muted-foreground text-center py-8">No data to display</p>;
}
const color = THEME_COLORS[theme];
return (
<div className={className}>
{title && (
<h3 className="text-sm font-medium text-primary mb-2 uppercase tracking-wide">
{title}
</h3>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsLineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="y" stroke={color} strokeWidth={2} dot={false} />
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
}---
Bar chart component
Create at components/BarChart.tsx (or colocate with the page):
import React from "react";
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
const THEME_COLORS = {
red: "#ef4444",
green: "#22c55e",
neutral: "#6366f1",
} as const;
type ChartTheme = keyof typeof THEME_COLORS;
interface CategoricalDataPoint {
name: string;
value: number;
}
interface CategoricalChartProps {
data: CategoricalDataPoint[];
theme?: ChartTheme;
title?: string;
className?: string;
}
export function CategoricalChart({
data,
theme = "neutral",
title,
className = "",
}: CategoricalChartProps) {
if (data.length === 0) {
return <p className="text-muted-foreground text-center py-8">No data to display</p>;
}
const color = THEME_COLORS[theme];
return (
<div className={className}>
{title && (
<h3 className="text-sm font-medium text-primary mb-2 uppercase tracking-wide">
{title}
</h3>
)}
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
}---
Area chart variant
For a filled area chart (useful for volume-over-time), swap Line for Area:
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Area type="monotone" dataKey="y" stroke={color} fill={color} fillOpacity={0.2} />
</AreaChart>
</ResponsiveContainer>---
Chart container wrapper
Wrap any chart in a styled card for consistent spacing:
import { Card } from "@/components/ui/card";
interface ChartContainerProps {
children: React.ReactNode;
className?: string;
}
export function ChartContainer({ children, className = "" }: ChartContainerProps) {
return (
<Card className={`p-4 border-gray-200 shadow-sm ${className}`}>
{children}
</Card>
);
}Usage:
<ChartContainer>
<TimeSeriesChart data={monthlyData} theme="green" title="Monthly Revenue" />
</ChartContainer>---
Preparing raw data
Map API responses to the expected shape before passing to the chart:
const timeSeriesData = useMemo(
() => apiRecords.map((r) => ({ x: r.date, y: r.revenue })),
[apiRecords],
);
const categoricalData = useMemo(
() => apiRecords.map((r) => ({ name: r.product, value: r.sales })),
[apiRecords],
);---
Key Recharts concepts
| Component | Purpose |
|---|---|
ResponsiveContainer | Wraps chart to fill parent width |
CartesianGrid | Background grid lines |
XAxis / YAxis | Axis labels; dataKey maps to the data field |
Tooltip | Hover info |
Legend | Series labels |
Line | Line series; type="monotone" for smooth curves |
Bar | Bar series; radius rounds top corners |
Area | Filled area; fillOpacity controls transparency |
---
Accessibility
- Always include a text legend (not just colors).
- Chart should be wrapped in a section with a visible heading.
- For critical data, provide a text summary or table alternative.
- Use sufficient color contrast between the chart stroke/fill and background.
- Consider
prefers-reduced-motionfor chart animations.
---
Common mistakes
| Mistake | Fix |
|---|---|
Missing ResponsiveContainer | Chart won't resize; always wrap |
| Fixed width/height on chart | Let ResponsiveContainer control sizing |
| No empty-data handling | Show "No data" message when data.length === 0 |
| Inline colors | Extract to THEME_COLORS constant |
| Using raw Recharts for every chart type | Use DonutChart (see donut-chart.md) for pie/donut |
Dashboard Layout — Implementation Guide
Anatomy of a dashboard page
A typical dashboard combines stat cards, charts, and data tables:
┌────────────────────────────────────────────────────┐
│ Search / global action bar │
├──────────┬──────────┬──────────────────────────────┤
│ Stat 1 │ Stat 2 │ Stat 3 │
├──────────┴──────────┴──────┬───────────────────────┤
│ │ │
│ Data table / list │ Donut chart │
│ (70% width) │ (30% width) │
│ │ │
└────────────────────────────┴───────────────────────┘---
Layout implementation
import { PageContainer } from "@/components/layout/PageContainer";
import { StatCard } from "@/components/StatCard";
import { DonutChart } from "@/components/DonutChart";
export default function Dashboard() {
return (
<PageContainer>
<div className="max-w-7xl mx-auto space-y-6">
{/* Search bar */}
<div>{/* global search component */}</div>
{/* Main content: 70/30 split */}
<div className="grid grid-cols-1 lg:grid-cols-[70%_30%] gap-6">
<div className="space-y-6">
{/* Stat cards row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard title="Metric A" value={42} />
<StatCard title="Metric B" value={18} />
<StatCard title="Metric C" value={7} />
</div>
{/* Data table */}
<div>{/* table component */}</div>
</div>
{/* Sidebar chart */}
<div>
<DonutChart title="Distribution" data={chartData} />
</div>
</div>
</div>
</PageContainer>
);
}---
Responsive behavior
| Breakpoint | Layout |
|---|---|
Mobile (< 768px) | Single column, everything stacked |
Tablet (md) | Stat cards in 3-col grid, rest stacked |
Desktop (lg) | 70/30 split for table + chart |
Key Tailwind classes:
grid grid-cols-1 lg:grid-cols-[70%_30%] gap-6
grid grid-cols-1 md:grid-cols-3 gap-6---
Loading state
Show a full-page loading state while dashboard data is being fetched:
if (loading) {
return (
<PageContainer>
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-muted-foreground">Loading dashboard…</p>
</div>
</PageContainer>
);
}Or use a skeleton layout:
if (loading) {
return (
<PageContainer>
<div className="max-w-7xl mx-auto space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 animate-pulse rounded-xl bg-muted" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-[70%_30%] gap-6">
<div className="h-64 animate-pulse rounded-xl bg-muted" />
<div className="h-64 animate-pulse rounded-xl bg-muted" />
</div>
</div>
</PageContainer>
);
}---
Data fetching pattern
Use useEffect with cancellation for dashboard metrics:
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const data = await fetchDashboardMetrics();
if (!cancelled) setMetrics(data);
} catch (error) {
if (!cancelled) console.error("Error loading metrics:", error);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);---
Combining multiple data sources
Dashboards often aggregate data from several APIs. Load them in parallel:
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [requests, setRequests] = useState<Request[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
Promise.all([fetchMetrics(), fetchRecentRequests()])
.then(([metricsData, requestsData]) => {
if (!cancelled) {
setMetrics(metricsData);
setRequests(requestsData);
}
})
.catch((err) => {
if (!cancelled) console.error(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);---
PageContainer wrapper
A simple wrapper for consistent page padding:
interface PageContainerProps {
children: React.ReactNode;
}
export function PageContainer({ children }: PageContainerProps) {
return <div className="p-6">{children}</div>;
}Donut / Pie Chart — Implementation Guide
Requires recharts (install from the web app directory; see SKILL.md Step 2).
---
Data structure
Charts expect an array of objects with name, value, and color:
interface ChartData {
name: string;
value: number;
color: string;
}---
Donut chart component
Create at components/DonutChart.tsx:
import React from "react";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import { Card } from "@/components/ui/card";
interface ChartData {
name: string;
value: number;
color: string;
}
interface DonutChartProps {
title: string;
data: ChartData[];
}
export const DonutChart: React.FC<DonutChartProps> = ({ title, data }) => {
const total = data.reduce((sum, item) => sum + item.value, 0);
const mainPercentage = total > 0 ? Math.round((data[0]?.value / total) * 100) : 0;
return (
<Card className="p-4 border-gray-200 shadow-sm flex flex-col">
<h3 className="text-sm font-medium text-primary mb-2 uppercase tracking-wide">
{title}
</h3>
<div className="relative flex items-center justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={70}
outerRadius={110}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-5xl font-bold text-primary">{mainPercentage}%</div>
</div>
</div>
</div>
{/* Legend */}
<div className="mt-6 grid grid-cols-2 gap-3">
{data.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-sm text-gray-700">{item.name}</span>
</div>
))}
</div>
</Card>
);
};---
Key Recharts concepts
| Component | Purpose |
|---|---|
ResponsiveContainer | Wraps chart to make it fill its parent's width |
PieChart | Chart container for pie/donut |
Pie | The data ring; innerRadius > 0 makes it a donut |
Cell | Individual segment; accepts fill color |
paddingAngle | Gap between segments (degrees) |
Donut vs Pie
| Property | Donut | Pie |
|---|---|---|
innerRadius | > 0 (e.g. 70) | 0 |
| Center label | Yes, positioned absolutely | Not typical |
---
Preparing chart data from raw records
Transform API data into the ChartData[] format before passing to the chart:
const CATEGORIES = ["Plumbing", "HVAC", "Electrical"] as const;
const OTHER_LABEL = "Other";
const COLORS = ["#7C3AED", "#EC4899", "#14B8A6", "#06B6D4"];
const chartData = useMemo(() => {
const counts: Record<string, number> = {};
CATEGORIES.forEach((c) => (counts[c] = 0));
counts[OTHER_LABEL] = 0;
records.forEach((record) => {
const type = record.category;
if (CATEGORIES.includes(type as (typeof CATEGORIES)[number])) {
counts[type]++;
} else {
counts[OTHER_LABEL]++;
}
});
return [
...CATEGORIES.map((name, i) => ({ name, value: counts[name], color: COLORS[i] })),
{ name: OTHER_LABEL, value: counts[OTHER_LABEL], color: COLORS[CATEGORIES.length] },
];
}, [records]);---
Color palette recommendations
| Use case | Colors |
|---|---|
| Categorical (4 items) | #7C3AED #EC4899 #14B8A6 #06B6D4 |
| Status (3 items) | #22C55E #F59E0B #EF4444 (green/amber/red) |
| Sequential | Use opacity variants of one hue: #7C3AED at 100%, 75%, 50%, 25% |
Keep chart colors consistent with the app's design system. Define them as constants, not inline values.
---
Other chart types
For bar charts and line / area charts, see bar-line-chart.md in this directory.
---
Accessibility
- Always include a text legend (not just colors).
- Chart should be wrapped in a section with a visible heading.
- For critical data, provide a text summary or table alternative.
- Use sufficient color contrast between segments.
- Consider
prefers-reduced-motionfor chart animations.
---
Common mistakes
| Mistake | Fix |
|---|---|
Missing ResponsiveContainer | Chart won't resize; always wrap in ResponsiveContainer |
Fixed width/height on PieChart | Let ResponsiveContainer control sizing |
| No legend | Add a grid legend below the chart |
| Inline colors | Extract to constants for consistency |
| No fallback for empty data | Show "No data" message when data is empty |
Stat Card — Implementation Guide
What is a stat card
A stat card displays a single KPI metric with an optional trend indicator. Used on dashboards to show at-a-glance numbers like "Total Properties: 42 (+10%)".
---
Component interface
interface StatCardProps {
title: string;
value: number | string;
trend?: {
value: number;
isPositive: boolean;
};
subtitle?: string;
onClick?: () => void;
}---
StatCard component
Create at components/StatCard.tsx:
import React from "react";
import { Card } from "@/components/ui/card";
import { TrendingUp, TrendingDown } from "lucide-react";
interface StatCardProps {
title: string;
value: number | string;
trend?: {
value: number;
isPositive: boolean;
};
subtitle?: string;
onClick?: () => void;
}
export const StatCard: React.FC<StatCardProps> = ({ title, value, trend, subtitle, onClick }) => {
return (
<Card
className={`p-4 border-gray-200 shadow-sm relative ${
onClick ? "cursor-pointer hover:shadow-lg transition-shadow" : ""
}`}
onClick={onClick}
>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wide">{title}</p>
<div className="flex items-baseline gap-3">
<p className="text-4xl font-bold text-primary">{value}</p>
{trend && (
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-sm font-medium ${
trend.isPositive
? "bg-emerald-100 text-emerald-800"
: "bg-pink-100 text-pink-800"
}`}
>
{trend.isPositive ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
{Math.abs(trend.value)}%
</span>
)}
</div>
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>}
</div>
</Card>
);
};This version uses Lucide icons (TrendingUp/TrendingDown) instead of custom SVGs for portability across projects.
---
Layout: stat card grid
Display stat cards in a responsive grid:
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Total Properties"
value={metrics.totalProperties}
trend={{ value: 10, isPositive: true }}
subtitle="Last month total 38"
/>
<StatCard
title="Units Available"
value={metrics.unitsAvailable}
trend={{ value: 5, isPositive: false }}
subtitle="Last month total 12/42"
/>
<StatCard
title="Occupied Units"
value={metrics.occupiedUnits}
trend={{ value: 8, isPositive: true }}
subtitle="Last month total 27"
/>
</div>---
Computing trend values
Calculate trends from current vs previous period:
const trends = useMemo(() => {
const previousTotal = metrics.totalProperties - Math.round(metrics.totalProperties * 0.1);
const trendPercent = previousTotal > 0
? Math.round(((metrics.totalProperties - previousTotal) / previousTotal) * 100)
: 0;
return {
value: Math.abs(trendPercent),
isPositive: trendPercent >= 0,
};
}, [metrics]);---
Trend badge color conventions
| Trend | Background | Text | Meaning |
|---|---|---|---|
| Positive (up) | bg-emerald-100 | text-emerald-800 | Growth, improvement |
| Negative (down) | bg-pink-100 | text-pink-800 | Decline, concern |
| Neutral | bg-gray-100 | text-gray-600 | No change |
---
Accessibility
- Card uses
cursor-pointerandhover:shadow-lgonly whenonClickis provided. - Trend icons have implicit meaning from color + direction icon.
- Stat values use large, bold text for visibility.
- Title uses
uppercase tracking-widefor visual hierarchy without heading tags (appropriate in a card grid).