活动统计
New使用量统计组件族:用于关键指标的连体统计条、基于 color-mix 强度色阶与逐列级联淡入的 GitHub 风格贡献热力图,以及纯文本周期切换器。
"use client";
import { useState } from "react";
import {
ActivityHeatmap,
StatsBar,
StatsItem,
StatsPeriodTabs,
} from "@/components/motion/activity-stats";
import { AnimatedNumber } from "@/components/motion/animated-number";
const WEEKS = 52;
const DENSE_WEEKS = 12;
/** Deterministic PRNG (Mulberry32) — same seed always produces the same
* sequence, so the heatmap renders identically on every load and on the
* server. Never swap this for `Math.random`/`Date.now`. */
function mulberry32(seed: number) {
let state = seed;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Simulates a realistic usage curve — sparse in the older weeks, dense over the most recent `DENSE_WEEKS`. */
function generateActivity(seed: number): number[] {
const rand = mulberry32(seed);
const data: number[] = [];
const rampWeeks = WEEKS - DENSE_WEEKS;
for (let week = 0; week < WEEKS; week++) {
const intensity =
week >= rampWeeks
? 0.75 + 0.25 * ((week - rampWeeks) / DENSE_WEEKS)
: 0.12 + 0.45 * (week / rampWeeks);
for (let day = 0; day < 7; day++) {
const skip = rand() > intensity + 0.15;
data.push(skip ? 0 : Math.round(rand() * intensity * 100));
}
}
return data;
}
// One fixed seed per period tab — switching tabs swaps in a visibly
// different (but still deterministic) dataset for the demo.
const DATASETS: Record<string, number[]> = {
daily: generateActivity(7),
weekly: generateActivity(23),
total: generateActivity(91),
};
const MONTH_LABELS = [
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
];
const MONTHS = MONTH_LABELS.map((label, i) => ({
label,
week: Math.round((i * WEEKS) / MONTH_LABELS.length),
}));
const PERIODS = [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "total", label: "Total" },
];
export function ActivityStatsPreview() {
const [period, setPeriod] = useState("daily");
const data = DATASETS[period];
return (
<div className="flex w-full items-center justify-center rounded-xl border border-border bg-neutral-100 px-4 py-10 dark:bg-neutral-900">
<div className="flex w-full max-w-2xl flex-col gap-6">
<StatsBar>
<StatsItem
value={<AnimatedNumber value={3.28} format={(n) => `${n.toFixed(2)}B`} />}
label="tokens"
/>
<StatsItem
value={<AnimatedNumber value={350} format={(n) => `${Math.round(n)}M`} />}
label="peak"
/>
<StatsItem
value={
<AnimatedNumber
value={204}
format={(n) => `${Math.floor(n / 60)}h ${Math.round(n % 60)}m`}
/>
}
label="longest task"
/>
<StatsItem
value={<AnimatedNumber value={3} format={(n) => `${Math.round(n)}`} />}
label="day streak"
/>
<StatsItem
value={<AnimatedNumber value={9} format={(n) => `${Math.round(n)}`} />}
label="day best streak"
/>
</StatsBar>
<div>
<div className="flex items-center justify-between gap-3 pb-3">
<div className="text-sm font-medium">Token activity</div>
<StatsPeriodTabs options={PERIODS} value={period} onChange={setPeriod} />
</div>
<ActivityHeatmap data={data} weeks={WEEKS} months={MONTHS} />
</div>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/activity-stats
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useMemo } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface StatsBarProps {
className?: string;
children?: ReactNode;
}
/** Connected row of `StatsItem`s, hairline-divided between siblings. */
export function StatsBar({ className, children }: StatsBarProps) {
return (
<div
className={cn(
"flex overflow-hidden rounded-[20px] border border-black/10 dark:border-white/[0.06]",
"divide-x-[1px] divide-black/5 dark:divide-white/[0.06]",
className,
)}
>
{children}
</div>
);
}
export interface StatsItemProps {
/** Typically an `AnimatedNumber`/`NumberTicker` node so it counts in on mount. */
value: ReactNode;
label: ReactNode;
className?: string;
}
/** One metric cell inside a `StatsBar` — a value line over a muted label line, both truncating equally. */
export function StatsItem({ value, label, className }: StatsItemProps) {
return (
<div
className={cn(
"flex min-w-px flex-1 flex-col items-center justify-center px-3 py-2.5 text-center",
className,
)}
>
<div className="w-full truncate text-sm leading-5 text-foreground">{value}</div>
<div className="w-full truncate text-sm leading-5 text-muted-foreground">{label}</div>
</div>
);
}
/** Opacity bands for level 1–4, keyed by `value / max` thresholds (<=.25/.5/.75/1). */
const HEAT_ALPHA: Record<1 | 2 | 3 | 4, number> = { 1: 0.25, 2: 0.45, 3: 0.7, 4: 1 };
function heatLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 {
if (value <= 0 || max <= 0) return 0;
const ratio = value / max;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
}
export interface ActivityHeatmapProps {
/**
* Column-major cell values: index `week * 7 + day`, top to bottom then
* left to right. Shorter arrays are zero-padded up to `weeks * 7`.
*/
data: number[];
weeks?: number;
/** Ceiling for the color scale; defaults to the highest value in `data`. */
max?: number;
/** Month-axis labels under the grid, `week` is the column index they sit above. */
months?: { label: string; week: number }[];
/** Native `title` tooltip text; defaults to the raw value. */
cellTitle?: (index: number, value: number) => string;
className?: string;
}
/**
* GitHub-style contribution grid. Colors come from a single `--heat-accent`
* CSS variable (blue, tuned separately for each color scheme) mixed at four
* opacity bands via `color-mix`, so cells never need a per-scheme class list.
* Entrance is a one-time column-cascade fade: each of the (up to) 52 columns
* is its own `motion.div` fading in with a small stagger, while the 7 cells
* inside a column mount statically — animating per-column instead of per-cell
* keeps this to `weeks` motion nodes instead of `weeks * 7`.
* `useReducedMotion()` skips the cascade and renders everything in place.
*/
export function ActivityHeatmap({
data,
weeks = 52,
max,
months,
cellTitle,
className,
}: ActivityHeatmapProps) {
const reduce = useReducedMotion() ?? false;
const total = weeks * 7;
const padded = useMemo(
() => Array.from({ length: total }, (_, i) => data[i] ?? 0),
[data, total],
);
const effectiveMax = useMemo(() => max ?? Math.max(1, ...padded), [max, padded]);
const columns = useMemo(
() => Array.from({ length: weeks }, (_, w) => padded.slice(w * 7, w * 7 + 7)),
[padded, weeks],
);
return (
<div className={cn("[--heat-accent:#339CFF] dark:[--heat-accent:#83C3FF]", className)}>
<div className="flex gap-[3px]">
{columns.map((column, weekIndex) => (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are a fixed-length, position-derived grid — index is a stable identity here.
key={weekIndex}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={
reduce ? undefined : { duration: 0.2, ease: EASE_OUT, delay: weekIndex * 0.006 }
}
className="grid grid-rows-7 gap-[3px]"
>
{column.map((value, day) => {
const index = weekIndex * 7 + day;
const level = heatLevel(value, effectiveMax);
return (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: day is a fixed 0-6 row position within the column.
key={day}
title={cellTitle ? cellTitle(index, value) : `${value}`}
style={
level !== 0
? {
backgroundColor: `color-mix(in srgb, var(--heat-accent) ${HEAT_ALPHA[level] * 100}%, transparent)`,
}
: undefined
}
className={cn(
"block h-[11px] w-[11px] rounded-[4px] transition-transform hover:scale-125",
level === 0 && "bg-black/[0.06] dark:bg-white/[0.043]",
)}
/>
);
})}
</motion.div>
))}
</div>
{months && months.length > 0 ? (
<div className="relative mt-1 h-4 text-xs text-muted-foreground">
{months.map((month) => (
<span
key={`${month.label}-${month.week}`}
className="absolute"
style={{ left: month.week * 14 }}
>
{month.label}
</span>
))}
</div>
) : null}
</div>
);
}
export interface StatsPeriodTabsProps {
options: { value: string; label: ReactNode }[];
value: string;
onChange: (value: string) => void;
className?: string;
}
/** Plain-text period switch (e.g. "Daily / Weekly / Total") — no pill, just color contrast on the active option. */
export function StatsPeriodTabs({ options, value, onChange, className }: StatsPeriodTabsProps) {
return (
<div className={cn("flex items-center gap-3 text-sm", className)}>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className={cn(
"transition-colors",
option.value === value
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/activity-stats
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useMemo } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface StatsBarProps {
className?: string;
children?: ReactNode;
}
/** Connected row of `StatsItem`s, hairline-divided between siblings. */
export function StatsBar({ className, children }: StatsBarProps) {
return (
<div
className={cn(
"flex overflow-hidden rounded-[20px] border border-black/10 dark:border-white/[0.06]",
"divide-x-[1px] divide-black/5 dark:divide-white/[0.06]",
className,
)}
>
{children}
</div>
);
}
export interface StatsItemProps {
/** Typically an `AnimatedNumber`/`NumberTicker` node so it counts in on mount. */
value: ReactNode;
label: ReactNode;
className?: string;
}
/** One metric cell inside a `StatsBar` — a value line over a muted label line, both truncating equally. */
export function StatsItem({ value, label, className }: StatsItemProps) {
return (
<div
className={cn(
"flex min-w-px flex-1 flex-col items-center justify-center px-3 py-2.5 text-center",
className,
)}
>
<div className="w-full truncate text-sm leading-5 text-foreground">{value}</div>
<div className="w-full truncate text-sm leading-5 text-muted-foreground">{label}</div>
</div>
);
}
/** Opacity bands for level 1–4, keyed by `value / max` thresholds (<=.25/.5/.75/1). */
const HEAT_ALPHA: Record<1 | 2 | 3 | 4, number> = { 1: 0.25, 2: 0.45, 3: 0.7, 4: 1 };
function heatLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 {
if (value <= 0 || max <= 0) return 0;
const ratio = value / max;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
}
export interface ActivityHeatmapProps {
/**
* Column-major cell values: index `week * 7 + day`, top to bottom then
* left to right. Shorter arrays are zero-padded up to `weeks * 7`.
*/
data: number[];
weeks?: number;
/** Ceiling for the color scale; defaults to the highest value in `data`. */
max?: number;
/** Month-axis labels under the grid, `week` is the column index they sit above. */
months?: { label: string; week: number }[];
/** Native `title` tooltip text; defaults to the raw value. */
cellTitle?: (index: number, value: number) => string;
className?: string;
}
/**
* GitHub-style contribution grid. Colors come from a single `--heat-accent`
* CSS variable (blue, tuned separately for each color scheme) mixed at four
* opacity bands via `color-mix`, so cells never need a per-scheme class list.
* Entrance is a one-time column-cascade fade: each of the (up to) 52 columns
* is its own `motion.div` fading in with a small stagger, while the 7 cells
* inside a column mount statically — animating per-column instead of per-cell
* keeps this to `weeks` motion nodes instead of `weeks * 7`.
* `useReducedMotion()` skips the cascade and renders everything in place.
*/
export function ActivityHeatmap({
data,
weeks = 52,
max,
months,
cellTitle,
className,
}: ActivityHeatmapProps) {
const reduce = useReducedMotion() ?? false;
const total = weeks * 7;
const padded = useMemo(
() => Array.from({ length: total }, (_, i) => data[i] ?? 0),
[data, total],
);
const effectiveMax = useMemo(() => max ?? Math.max(1, ...padded), [max, padded]);
const columns = useMemo(
() => Array.from({ length: weeks }, (_, w) => padded.slice(w * 7, w * 7 + 7)),
[padded, weeks],
);
return (
<div className={cn("[--heat-accent:#339CFF] dark:[--heat-accent:#83C3FF]", className)}>
<div className="flex gap-[3px]">
{columns.map((column, weekIndex) => (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: columns are a fixed-length, position-derived grid — index is a stable identity here.
key={weekIndex}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={
reduce ? undefined : { duration: 0.2, ease: EASE_OUT, delay: weekIndex * 0.006 }
}
className="grid grid-rows-7 gap-[3px]"
>
{column.map((value, day) => {
const index = weekIndex * 7 + day;
const level = heatLevel(value, effectiveMax);
return (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: day is a fixed 0-6 row position within the column.
key={day}
title={cellTitle ? cellTitle(index, value) : `${value}`}
style={
level !== 0
? {
backgroundColor: `color-mix(in srgb, var(--heat-accent) ${HEAT_ALPHA[level] * 100}%, transparent)`,
}
: undefined
}
className={cn(
"block h-[11px] w-[11px] rounded-[4px] transition-transform hover:scale-125",
level === 0 && "bg-black/[0.06] dark:bg-white/[0.043]",
)}
/>
);
})}
</motion.div>
))}
</div>
{months && months.length > 0 ? (
<div className="relative mt-1 h-4 text-xs text-muted-foreground">
{months.map((month) => (
<span
key={`${month.label}-${month.week}`}
className="absolute"
style={{ left: month.week * 14 }}
>
{month.label}
</span>
))}
</div>
) : null}
</div>
);
}
export interface StatsPeriodTabsProps {
options: { value: string; label: ReactNode }[];
value: string;
onChange: (value: string) => void;
className?: string;
}
/** Plain-text period switch (e.g. "Daily / Weekly / Total") — no pill, just color contrast on the active option. */
export function StatsPeriodTabs({ options, value, onChange, className }: StatsPeriodTabsProps) {
return (
<div className={cn("flex items-center gap-3 text-sm", className)}>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
className={cn(
"transition-colors",
option.value === value
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
);
}
"use client";
import { animate, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AnimatedNumberProps {
value: number;
duration?: number;
format?: (n: number) => string;
className?: string;
startOnView?: boolean;
}
export function AnimatedNumber({
value,
duration = 1.2,
format = (n) => Math.round(n).toLocaleString(),
className,
startOnView = true,
}: AnimatedNumberProps) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
if (startOnView && !inView) return;
if (reduce) {
fromRef.current = value;
setDisplay(value);
return;
}
const controls = animate(fromRef.current, value, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setDisplay(v),
});
fromRef.current = value;
return () => controls.stop();
}, [value, duration, inView, startOnView, reduce]);
return (
<span ref={ref} className={cn("tabular-nums", className)}>
{format(display)}
</span>
);
}
API 参考
StatsBar
className?string—StatsItem
valueReactNodeTypically an `AnimatedNumber`/`NumberTicker` node so it counts in on mount.
—labelReactNode—className?string—ActivityHeatmap
datanumber[]Column-major cell values: index `week * 7 + day`, top to bottom then left to right. Shorter arrays are zero-padded up to `weeks * 7`.
—weeks?number52max?numberCeiling for the color scale; defaults to the highest value in `data`.
—months?{ label: string; week: number; }[]Month-axis labels under the grid, `week` is the column index they sit above.
—cellTitle?((index: number, value: number) => string)Native `title` tooltip text; defaults to the raw value.
—className?string—StatsPeriodTabs
options{ value: string; label: ReactNode; }[]—valuestring—onChange(value: string) => void—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.