Charts 4: scorecard
A quarterly scorecard: goals met and average progress, concentric rings for the goals, a radar of the team against its targets and a ranked bar chart, with a table view and CSV download on every chart.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Charts 4. https://ui.ballmac.com/blocks/charts-4
"use client"
import * as React from "react"
import { Target } from "lucide-react"
import { ChartBar } from "@/components/ballmac/chart-bar"
import { ChartRadar } from "@/components/ballmac/chart-radar"
import { ChartRadial } from "@/components/ballmac/chart-radial"
import { KpiItem, KpiRow } from "@/components/ballmac/kpi-row"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Charts4Goal = { name: string; label: string; /** Percent of the target reached. */ value: number }
type Charts4Area = { area: string; current: number; target: number }
type Charts4Leader = { name: string; value: number }
type Charts4Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
title?: string
description?: string
/** The goals, each as the percent of its target reached. Up to four read well as rings. */
goals?: Charts4Goal[]
/** How the team scores on each area, against the score it is aiming for. 0 to 100. */
profile?: Charts4Area[]
/** The people or teams to rank. */
leaders?: Charts4Leader[]
/** Writes a leader's number. */
leaderFormatter?: (value: number) => string
/** What the leaders are ranked on: "Closed deals". */
leaderTitle?: string
locale?: string
}
const sampleGoals: Charts4Goal[] = [
{ name: "revenue", label: "Revenue", value: 92 },
{ name: "retention", label: "Retention", value: 78 },
{ name: "launches", label: "Launches", value: 60 },
]
const sampleProfile: Charts4Area[] = [
{ area: "Quality", current: 82, target: 90 },
{ area: "Speed", current: 68, target: 80 },
{ area: "Cost", current: 74, target: 75 },
{ area: "Support", current: 88, target: 85 },
{ area: "Docs", current: 57, target: 80 },
{ area: "Security", current: 79, target: 90 },
]
const sampleLeaders: Charts4Leader[] = [
{ name: "Maya Jensen", value: 412000 },
{ name: "Tomás Rivera", value: 368000 },
{ name: "Aiko Tanaka", value: 331000 },
{ name: "Priya Nair", value: 296000 },
{ name: "Liam O'Connor", value: 244000 },
]
function Skel() {
return (
<div aria-hidden="true" className="grid gap-6">
<Skeleton className="h-24 rounded-xl" />
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="aspect-square rounded-xl" />
<Skeleton className="aspect-square rounded-xl" />
</div>
<Skeleton className="aspect-[2/1] rounded-xl" />
</div>
)
}
/**
* A quarterly scorecard: how many goals are met and how far along the rest are, concentric rings for the goals, a radar of the team against its targets
* and a ranked bar chart. The headline figures are worked out from the data, and every chart has a table view and a CSV download.
*/
function Charts4({
title = "Quarterly scorecard",
description = "Goals, how the team scores and who is leading.",
goals = sampleGoals,
profile = sampleProfile,
leaders = sampleLeaders,
leaderFormatter = (v) => `$${Math.round(v / 1000)}k`,
leaderTitle = "Closed deals",
locale = "en",
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Charts4Props) {
const id = React.useId()
const g = state === "empty" ? [] : goals
const met = g.filter((x) => x.value >= 100).length
const average = g.length ? g.reduce((sum, x) => sum + x.value, 0) / g.length : 0
const gaps = profile.map((a) => ({ ...a, gap: a.target - a.current })).sort((a, b) => b.gap - a.gap)
const widest = gaps[0]
const top = [...leaders].sort((a, b) => b.value - a.value)[0]
const pct = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 })
const content = !g.length ? null : (
<div className="grid gap-6">
<KpiRow columns={4} className="w-full">
<KpiItem label="Goals met" value={`${met} of ${g.length}`} detail="At or over their target" />
<KpiItem label="Average progress" value={`${pct.format(average)}%`} detail="Across every goal" />
<KpiItem label="Biggest gap" value={widest && widest.gap > 0 ? widest.area : "None"} detail={widest && widest.gap > 0 ? `${widest.gap} points under target` : "Every area is on target"} />
<KpiItem label="Leading" value={top?.name ?? "–"} detail={top ? `${leaderFormatter(top.value)} ${leaderTitle.toLowerCase()}` : undefined} />
</KpiRow>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 [&>*]:min-w-0">
<ChartRadial title="Goals" description="Percent of each target" data={g} centerLabel={false} valueFormatter={(v) => `${v}%`} valueLabel="of target" categoryLabel="Goal" locale={locale} />
<ChartRadar
title="Team profile"
description="Score against the target, 0 to 100"
data={profile}
xKey="area"
categoryLabel="Area"
domain={[0, 100]}
locale={locale}
series={[
{ key: "target", label: "Target", color: "var(--chart-3)" },
{ key: "current", label: "Current" },
]}
/>
</div>
<ChartBar title={leaderTitle} description="This quarter, highest first" data={leaders} xKey="name" categoryLabel="Name" layout="bars" sort="descending" labels valueAxis={false} grid="none" highlight={top?.name} valueFormatter={leaderFormatter} locale={locale} aspect="panorama" series={[{ key: "value", label: leaderTitle }]} radius={8} />
</div>
)
return (
<section data-slot="charts-4" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
<header className="mb-8">
<h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
{title}
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{description}</p>
</header>
<StateView
state={state === "empty" || !g.length ? "empty" : state === "loading" || state === "error" ? state : "ready"}
onRetry={onRetry}
label="Loading the scorecard"
loading={<Skel />}
empty={{ icon: <Target />, title: emptyState?.title ?? "No goals yet", description: emptyState?.description ?? "Set a goal and its progress appears here.", action: emptyState?.action }}
error={{ title: errorState?.title ?? "The scorecard could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}
>
{content}
</StateView>
</section>
)
}
export { Charts4, type Charts4Props, type Charts4Goal, type Charts4Area, type Charts4Leader }Installation
$ pnpm dlx shadcn@latest add @ballmac/charts-4Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart-bar @ballmac/chart-radar @ballmac/chart-radial @ballmac/kpi-row @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/charts-4/charts-4.tsx// Ballmac UI: Charts 4. https://ui.ballmac.com/blocks/charts-4 "use client" import * as React from "react" import { Target } from "lucide-react" import { ChartBar } from "@/components/ballmac/chart-bar" import { ChartRadar } from "@/components/ballmac/chart-radar" import { ChartRadial } from "@/components/ballmac/chart-radial" import { KpiItem, KpiRow } from "@/components/ballmac/kpi-row" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Charts4Goal = { name: string; label: string; /** Percent of the target reached. */ value: number } type Charts4Area = { area: string; current: number; target: number } type Charts4Leader = { name: string; value: number } type Charts4Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { title?: string description?: string /** The goals, each as the percent of its target reached. Up to four read well as rings. */ goals?: Charts4Goal[] /** How the team scores on each area, against the score it is aiming for. 0 to 100. */ profile?: Charts4Area[] /** The people or teams to rank. */ leaders?: Charts4Leader[] /** Writes a leader's number. */ leaderFormatter?: (value: number) => string /** What the leaders are ranked on: "Closed deals". */ leaderTitle?: string locale?: string } const sampleGoals: Charts4Goal[] = [ { name: "revenue", label: "Revenue", value: 92 }, { name: "retention", label: "Retention", value: 78 }, { name: "launches", label: "Launches", value: 60 }, ] const sampleProfile: Charts4Area[] = [ { area: "Quality", current: 82, target: 90 }, { area: "Speed", current: 68, target: 80 }, { area: "Cost", current: 74, target: 75 }, { area: "Support", current: 88, target: 85 }, { area: "Docs", current: 57, target: 80 }, { area: "Security", current: 79, target: 90 }, ] const sampleLeaders: Charts4Leader[] = [ { name: "Maya Jensen", value: 412000 }, { name: "Tomás Rivera", value: 368000 }, { name: "Aiko Tanaka", value: 331000 }, { name: "Priya Nair", value: 296000 }, { name: "Liam O'Connor", value: 244000 }, ] function Skel() { return ( <div aria-hidden="true" className="grid gap-6"> <Skeleton className="h-24 rounded-xl" /> <div className="grid gap-6 md:grid-cols-2"> <Skeleton className="aspect-square rounded-xl" /> <Skeleton className="aspect-square rounded-xl" /> </div> <Skeleton className="aspect-[2/1] rounded-xl" /> </div> ) } /** * A quarterly scorecard: how many goals are met and how far along the rest are, concentric rings for the goals, a radar of the team against its targets * and a ranked bar chart. The headline figures are worked out from the data, and every chart has a table view and a CSV download. */ function Charts4({ title = "Quarterly scorecard", description = "Goals, how the team scores and who is leading.", goals = sampleGoals, profile = sampleProfile, leaders = sampleLeaders, leaderFormatter = (v) => `$${Math.round(v / 1000)}k`, leaderTitle = "Closed deals", locale = "en", state = "ready", onRetry, emptyState, errorState, className, ...props }: Charts4Props) { const id = React.useId() const g = state === "empty" ? [] : goals const met = g.filter((x) => x.value >= 100).length const average = g.length ? g.reduce((sum, x) => sum + x.value, 0) / g.length : 0 const gaps = profile.map((a) => ({ ...a, gap: a.target - a.current })).sort((a, b) => b.gap - a.gap) const widest = gaps[0] const top = [...leaders].sort((a, b) => b.value - a.value)[0] const pct = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }) const content = !g.length ? null : ( <div className="grid gap-6"> <KpiRow columns={4} className="w-full"> <KpiItem label="Goals met" value={`${met} of ${g.length}`} detail="At or over their target" /> <KpiItem label="Average progress" value={`${pct.format(average)}%`} detail="Across every goal" /> <KpiItem label="Biggest gap" value={widest && widest.gap > 0 ? widest.area : "None"} detail={widest && widest.gap > 0 ? `${widest.gap} points under target` : "Every area is on target"} /> <KpiItem label="Leading" value={top?.name ?? "–"} detail={top ? `${leaderFormatter(top.value)} ${leaderTitle.toLowerCase()}` : undefined} /> </KpiRow> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 [&>*]:min-w-0"> <ChartRadial title="Goals" description="Percent of each target" data={g} centerLabel={false} valueFormatter={(v) => `${v}%`} valueLabel="of target" categoryLabel="Goal" locale={locale} /> <ChartRadar title="Team profile" description="Score against the target, 0 to 100" data={profile} xKey="area" categoryLabel="Area" domain={[0, 100]} locale={locale} series={[ { key: "target", label: "Target", color: "var(--chart-3)" }, { key: "current", label: "Current" }, ]} /> </div> <ChartBar title={leaderTitle} description="This quarter, highest first" data={leaders} xKey="name" categoryLabel="Name" layout="bars" sort="descending" labels valueAxis={false} grid="none" highlight={top?.name} valueFormatter={leaderFormatter} locale={locale} aspect="panorama" series={[{ key: "value", label: leaderTitle }]} radius={8} /> </div> ) return ( <section data-slot="charts-4" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}> <header className="mb-8"> <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance"> {title} </h1> <p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{description}</p> </header> <StateView state={state === "empty" || !g.length ? "empty" : state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the scorecard" loading={<Skel />} empty={{ icon: <Target />, title: emptyState?.title ?? "No goals yet", description: emptyState?.description ?? "Set a goal and its progress appears here.", action: emptyState?.action }} error={{ title: errorState?.title ?? "The scorecard could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }} > {content} </StateView> </section> ) } export { Charts4, type Charts4Props, type Charts4Goal, type Charts4Area, type Charts4Leader }Update the import paths to match your project setup.
Usage
"use client"
import { Charts4 } from "@/components/ballmac/blocks/charts-4/charts-4"
export default function Charts4Demo() {
return <Charts4 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeletons shaped like the figures and the three charts, under the real heading, announced as loading.
state="loading"No goals yet
Says progress appears once a goal is set; emptyState sets the copy.
state="empty" (or goals={[]})Could not load
An alert with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 4 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (5% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
12 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by charts-4.test.tsx.
For information: Footprint
2.5 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
<Charts4 title description goals={[{ name, label, value }]} profile={[{ area, current, target }]} leaders={[{ name, value }]} leaderFormatter leaderTitle state />. goals are percents of target (100 is met). profile scores run 0 to 100. The goals met, average progress, biggest gap and leader are worked out from the data. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Charts 4: scorecard (@ballmac/charts-4) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Quarterly or sprint reviews
- OKR and team performance pages
Not for
- Time series (use charts-1 or charts-3)
- Revenue (use charts-2)
Registry JSON: https://ui.ballmac.com/r/charts-4.json
Free to use in personal and commercial projects.
More charts blocks
Charts 1: analytics overview
An analytics overview: a range picker, four figures with their change from the period before, a stacked area chart of new and returning visitors, top pages and a sources donut, each with a table view and CSV download.
Charts 2: revenue report
A revenue report: monthly recurring revenue, annual run rate, what was added last month and the largest plan, then revenue by plan as stacked columns and net revenue retention as a line against the 100% mark.
Charts 3: small multiples
Small multiples: one small area chart per metric with the latest value, its change over the period in words and an arrow, which direction is good news, and a written summary for screen readers.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.