Compare total activity with its composition. Count view adds installs and updates; share view normalizes each release to 100%. The summary uses the combined event totals, not an average of release percentages. All data is illustrative.
"use client";
import { useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartSelect,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const releases = [
{ release: "R1", installs: 148, updates: 62 },
{ release: "R2", installs: 232, updates: 104 },
{ release: "R3", installs: 196, updates: 88 },
{ release: "R4", installs: 284, updates: 132 },
{ release: "R5", installs: 341, updates: 176 },
{ release: "R6", installs: 378, updates: 209 },
];
const config = {
installs: { label: "New installs", color: "var(--chart-1)" },
updates: {
label: "Updates",
color: "color-mix(in srgb, var(--chart-2) 65%, var(--foreground))",
},
} satisfies ChartConfig;
const number = new Intl.NumberFormat("en-US");
export default function ChartReleaseActivity() {
const [view, setView] = useState("count");
const [range, setRange] = useState("6");
const data = releases.slice(-Number(range));
const installs = data.reduce((total, row) => total + row.installs, 0);
const updates = data.reduce((total, row) => total + row.updates, 0);
const total = installs + updates;
const share = view === "share";
return (
<Card className="min-w-0" data-chart-recipe="activity">
<CardHeader>
<p className="font-mono text-xs uppercase tracking-widest">
Activity / Volume or composition?
</p>
<CardTitle>
<h3>Release adoption</h3>
</CardTitle>
<CardDescription>
Separate new adoption from repeat use. Illustrative event counts, not unique users.
</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-6">
<div className="flex flex-wrap gap-4">
<ChartSelect
label="Activity measure"
value={view}
onValueChange={setView}
options={[
{ value: "count", label: "Event counts" },
{ value: "share", label: "Share of each release" },
]}
/>
<ChartSelect
label="Release period"
value={range}
onValueChange={setRange}
options={[
{ value: "6", label: "All 6 releases" },
{ value: "3", label: "Latest 3 releases" },
]}
/>
</div>
<dl className="grid gap-4 border-y-2 border-border py-4 sm:grid-cols-3" aria-live="polite">
{[
["Total activity", number.format(total)],
["New installs", number.format(installs)],
["Update share", `${((updates / total) * 100).toFixed(1)}%`],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs">{label}</dt>
<dd className="mt-1 text-2xl font-heading tabular-nums">{value}</dd>
</div>
))}
</dl>
<figure
aria-label={
share ? "Install and update share by release" : "Install and update events by release"
}
className="min-w-0"
>
<ChartContainer config={config} className="h-72 w-full min-w-0 aspect-auto">
<AreaChart
accessibilityLayer
data={data}
stackOffset={share ? "expand" : "none"}
margin={{ top: 12, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid vertical={false} />
<XAxis dataKey="release" tickLine={false} axisLine={false} tickMargin={10} />
<YAxis
width={48}
domain={share ? [0, 1] : [0, "auto"]}
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
share ? `${Math.round(Number(value) * 100)}%` : number.format(Number(value))
}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) =>
`${name === "installs" ? "New installs" : "Updates"}: ${number.format(Number(value))} events`
}
/>
}
/>
<Area
dataKey="installs"
type="linear"
stackId="events"
fill="var(--color-installs)"
fillOpacity={0.7}
stroke="var(--foreground)"
strokeWidth={2}
isAnimationActive={false}
/>
<Area
dataKey="updates"
type="linear"
stackId="events"
fill="var(--color-updates)"
fillOpacity={0.6}
stroke="var(--foreground)"
strokeWidth={2}
strokeDasharray="6 4"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
<figcaption className="mt-3 text-xs leading-5">
Lower area / solid boundary: new installs. Upper area / dashed boundary: updates.
</figcaption>
</figure>
<output className="border-l-4 border-main pl-3 text-sm leading-6">
{share
? "Every release totals 100%. A larger share does not necessarily mean more events."
: "Stack height is total activity. The solid and dashed boundaries separate installs and updates."}
</output>
<details className="min-w-0 border-t-2 border-border pt-4">
<summary className="cursor-pointer text-sm font-heading">View activity data</summary>
<table className="mt-3 w-full text-left text-xs tabular-nums">
<caption className="pb-3 text-left">
The same {data.length} releases. Update share uses each release's total.
</caption>
<thead>
<tr>
{["Release", "Installs", "Updates", "Update share"].map((label) => (
<th key={label} scope="col" className="border-b-2 border-border py-2 pr-2">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.release}>
<th scope="row" className="py-3 pr-2">
{row.release}
</th>
<td>{row.installs}</td>
<td>{row.updates}</td>
<td>{((row.updates / (row.installs + row.updates)) * 100).toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</details>
</CardContent>
</Card>
);
}
#Installation
Complete the base setup. The CLI installs this recipe with Chart and Card without replacing your theme.
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/chart-release-activity.jsonnpm install recharts@^3.9.2Install Chart and Card, then create components/ui/chart-release-activity.tsx:
"use client";
import { useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartSelect,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const releases = [
{ release: "R1", installs: 148, updates: 62 },
{ release: "R2", installs: 232, updates: 104 },
{ release: "R3", installs: 196, updates: 88 },
{ release: "R4", installs: 284, updates: 132 },
{ release: "R5", installs: 341, updates: 176 },
{ release: "R6", installs: 378, updates: 209 },
];
const config = {
installs: { label: "New installs", color: "var(--chart-1)" },
updates: {
label: "Updates",
color: "color-mix(in srgb, var(--chart-2) 65%, var(--foreground))",
},
} satisfies ChartConfig;
const number = new Intl.NumberFormat("en-US");
export default function ChartReleaseActivity() {
const [view, setView] = useState("count");
const [range, setRange] = useState("6");
const data = releases.slice(-Number(range));
const installs = data.reduce((total, row) => total + row.installs, 0);
const updates = data.reduce((total, row) => total + row.updates, 0);
const total = installs + updates;
const share = view === "share";
return (
<Card className="min-w-0" data-chart-recipe="activity">
<CardHeader>
<p className="font-mono text-xs uppercase tracking-widest">
Activity / Volume or composition?
</p>
<CardTitle>
<h3>Release adoption</h3>
</CardTitle>
<CardDescription>
Separate new adoption from repeat use. Illustrative event counts, not unique users.
</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-6">
<div className="flex flex-wrap gap-4">
<ChartSelect
label="Activity measure"
value={view}
onValueChange={setView}
options={[
{ value: "count", label: "Event counts" },
{ value: "share", label: "Share of each release" },
]}
/>
<ChartSelect
label="Release period"
value={range}
onValueChange={setRange}
options={[
{ value: "6", label: "All 6 releases" },
{ value: "3", label: "Latest 3 releases" },
]}
/>
</div>
<dl className="grid gap-4 border-y-2 border-border py-4 sm:grid-cols-3" aria-live="polite">
{[
["Total activity", number.format(total)],
["New installs", number.format(installs)],
["Update share", `${((updates / total) * 100).toFixed(1)}%`],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs">{label}</dt>
<dd className="mt-1 text-2xl font-heading tabular-nums">{value}</dd>
</div>
))}
</dl>
<figure
aria-label={
share ? "Install and update share by release" : "Install and update events by release"
}
className="min-w-0"
>
<ChartContainer config={config} className="h-72 w-full min-w-0 aspect-auto">
<AreaChart
accessibilityLayer
data={data}
stackOffset={share ? "expand" : "none"}
margin={{ top: 12, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid vertical={false} />
<XAxis dataKey="release" tickLine={false} axisLine={false} tickMargin={10} />
<YAxis
width={48}
domain={share ? [0, 1] : [0, "auto"]}
tickLine={false}
axisLine={false}
tickFormatter={(value) =>
share ? `${Math.round(Number(value) * 100)}%` : number.format(Number(value))
}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) =>
`${name === "installs" ? "New installs" : "Updates"}: ${number.format(Number(value))} events`
}
/>
}
/>
<Area
dataKey="installs"
type="linear"
stackId="events"
fill="var(--color-installs)"
fillOpacity={0.7}
stroke="var(--foreground)"
strokeWidth={2}
isAnimationActive={false}
/>
<Area
dataKey="updates"
type="linear"
stackId="events"
fill="var(--color-updates)"
fillOpacity={0.6}
stroke="var(--foreground)"
strokeWidth={2}
strokeDasharray="6 4"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
<figcaption className="mt-3 text-xs leading-5">
Lower area / solid boundary: new installs. Upper area / dashed boundary: updates.
</figcaption>
</figure>
<output className="border-l-4 border-main pl-3 text-sm leading-6">
{share
? "Every release totals 100%. A larger share does not necessarily mean more events."
: "Stack height is total activity. The solid and dashed boundaries separate installs and updates."}
</output>
<details className="min-w-0 border-t-2 border-border pt-4">
<summary className="cursor-pointer text-sm font-heading">View activity data</summary>
<table className="mt-3 w-full text-left text-xs tabular-nums">
<caption className="pb-3 text-left">
The same {data.length} releases. Update share uses each release's total.
</caption>
<thead>
<tr>
{["Release", "Installs", "Updates", "Update share"].map((label) => (
<th key={label} scope="col" className="border-b-2 border-border py-2 pr-2">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.release}>
<th scope="row" className="py-3 pr-2">
{row.release}
</th>
<td>{row.installs}</td>
<td>{row.updates}</td>
<td>{((row.updates / (row.installs + row.updates)) * 100).toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</details>
</CardContent>
</Card>
);
}
#Usage
import ChartReleaseActivity from "@/components/ui/chart-release-activity";
export default function AdoptionReport() {
return <ChartReleaseActivity />;
}#API reference
This editable recipe has no required props. Change the constants in the installed source to connect application data.
| Source / control | Purpose |
|---|---|
releases | Ordered release labels with nonnegative install and update counts. |
config | Series labels and shared-theme colors. |
| Activity measure | Counts or shares, with matching vertical-axis units. |
| Release period | All six releases or the latest three. |
| View activity data | Exact counts and per-release update shares. |
For dynamic data, handle an empty array or zero totals before calculating shares. Do not interpret repeat events as unique users. Normalized areas show composition, not absolute volume; tooltips retain the original event counts.
#Accessibility
Both controls have visible labels and native keyboard behavior. Solid and dashed boundaries distinguish the two series in addition to color. The text summary and table provide the values without requiring hover. The chart has a definite height, an accessible figure label, and no entrance animation.