Blocks/Cartes
Activity
Une heatmap annuelle premium avec tabs dynamiques, statistiques et légende.
BetaWhoa UI est en bêta : certains éléments peuvent encore changer. Un bug ? Écrivez-moi à hello@ugolin-olle.com.
Activity
Jul 2025 – Jul 2026240,026 words in the last year
Mon
Wed
Fri
Dec
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
Nov
Active259d
Longest14d
Current4d
Less
More
../../apps/web/examples/activity.tsx
'use client';import { Activity } from '@/components/blocks/activity';import type { ActivityDataPoint } from '@/components/blocks/activity/types';function mulberry32(seed: number) { return function random() { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}// Local "YYYY-MM-DD" formatting — `toISOString()` converts to UTC first, which can shift the day.function toDateKey(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`;}function generateData(seed: number, days: number, maxValue: number): ActivityDataPoint[] { const random = mulberry32(seed); const today = new Date(); today.setHours(0, 0, 0, 0); const data: ActivityDataPoint[] = []; for (let i = days - 1; i >= 0; i -= 1) { const date = new Date(today); date.setDate(date.getDate() - i); const isActive = random() > 0.32; const value = isActive ? Math.round(random() * maxValue) + 1 : 0; const level = value === 0 ? 0 : Math.min(4, Math.floor((value / maxValue) * 4) + 1); data.push({ date: toDateKey(date), level, value }); } return data;}const commits = generateData(1, 371, 12);const focus = generateData(2, 371, 6);const writing = generateData(3, 371, 1800);const exercise = generateData(4, 371, 2);export default function Demo() { return ( <Activity title="Activity" period="Jul 2025 – Jul 2026" defaultTab="writing" tabs={[ { id: 'commits', label: 'Commits', color: '#16a34a', unit: 'commits', data: commits }, { id: 'focus', label: 'Focus', color: '#7c3aed', unit: 'focus sessions', data: focus }, { id: 'writing', label: 'Writing', color: '#f59e0b', unit: 'words', data: writing }, { id: 'exercise', label: 'Exercise', color: '#e11d48', unit: 'workouts', data: exercise }, ]} /> );}Installation#
Via shadcn — copie le code source
$pnpm dlx shadcn@latest add https://ugolin-olle.com/r/activity.jsonInstallation manuelle
'use client';import { AnimatePresence, motion, type Variants } from 'motion/react';import * as React from 'react';import { cn } from '@/lib/utils';import { ActivityHeader } from './activity-header';import { ActivityHeatmap } from './activity-heatmap';import { ActivityLegend } from './activity-legend';import { ActivityStats } from './activity-stats';import { ActivityTabs } from './activity-tabs';import type { ActivityTab } from './types';import { computeActivityStreaks, sumActivityValues } from './utils';export interface ActivityProps { title?: string; period?: string; tabs: ActivityTab[]; /** Uncontrolled initial tab. Defaults to the first tab. */ defaultTab?: string; /** Controlled active tab id. */ activeTab?: string; onTabChange?: (id: string) => void; onRefresh?: () => void; /** Full override for the refresh button. */ refreshButton?: React.ReactNode; /** Number of intensity levels, including the empty level 0. */ levels?: number; cellSize?: number; cellGap?: number; cellRadius?: number; weeks?: number; dayLabels?: (string | undefined)[]; showMonthLabels?: boolean; legendLabels?: { less?: string; more?: string }; /** Disable the tab-switch motion animations. */ animated?: boolean; className?: string;}const contentVariants: Variants = { enter: (direction: number) => ({ x: direction >= 0 ? 32 : -32, opacity: 0, scale: 0.98, filter: 'blur(8px)', }), center: { x: 0, opacity: 1, scale: 1, filter: 'blur(0px)', }, exit: (direction: number) => ({ x: direction >= 0 ? -32 : 32, opacity: 0, scale: 0.98, filter: 'blur(8px)', }),};export function Activity({ title = 'Activity', period, tabs, defaultTab, activeTab: controlledTab, onTabChange, onRefresh, refreshButton, levels = 5, cellSize = 11, cellGap = 3, cellRadius = 3, weeks = 48, dayLabels, showMonthLabels = true, legendLabels, animated = true, className,}: ActivityProps) { const [internalTab, setInternalTab] = React.useState(defaultTab ?? tabs[0]?.id); const activeId = controlledTab ?? internalTab ?? tabs[0]?.id; const activeTabData = tabs.find((tab) => tab.id === activeId) ?? tabs[0]; // Direction is derived from each tab's position in `tabs`, not from clicks: this keeps it // correct whether the active tab changes via a click or via the controlled `activeTab` prop. // Updating state during render (React's documented pattern for "adjusting state when a prop // changes") avoids the one-frame flash a `useEffect` would introduce. const [prevTabId, setPrevTabId] = React.useState(activeTabData?.id); const [direction, setDirection] = React.useState(0); if (activeTabData && activeTabData.id !== prevTabId) { const oldIndex = tabs.findIndex((tab) => tab.id === prevTabId); const newIndex = tabs.findIndex((tab) => tab.id === activeTabData.id); setDirection(newIndex > oldIndex ? 1 : newIndex < oldIndex ? -1 : 0); setPrevTabId(activeTabData.id); } const handleChange = (id: string) => { setInternalTab(id); onTabChange?.(id); }; if (!activeTabData) return null; const streaks = computeActivityStreaks(activeTabData.data); const total = sumActivityValues(activeTabData.data); const active = activeTabData.stats?.active ?? { label: 'Active', value: `${streaks.active}d` }; const longest = activeTabData.stats?.longest ?? { label: 'Longest', value: `${streaks.longest}d`, }; const current = activeTabData.stats?.current ?? { label: 'Current', value: `${streaks.current}d`, }; return ( <div className={cn('w-full max-w-4xl rounded-3xl border border-border/60 bg-muted p-3', className)} > <div className="space-y-3 px-1.5 pt-1"> <ActivityHeader title={title} period={period} onRefresh={onRefresh} refreshButton={refreshButton} /> <ActivityTabs tabs={tabs} activeTab={activeTabData.id} onChange={handleChange} /> </div> <div className="relative mt-3 overflow-hidden rounded-2xl border border-border/40 bg-card p-4 shadow-sm"> <div className="grid"> <AnimatePresence custom={direction} initial={false}> <motion.div key={activeTabData.id} custom={direction} variants={animated ? contentVariants : undefined} initial={animated ? 'enter' : false} animate={animated ? 'center' : undefined} exit={animated ? 'exit' : undefined} transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }} style={{ gridArea: '1 / 1' }} > <p className="text-sm text-muted-foreground"> {activeTabData.summary ?? ( <> <span className="font-semibold text-foreground">{total.toLocaleString()}</span>{' '} {activeTabData.unit ?? 'entries'} in the last year </> )} </p> <div className="mt-4"> <ActivityHeatmap data={activeTabData.data} color={activeTabData.color} categoryLabel={activeTabData.label} unit={activeTabData.unit} levels={levels} cellSize={cellSize} cellGap={cellGap} cellRadius={cellRadius} weeks={weeks} dayLabels={dayLabels} showMonthLabels={showMonthLabels} /> </div> <div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-dashed border-border/60 pt-3"> <ActivityStats color={activeTabData.color} active={active} longest={longest} current={current} /> <ActivityLegend color={activeTabData.color} levels={levels} lessLabel={legendLabels?.less} moreLabel={legendLabels?.more} cellSize={cellSize - 1} cellRadius={cellRadius} /> </div> </motion.div> </AnimatePresence> </div> </div> </div> );}export type { ActivityDataPoint, ActivityStat, ActivityTab, ActivityTabStats } from './types';Une card élégante affichant une activité annuelle sous forme de heatmap façon GitHub, avec des
onglets de catégories entièrement personnalisables, des transitions animées via motion/react, et
des statistiques calculées automatiquement (jours actifs, plus longue série, série en cours).
Inspired by Jeet