- 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
Activity calendar
May
148,645 steps this month
Apr
Apr
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
May
May
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
Jun
Jun
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
Jul
Jul
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
Daily activity · May 12, 2026
- Energy
598 kcal
- Training
70 min
- Distance
5.6 km
Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/most-active-days-card-01
Provide daily records with an ISO date, steps, energy, training minutes, distance, and three progress values between zero and one. Set initialDate to choose the initially selected day.
Source
"use client";
import { cn } from "cn";
import * as React from "react";
import { useCountUp } from "@/components/hooks/use-count-up";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
} from "@/components/ui/empty";
import {
PeriodSwitcher,
PeriodSwitcherLabel,
PeriodSwitcherNext,
PeriodSwitcherPrevious,
} from "@/components/ui/period-switcher";
import { Reveal } from "@/components/ui/reveal";
export interface ActivityDay {
date: string;
steps: number;
energy: number;
minutes: number;
distance: number;
progress: [number, number, number];
}
const colors = [
"var(--activity-ring-energy, var(--chart-3))",
"var(--activity-ring-minutes, var(--chart-2))",
"var(--activity-ring-distance, var(--chart-4))",
];
const monthName = (date: Date, format: "long" | "short" = "long") =>
date.toLocaleDateString("en-US", { month: format, timeZone: "UTC" });
const dateKey = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const Rings = React.memo(function Rings({
progress,
size,
}: {
progress: number[];
size: number;
}) {
const mini = size < 40;
const radii = mini ? [12, 8.2, 4.4] : [79, 57, 35];
const center = mini ? 14 : 90;
return (
<svg
width={size}
height={size}
viewBox={mini ? "0 0 28 28" : "0 0 180 180"}
aria-hidden="true"
className="shrink-0"
>
{radii.map((radius, index) => {
const length = 2 * Math.PI * radius;
const value = Math.min(1, Math.max(0, progress[index] ?? 0));
return (
<g key={radius} fill="none" strokeWidth={mini ? 3 : 18}>
<circle
cx={center}
cy={center}
r={radius}
stroke={colors[index]}
opacity={0.16}
/>
<circle
cx={center}
cy={center}
r={radius}
stroke={colors[index]}
strokeLinecap="round"
strokeDasharray={`${length * value} ${length}`}
transform={`rotate(-90 ${center} ${center})`}
className={cn(
// The marker class draws with `backwards` only: a forwards fill
// would pin the animated value and stop its transition from
// tweening changes.
"most-active-days-card-01-ring",
value > 0 ? "opacity-100" : "opacity-0"
)}
/>
</g>
);
})}
</svg>
);
});
const EMPTY_PROGRESS = [0, 0, 0];
type CalendarComponents = NonNullable<
React.ComponentProps<typeof Calendar>["components"]
>;
const MONTH_CLASS_NAMES = {
root: "w-full",
months: "w-full",
month: "w-full",
month_caption: "sr-only",
caption_label: "",
month_grid: "block w-full",
weekdays: "hidden",
weeks: "grid grid-cols-7",
week: "contents",
day: "h-19.5 min-w-0 p-0",
hidden: "hidden",
outside: "hidden",
};
/**
* One month of the calendar. It is memoised and its day button is stable, so
* switching the visible month (which re-renders the card) does not remount
* every day and replay all its rings.
*/
const MonthSection = React.memo(function MonthSection({
month,
selected,
byDate,
onSelect,
monthRefs,
}: {
month: string;
selected: string;
byDate: Map<string, ActivityDay>;
onSelect: (date: string) => void;
monthRefs: React.RefObject<Record<string, HTMLElement | null>>;
}) {
const first = new Date(`${month}-01T00:00:00Z`);
const components = React.useMemo<CalendarComponents>(
() => ({
DayButton: ({ day, modifiers, children, ...props }) => {
const record = byDate.get(dateKey(day.date));
return (
<Button
{...props}
variant="ghost"
size="default"
radius={10}
aria-label={`${day.date.toLocaleDateString("en-US", { dateStyle: "full" })}${record ? `, ${record.steps.toLocaleString("en-US")} steps` : ", no data"}`}
aria-pressed={modifiers.selected}
className="relative flex h-full w-full min-w-0 flex-col gap-2"
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[10px] border-2 border-transparent transition-colors duration-150 group-aria-pressed/button:border-border motion-reduce:transition-none"
/>
{children}
<Rings progress={record?.progress ?? EMPTY_PROGRESS} size={28} />
</Button>
);
},
}),
[byDate]
);
return (
<section
ref={(node) => {
monthRefs.current[month] = node;
}}
className="px-2 pb-2"
>
<h3
aria-hidden="true"
className="sticky top-0 z-10 bg-surface-nested px-2 py-2 text-base font-medium"
>
{monthName(first, "short")}
</h3>
<Calendar
month={new Date(first.getUTCFullYear(), first.getUTCMonth(), 1)}
hideNavigation
showOutsideDays={false}
mode="single"
selected={selected ? new Date(`${selected}T00:00:00`) : undefined}
disabled={(date) => !byDate.has(dateKey(date))}
onSelect={(date) => {
if (date) {
onSelect(dateKey(date));
}
}}
className="w-full"
classNames={MONTH_CLASS_NAMES}
formatters={{ formatCaption: () => monthName(first, "short") }}
components={components}
/>
</section>
);
});
export function MostActiveDaysCard({
data,
initialDate,
className,
}: {
data: ActivityDay[];
initialDate?: string;
className?: string;
}) {
const sorted = React.useMemo(
() => [...data].toSorted((a, b) => a.date.localeCompare(b.date)),
[data]
);
const [selected, setSelected] = React.useState(
initialDate ?? sorted[0]?.date ?? ""
);
const [visibleMonth, setVisibleMonth] = React.useState(
(initialDate ?? sorted[0]?.date ?? "").slice(0, 7)
);
const scrollRef = React.useRef<HTMLDivElement>(null);
const monthRefs = React.useRef<Record<string, HTMLElement | null>>({});
const months = React.useMemo(
() => [...new Set(sorted.map((day) => day.date.slice(0, 7)))],
[sorted]
);
const byDate = React.useMemo(
() => new Map(sorted.map((day) => [day.date, day])),
[sorted]
);
const active = byDate.get(selected);
const monthIndex = months.indexOf(visibleMonth);
const total = sorted
.filter((day) => day.date.startsWith(visibleMonth))
.reduce((sum, day) => sum + day.steps, 0);
const totalRef = useCountUp<HTMLSpanElement>(total);
const spyHold = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Re-armed by every scroll event, so it releases once the scroll goes quiet
// (also covers browsers without `scrollend` and clicks that don't scroll).
const holdSpy = () => {
if (spyHold.current) {
clearTimeout(spyHold.current);
}
spyHold.current = setTimeout(() => {
spyHold.current = null;
}, 150);
};
React.useEffect(
() => () => {
if (spyHold.current) {
clearTimeout(spyHold.current);
}
},
[]
);
const changeMonth = (index: number) => {
const month = months[index];
if (!month) {
return;
}
setVisibleMonth(month);
// The label already shows the target, so ignore the scroll listener until
// the programmatic scroll settles; otherwise it would animate through
// every month the panel passes. The selected day stays put as well, so the
// daily panel only changes when a day is picked.
holdSpy();
const container = scrollRef.current;
const target = monthRefs.current[month];
if (container && target) {
container.scrollTo({
top: target.offsetTop,
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "instant"
: "smooth",
});
}
};
React.useEffect(() => {
if (initialDate) {
changeMonth(months.indexOf(initialDate.slice(0, 7)));
}
// Only position the initial month when the data changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDate, months]);
return (
<div className={cn("grid w-full gap-4 sm:grid-cols-2", className)}>
<Reveal
variant="card"
render={
<Card
radius={20}
variant="muted"
size="xs"
aria-label="Activity calendar"
className="h-82.5 min-w-0"
/>
}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<p className="text-sm leading-5 text-muted-foreground">
Activity calendar
</p>
<PeriodSwitcher>
<PeriodSwitcherPrevious
tooltip="Previous month"
disabled={monthIndex <= 0}
onClick={() => changeMonth(monthIndex - 1)}
/>
<PeriodSwitcherLabel className="min-w-21">
{visibleMonth
? monthName(new Date(`${visibleMonth}-01T00:00:00Z`))
: "—"}
</PeriodSwitcherLabel>
<PeriodSwitcherNext
tooltip="Next month"
disabled={monthIndex === -1 || monthIndex >= months.length - 1}
onClick={() => changeMonth(monthIndex + 1)}
/>
</PeriodSwitcher>
</div>
<p className="text-2xl leading-7.5 font-medium tabular-nums">
<span ref={totalRef}>{total.toLocaleString("en-US")}</span>{" "}
<span className="text-xs font-normal text-muted-foreground">
steps this month
</span>
</p>
</CardHeader>
<CardContent className="min-h-0 flex-1">
<Card
variant="raised"
size="flush"
radius={10}
className="min-h-0 flex-1"
>
<div
ref={scrollRef}
onScroll={(event) => {
if (spyHold.current) {
holdSpy();
return;
}
const { top } = event.currentTarget.getBoundingClientRect();
let [current] = months;
for (const month of months) {
const node = monthRefs.current[month];
if (node && node.getBoundingClientRect().top <= top + 12) {
current = month;
}
}
if (current) {
setVisibleMonth(current);
}
}}
className="relative min-h-0 flex-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{months.map((month) => (
<MonthSection
key={month}
month={month}
selected={selected}
byDate={byDate}
onSelect={setSelected}
monthRefs={monthRefs}
/>
))}
{months.length === 0 && (
<Empty variant="ghost">
<EmptyHeader>
<EmptyDescription>No activity yet.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</Card>
</CardContent>
</Reveal>
<Reveal
variant="card"
render={
<Card
radius={20}
variant="muted"
size="xs"
aria-label="Selected day activity"
className="h-82.5 min-w-0"
/>
}
>
<CardHeader>
<CardDescription>
{active
? `Daily activity · ${new Date(`${active.date}T00:00:00Z`).toLocaleDateString("en-US", { dateStyle: "long", timeZone: "UTC" })}`
: "Select a day"}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-3 gap-2 @container">
{[
["Energy", active?.energy, "kcal"],
["Training", active?.minutes, "min"],
["Distance", active?.distance, "km"],
].map(([label, value, unit], index) => (
<Card
key={String(label)}
variant="raised"
size="xs"
radius={10}
className="min-w-0"
render={<li />}
>
<CardContent className="gap-0">
<div className="flex items-center gap-1.5 text-muted-foreground">
<span
className="size-3 shrink-0 rounded-[4px] bg-(--swatch)"
style={
{ "--swatch": colors[index] } as React.CSSProperties
}
/>
<span className="sr-only @[260px]:not-sr-only">
{label}
</span>
</div>
<p className="text-xs font-medium tabular-nums @[260px]:text-sm">
{typeof value === "number" ? (
<>
{value.toLocaleString("en-US", {
minimumFractionDigits: index === 2 ? 1 : 0,
maximumFractionDigits: index === 2 ? 1 : 0,
})}{" "}
{unit}
</>
) : (
"—"
)}
</p>
</CardContent>
</Card>
))}
</ul>
</CardContent>
<div className="flex flex-1 items-center justify-center">
<Rings progress={active?.progress ?? EMPTY_PROGRESS} size={182} />
</div>
</Reveal>
</div>
);
} On this page