- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Work completed this year
704
+12.4%704
Tasks completed
65
Best month
8h 20m
Longest session
0 days
Current streak
Activity
JanFebMarAprMayJunJulAugSepOctNovDec
Usage
Pass daily data entries with ISO date strings and counts, plus an array of summary stats. Choose an accent using the palette prop. The interval controls group the supplied observations by week, month, or year. Hover a cell or bar to read its exact total.
Source
"use client";
import { cn } from "cn";
import * as React from "react";
import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Reveal } from "@/components/ui/reveal";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export interface ContributionDay {
date: string;
count: number;
}
export interface ContributionStat {
label: string;
value: string;
}
export type ContributionPalette =
| "purple"
| "blue"
| "cyan"
| "teal"
| "green"
| "lime"
| "orange"
| "rose"
| "gray";
const palettes: Record<ContributionPalette, string> = {
purple: "var(--contributions-purple, var(--chart-5))",
blue: "var(--contributions-blue, var(--primary))",
cyan: "var(--contributions-cyan, var(--chart-4))",
teal: "var(--contributions-teal, var(--chart-1))",
green: "var(--contributions-green, var(--chart-1))",
lime: "var(--contributions-lime, var(--chart-1))",
orange: "var(--contributions-orange, var(--chart-3))",
rose: "var(--contributions-rose, var(--chart-3))",
gray: "var(--foreground-tertiary, var(--muted-foreground))",
};
// A stepped ramp for the purple palette, one token per level.
const purpleRamp = [
"var(--contributions-purple-1, color-mix(in srgb, var(--contributions-purple, var(--chart-5)) 30%, var(--surface-sunken, var(--muted))))",
"var(--contributions-purple-2, color-mix(in srgb, var(--contributions-purple, var(--chart-5)) 55%, var(--surface-sunken, var(--muted))))",
"var(--contributions-purple-3, color-mix(in srgb, var(--contributions-purple, var(--chart-5)) 80%, var(--surface-sunken, var(--muted))))",
"var(--contributions-purple-4, var(--contributions-purple, var(--chart-5)))",
];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
type Period = "weekly" | "monthly" | "yearly";
const periodLabels: Record<Period, string> = {
weekly: "Weekly",
monthly: "Monthly",
yearly: "Yearly",
};
const cellColor = (
palette: ContributionPalette,
count: number,
maximum: number
) =>
count <= 0
? "var(--contributions-empty, var(--border))"
: palette === "purple"
? purpleRamp[
Math.min(3, Math.max(0, Math.ceil((count / maximum) * 4) - 1))
]
: `color-mix(in srgb, ${palettes[palette]} ${Math.max(25, Math.ceil((count / maximum) * 4) * 25)}%, var(--surface-sunken, var(--muted)))`;
/**
* The daily cells. They are memoised because the hint under the pointer
* re-renders the card on every cell, and the grid has hundreds of them. The
* grid reveals as one piece (a left-to-right sweep on the container) instead
* of one animation per cell.
*/
const DailyGrid = React.memo(function DailyGrid({
dates,
dayMap,
dailyMax,
palette,
weeks,
}: {
dates: string[];
dayMap: Map<string, number>;
dailyMax: number;
palette: ContributionPalette;
weeks: number;
}) {
const roomy = weeks < 53;
return (
<div
className={cn(
"contributions-card-01-sweep grid grid-flow-col grid-cols-(--weeks) grid-rows-7",
roomy ? "gap-0.75" : "gap-0.5"
)}
style={
{
"--weeks": `repeat(${weeks}, minmax(0, 1fr))`,
} as React.CSSProperties
}
>
{dates.map((date) => {
const count = dayMap.get(date) ?? 0;
return (
<span
key={date}
data-contribution={date}
data-count={count}
className={cn(
"contributions-card-01-cell aspect-square bg-(--cell) hover:ring-1 hover:ring-foreground/25",
roomy ? "rounded-[3px]" : "rounded-[1px]"
)}
style={
{
"--cell": cellColor(palette, count, dailyMax),
} as React.CSSProperties
}
/>
);
})}
</div>
);
});
export function ContributionsCard({
data,
stats,
title = "Activity this year",
change,
palette = "purple",
weeks = 53,
className,
}: {
data: ContributionDay[];
stats: ContributionStat[];
title?: string;
change?: string;
palette?: ContributionPalette;
/** Weeks of daily cells to draw. Fewer weeks give larger, rounder cells. */
weeks?: number;
className?: string;
}) {
const [period, setPeriod] = React.useState<Period>("weekly");
const [hint, setHint] = React.useState<string | null>(null);
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const total = data.reduce((sum, day) => sum + Math.max(0, day.count), 0);
const totalRef = useCountUp<HTMLParagraphElement>(total);
// The same observations are grouped into different time buckets for each view.
const buckets = React.useMemo(() => {
const sorted = [...data]
.filter(
(day) =>
/^\d{4}-\d{2}-\d{2}$/.test(day.date) &&
!Number.isNaN(Date.parse(`${day.date}T00:00:00Z`))
)
.toSorted((a, b) => a.date.localeCompare(b.date));
const grouped = new Map<string, number>();
for (const day of sorted) {
const date = new Date(`${day.date}T00:00:00Z`);
if (period === "weekly") {
date.setUTCDate(date.getUTCDate() - date.getUTCDay());
}
const key =
period === "weekly"
? date.toISOString().slice(0, 10)
: period === "monthly"
? day.date.slice(0, 7)
: day.date.slice(0, 4);
grouped.set(key, (grouped.get(key) ?? 0) + Math.max(0, day.count));
}
return Array.from(grouped, ([label, count]) => ({ label, count }));
}, [data, period]);
const max = Math.max(1, ...buckets.map((bucket) => bucket.count));
const dayMap = React.useMemo(
() => new Map(data.map((day) => [day.date, Math.max(0, day.count)])),
[data]
);
const dailyMax = Math.max(1, ...dayMap.values());
const dates = React.useMemo(() => {
const latest = [...dayMap.keys()]
.filter(
(date) =>
/^\d{4}-\d{2}-\d{2}$/.test(date) &&
!Number.isNaN(Date.parse(`${date}T00:00:00Z`))
)
.toSorted()
.at(-1);
if (!latest) {
return [];
}
const end = new Date(`${latest}T00:00:00Z`);
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
return Array.from({ length: weeks * 7 }, (_, index) => {
const date = new Date(end);
date.setUTCDate(end.getUTCDate() - (weeks * 7 - 1) + index);
return date.toISOString().slice(0, 10);
});
}, [dayMap, weeks]);
const roomy = weeks < 53;
const hideHint = () => setTooltipOpen(false);
const showHint = (event: React.PointerEvent<HTMLDivElement>) => {
const target = (event.target as HTMLElement).closest<HTMLElement>(
"[data-contribution]"
);
const label = target?.dataset.contribution;
if (!target || !label) {
hideHint();
return;
}
setHint(`${label}: ${target.dataset.count ?? 0}`);
setTooltipOpen(true);
};
return (
<Reveal
variant="card"
render={
<Card
radius={16}
variant="muted"
size="xs"
className={cn("min-w-0", className)}
/>
}
>
<CardHeader className="mb-1.5 gap-0">
<CardTitle variant="secondary">{title}</CardTitle>
<div className="flex items-center gap-2">
<p
ref={totalRef}
className="text-2xl leading-8.5 font-medium tabular-nums"
>
{total.toLocaleString("en-US")}
</p>
{change && (
<Badge variant="label" tone="lime">
{change}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.label} radius={10} variant="raised" size="xs">
<CardContent className="gap-0">
<p className="text-sm leading-5 font-medium">{stat.value}</p>
<p className="text-sm leading-5 text-muted-foreground">
{stat.label}
</p>
</CardContent>
</Card>
))}
</CardContent>
<CardHeader className="-mt-1 flex items-center justify-between">
<CardTitle variant="secondary">Activity</CardTitle>
<div>
<Tabs
value={period}
onValueChange={(value) => {
if (
value === "weekly" ||
value === "monthly" ||
value === "yearly"
) {
setPeriod(value);
setHint(null);
setTooltipOpen(false);
}
}}
>
<TabsList variant="ghost" aria-label="Activity interval">
{(["weekly", "monthly", "yearly"] as const).map((value) => (
<TabsTrigger key={value} value={value}>
{periodLabels[value]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent className="mx-1.5">
<Tooltip
open={tooltipOpen}
onOpenChange={(open) => {
if (!open) {
setTooltipOpen(false);
}
}}
trackCursorAxis="both"
>
<TooltipTrigger
delay={120}
render={<div className="overflow-x-auto" />}
>
<div
className="@container min-w-[400px]"
onPointerOver={showHint}
onPointerLeave={hideHint}
>
{period === "weekly" ? (
<div
role="img"
aria-label={`Daily activity over ${weeks} weeks: ${total.toLocaleString()} contributions`}
>
<DailyGrid
dates={dates}
dayMap={dayMap}
dailyMax={dailyMax}
palette={palette}
weeks={weeks}
/>
<div
className={cn(
// oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: axis labels are 13px
"flex justify-between text-[13px] leading-4.5 text-muted-foreground",
roomy ? "mt-2" : "mt-1"
)}
>
{months.map((month) => (
<span key={month}>{month}</span>
))}
</div>
</div>
) : (
<div role="img" aria-label={`${period} activity totals`}>
<div
key={period}
className="flex h-[calc((100cqw_-_104px)*7/53_+_12px)] items-end gap-1"
>
{buckets.map((bucket, index) => (
<div
key={bucket.label}
data-contribution={bucket.label}
data-count={bucket.count}
className="h-(--h) min-w-2 flex-1 contributions-card-01-bar origin-bottom rounded-[3px] bg-(--bar) hover:brightness-90"
style={
{
"--h": `${Math.max(5, (bucket.count / max) * 100)}%`,
"--bar": cellColor(palette, bucket.count, max),
"--delay": `${Math.min(index * 8, 240)}ms`,
} as React.CSSProperties
}
/>
))}
</div>
{/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: axis labels are 13px */}
<div className="mt-1 flex justify-between gap-2 text-[13px] leading-4.5 text-muted-foreground">
<span>{buckets[0]?.label}</span>
<span>{buckets.at(-1)?.label}</span>
</div>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
</CardContent>
</Reveal>
);
}