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.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Charts 1. https://ui.ballmac.com/blocks/charts-1
"use client"
import * as React from "react"
import { ArrowDown, ArrowUp, ChartNoAxesColumn } from "lucide-react"
import { ChartArea } from "@/components/ballmac/chart-area"
import { ChartBar } from "@/components/ballmac/chart-bar"
import { ChartPie } from "@/components/ballmac/chart-pie"
import { KpiItem, KpiRow } from "@/components/ballmac/kpi-row"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Charts1Day = { date: string; newVisitors: number; returning: number; signups: number }
type Charts1Page = { page: string; views: number }
type Charts1Source = { name: string; label: string; value: number }
type Charts1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
title?: string
description?: string
/** One row per day, oldest first, as ISO dates (2026-07-01). At least twice the longest range, so each range has a period to compare with. */
daily?: Charts1Day[]
/** Most viewed pages in the range. */
pages?: Charts1Page[]
/** Where visitors came from in the range. */
sources?: Charts1Source[]
/** The range shown at first: 7, 30 or 90 days. */
defaultRange?: "7" | "30" | "90"
/** Called when the range changes, so you can load that range's pages and sources. */
onRangeChange?: (range: "7" | "30" | "90") => void
locale?: string
}
// Sample data, worked out from the day number so the server and the browser agree.
const start = Date.UTC(2026, 3, 1)
const sampleDaily: Charts1Day[] = Array.from({ length: 180 }, (_, i) => {
const date = new Date(start + i * 86_400_000)
const weekend = [0, 6].includes(date.getUTCDay())
const newVisitors = Math.round(300 + i * 2.6 + Math.sin(i / 3.1) * 42 - (weekend ? 70 : 0))
const returning = Math.round(190 + i * 1.7 + Math.cos(i / 4.3) * 31 + (weekend ? 18 : 0))
return { date: date.toISOString().slice(0, 10), newVisitors, returning, signups: Math.round((newVisitors + returning) * (0.034 + Math.sin(i / 9) * 0.004)) }
})
const samplePages: Charts1Page[] = [
{ page: "/pricing", views: 18420 },
{ page: "/docs/get-started", views: 15310 },
{ page: "/blocks", views: 12980 },
{ page: "/components/button", views: 9640 },
{ page: "/themes", views: 7220 },
]
const sampleSources: Charts1Source[] = [
{ name: "search", label: "Search", value: 4210 },
{ name: "direct", label: "Direct", value: 2890 },
{ name: "social", label: "Social", value: 1630 },
{ name: "email", label: "Email", value: 980 },
]
const sum = (rows: Charts1Day[], pick: (d: Charts1Day) => number) => rows.reduce((total, d) => total + pick(d), 0)
function Skel() {
return (
<div aria-hidden="true" className="grid gap-6">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="aspect-[2/1] w-full rounded-xl" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-72 rounded-xl" />
</div>
</div>
)
}
/**
* An analytics overview: a range picker, four figures with their change from the period before, an area chart of new and returning visitors, the
* top pages and where visitors came from. Every figure is worked out from the daily rows, and each chart has a table view and a CSV download.
*/
function Charts1({
title = "Analytics",
description = "How many people visit, where they come from and what they read.",
daily = sampleDaily,
pages = samplePages,
sources = sampleSources,
defaultRange = "30",
onRangeChange,
locale = "en",
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Charts1Props) {
const id = React.useId()
const [range, setRange] = React.useState<"7" | "30" | "90">(defaultRange)
const days = Number(range)
const rows = state === "empty" ? [] : daily
const current = rows.slice(-days)
const before = rows.slice(-days * 2, -days)
const n = React.useMemo(() => new Intl.NumberFormat(locale), [locale])
const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }), [locale])
const change = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" }), [locale])
const day = React.useMemo(() => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", timeZone: "UTC" }), [locale])
const figures = React.useMemo(() => {
const visitors = (d: Charts1Day) => d.newVisitors + d.returning
const figs = [
{ label: "Visitors", now: sum(current, visitors), prev: sum(before, visitors), write: (v: number) => n.format(v) },
{ label: "Sign-ups", now: sum(current, (d) => d.signups), prev: sum(before, (d) => d.signups), write: (v: number) => n.format(v) },
{ label: "Conversion", now: sum(current, (d) => d.signups) / (sum(current, visitors) || 1), prev: sum(before, (d) => d.signups) / (sum(before, visitors) || 1), write: (v: number) => pct.format(v) },
{ label: "Returning", now: sum(current, (d) => d.returning) / (sum(current, visitors) || 1), prev: sum(before, (d) => d.returning) / (sum(before, visitors) || 1), write: (v: number) => pct.format(v) },
]
return figs.map((f) => ({ ...f, delta: before.length && f.prev ? (f.now - f.prev) / f.prev : null }))
}, [current, before, n, pct])
const content = !current.length ? null : (
<div className="grid gap-6">
<KpiRow columns={4} className="w-full">
{figures.map((f) => (
<KpiItem
key={f.label}
label={f.label}
value={f.write(f.now)}
detail={
f.delta === null ? (
"No earlier period to compare"
) : (
<span className="inline-flex items-center gap-1">
{f.delta >= 0 ? <ArrowUp aria-hidden="true" className="size-3.5 text-chart-2" /> : <ArrowDown aria-hidden="true" className="text-destructive size-3.5" />}
<span className="text-foreground font-medium tabular-nums">{change.format(f.delta)}</span>
<span className="sr-only">{f.delta >= 0 ? " up" : " down"}</span> vs the previous {days} days
</span>
)
}
/>
))}
</KpiRow>
<ChartArea
title="Visitors"
description={`New and returning, the last ${days} days`}
data={current}
xKey="date"
categoryLabel="Day"
categoryFormatter={(v) => day.format(new Date(`${String(v)}T00:00:00Z`))}
locale={locale}
stack="stacked"
fillMode="gradient"
aspect="panorama"
series={[
{ key: "newVisitors", label: "New" },
{ key: "returning", label: "Returning", color: "var(--chart-3)" },
]}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 [&>*]:min-w-0">
<ChartBar
title="Top pages"
description="Views in the range"
data={pages}
xKey="page"
categoryLabel="Page"
layout="bars"
labels
valueAxis={false}
grid="none"
sort="descending"
locale={locale}
series={[{ key: "views", label: "Views" }]}
aspect="wide"
/>
<ChartPie title="Where visitors come from" description="Share of visits" data={sources} valueLabel="visits" categoryLabel="Source" locale={locale} aspect="video" />
</div>
</div>
)
return (
<section data-slot="charts-1" 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 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
{title}
</h1>
<p className="text-muted-foreground mt-2 text-pretty">{description}</p>
</div>
{state === "ready" ? (
<SegmentedControl
aria-label="Range"
value={range}
onValueChange={(v) => {
setRange(v as "7" | "30" | "90")
onRangeChange?.(v as "7" | "30" | "90")
}}
>
<SegmentedControlItem value="7">7 days</SegmentedControlItem>
<SegmentedControlItem value="30">30 days</SegmentedControlItem>
<SegmentedControlItem value="90">90 days</SegmentedControlItem>
</SegmentedControl>
) : null}
</header>
<StateView
state={state === "empty" || !current.length ? "empty" : state === "loading" || state === "error" ? state : "ready"}
onRetry={onRetry}
label="Loading analytics"
loading={<Skel />}
empty={{ icon: <ChartNoAxesColumn />, title: emptyState?.title ?? "No visits yet", description: emptyState?.description ?? "Once people visit, their numbers and charts appear here.", action: emptyState?.action }}
error={{ title: errorState?.title ?? "Analytics could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}
>
{content}
</StateView>
</section>
)
}
export { Charts1, type Charts1Props, type Charts1Day, type Charts1Page, type Charts1Source }Installation
$ pnpm dlx shadcn@latest add @ballmac/charts-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart-area @ballmac/chart-bar @ballmac/chart-pie @ballmac/kpi-row @ballmac/segmented-control @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/charts-1/charts-1.tsx// Ballmac UI: Charts 1. https://ui.ballmac.com/blocks/charts-1 "use client" import * as React from "react" import { ArrowDown, ArrowUp, ChartNoAxesColumn } from "lucide-react" import { ChartArea } from "@/components/ballmac/chart-area" import { ChartBar } from "@/components/ballmac/chart-bar" import { ChartPie } from "@/components/ballmac/chart-pie" import { KpiItem, KpiRow } from "@/components/ballmac/kpi-row" import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Charts1Day = { date: string; newVisitors: number; returning: number; signups: number } type Charts1Page = { page: string; views: number } type Charts1Source = { name: string; label: string; value: number } type Charts1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { title?: string description?: string /** One row per day, oldest first, as ISO dates (2026-07-01). At least twice the longest range, so each range has a period to compare with. */ daily?: Charts1Day[] /** Most viewed pages in the range. */ pages?: Charts1Page[] /** Where visitors came from in the range. */ sources?: Charts1Source[] /** The range shown at first: 7, 30 or 90 days. */ defaultRange?: "7" | "30" | "90" /** Called when the range changes, so you can load that range's pages and sources. */ onRangeChange?: (range: "7" | "30" | "90") => void locale?: string } // Sample data, worked out from the day number so the server and the browser agree. const start = Date.UTC(2026, 3, 1) const sampleDaily: Charts1Day[] = Array.from({ length: 180 }, (_, i) => { const date = new Date(start + i * 86_400_000) const weekend = [0, 6].includes(date.getUTCDay()) const newVisitors = Math.round(300 + i * 2.6 + Math.sin(i / 3.1) * 42 - (weekend ? 70 : 0)) const returning = Math.round(190 + i * 1.7 + Math.cos(i / 4.3) * 31 + (weekend ? 18 : 0)) return { date: date.toISOString().slice(0, 10), newVisitors, returning, signups: Math.round((newVisitors + returning) * (0.034 + Math.sin(i / 9) * 0.004)) } }) const samplePages: Charts1Page[] = [ { page: "/pricing", views: 18420 }, { page: "/docs/get-started", views: 15310 }, { page: "/blocks", views: 12980 }, { page: "/components/button", views: 9640 }, { page: "/themes", views: 7220 }, ] const sampleSources: Charts1Source[] = [ { name: "search", label: "Search", value: 4210 }, { name: "direct", label: "Direct", value: 2890 }, { name: "social", label: "Social", value: 1630 }, { name: "email", label: "Email", value: 980 }, ] const sum = (rows: Charts1Day[], pick: (d: Charts1Day) => number) => rows.reduce((total, d) => total + pick(d), 0) function Skel() { return ( <div aria-hidden="true" className="grid gap-6"> <Skeleton className="h-24 rounded-xl" /> <Skeleton className="aspect-[2/1] w-full rounded-xl" /> <div className="grid gap-6 lg:grid-cols-2"> <Skeleton className="h-72 rounded-xl" /> <Skeleton className="h-72 rounded-xl" /> </div> </div> ) } /** * An analytics overview: a range picker, four figures with their change from the period before, an area chart of new and returning visitors, the * top pages and where visitors came from. Every figure is worked out from the daily rows, and each chart has a table view and a CSV download. */ function Charts1({ title = "Analytics", description = "How many people visit, where they come from and what they read.", daily = sampleDaily, pages = samplePages, sources = sampleSources, defaultRange = "30", onRangeChange, locale = "en", state = "ready", onRetry, emptyState, errorState, className, ...props }: Charts1Props) { const id = React.useId() const [range, setRange] = React.useState<"7" | "30" | "90">(defaultRange) const days = Number(range) const rows = state === "empty" ? [] : daily const current = rows.slice(-days) const before = rows.slice(-days * 2, -days) const n = React.useMemo(() => new Intl.NumberFormat(locale), [locale]) const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }), [locale]) const change = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" }), [locale]) const day = React.useMemo(() => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", timeZone: "UTC" }), [locale]) const figures = React.useMemo(() => { const visitors = (d: Charts1Day) => d.newVisitors + d.returning const figs = [ { label: "Visitors", now: sum(current, visitors), prev: sum(before, visitors), write: (v: number) => n.format(v) }, { label: "Sign-ups", now: sum(current, (d) => d.signups), prev: sum(before, (d) => d.signups), write: (v: number) => n.format(v) }, { label: "Conversion", now: sum(current, (d) => d.signups) / (sum(current, visitors) || 1), prev: sum(before, (d) => d.signups) / (sum(before, visitors) || 1), write: (v: number) => pct.format(v) }, { label: "Returning", now: sum(current, (d) => d.returning) / (sum(current, visitors) || 1), prev: sum(before, (d) => d.returning) / (sum(before, visitors) || 1), write: (v: number) => pct.format(v) }, ] return figs.map((f) => ({ ...f, delta: before.length && f.prev ? (f.now - f.prev) / f.prev : null })) }, [current, before, n, pct]) const content = !current.length ? null : ( <div className="grid gap-6"> <KpiRow columns={4} className="w-full"> {figures.map((f) => ( <KpiItem key={f.label} label={f.label} value={f.write(f.now)} detail={ f.delta === null ? ( "No earlier period to compare" ) : ( <span className="inline-flex items-center gap-1"> {f.delta >= 0 ? <ArrowUp aria-hidden="true" className="size-3.5 text-chart-2" /> : <ArrowDown aria-hidden="true" className="text-destructive size-3.5" />} <span className="text-foreground font-medium tabular-nums">{change.format(f.delta)}</span> <span className="sr-only">{f.delta >= 0 ? " up" : " down"}</span> vs the previous {days} days </span> ) } /> ))} </KpiRow> <ChartArea title="Visitors" description={`New and returning, the last ${days} days`} data={current} xKey="date" categoryLabel="Day" categoryFormatter={(v) => day.format(new Date(`${String(v)}T00:00:00Z`))} locale={locale} stack="stacked" fillMode="gradient" aspect="panorama" series={[ { key: "newVisitors", label: "New" }, { key: "returning", label: "Returning", color: "var(--chart-3)" }, ]} /> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2 [&>*]:min-w-0"> <ChartBar title="Top pages" description="Views in the range" data={pages} xKey="page" categoryLabel="Page" layout="bars" labels valueAxis={false} grid="none" sort="descending" locale={locale} series={[{ key: "views", label: "Views" }]} aspect="wide" /> <ChartPie title="Where visitors come from" description="Share of visits" data={sources} valueLabel="visits" categoryLabel="Source" locale={locale} aspect="video" /> </div> </div> ) return ( <section data-slot="charts-1" 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 flex flex-wrap items-end justify-between gap-4"> <div> <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance"> {title} </h1> <p className="text-muted-foreground mt-2 text-pretty">{description}</p> </div> {state === "ready" ? ( <SegmentedControl aria-label="Range" value={range} onValueChange={(v) => { setRange(v as "7" | "30" | "90") onRangeChange?.(v as "7" | "30" | "90") }} > <SegmentedControlItem value="7">7 days</SegmentedControlItem> <SegmentedControlItem value="30">30 days</SegmentedControlItem> <SegmentedControlItem value="90">90 days</SegmentedControlItem> </SegmentedControl> ) : null} </header> <StateView state={state === "empty" || !current.length ? "empty" : state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading analytics" loading={<Skel />} empty={{ icon: <ChartNoAxesColumn />, title: emptyState?.title ?? "No visits yet", description: emptyState?.description ?? "Once people visit, their numbers and charts appear here.", action: emptyState?.action }} error={{ title: errorState?.title ?? "Analytics could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }} > {content} </StateView> </section> ) } export { Charts1, type Charts1Props, type Charts1Day, type Charts1Page, type Charts1Source }Update the import paths to match your project setup.
Usage
"use client"
import { Charts1 } from "@/components/ballmac/blocks/charts-1/charts-1"
export default function Charts1Demo() {
return <Charts1 />
}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 charts, under the real heading, announced as loading.
state="loading"No visits yet
Says the numbers appear once people visit; emptyState sets the copy.
state="empty" (or daily={[]})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 (1% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
15 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-1.test.tsx.
For information: Footprint
3.3 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
<Charts1 title description daily={[{ date, newVisitors, returning, signups }]} pages sources defaultRange='30' onRangeChange state />. daily is one row per day, oldest first, at least twice the longest range. Figures (visitors, sign-ups, conversion, returning share) and their change from the previous period are worked out from daily. Pages and sources are what you pass; reload them in onRangeChange. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Charts 1: analytics overview (@ballmac/charts-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The overview page of a product or marketing site's analytics
- A starting point for any metric dashboard with a range picker
Not for
- A funnel (use funnel-1)
- Cohort retention (use retention-1)
- One chart only (use chart-area, chart-bar or chart-pie)
Registry JSON: https://ui.ballmac.com/r/charts-1.json
Free to use in personal and commercial projects.
More charts blocks
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.
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.
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.