Wallet Activity HeatmapConnect WalletDonation CardNFT CardSolana Business CardAnimated Tip JarComing soonNFT ShelfComing soonSolana Minimal ReceiptComing soonTransaction StoryboardComing soon
Wallet Activity Heatmap
A simple grid-based heatmap to visualize wallet activity over time.
Install dependencies
Choose your package manager
npm i dayjs framer-motion
Notes
Helpful context and caveats
- Set the NEXT_PUBLIC_HELIUS_API_KEY environment variable to your Helius API key. You can get one by signing up at https://helius.dev
Implementation
Copy-paste the code
"use client";
import { useEffect, useState, useMemo } from "react";
import dayjs from "dayjs";
import { motion } from "framer-motion";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
const HELIUS_KEY = process.env.NEXT_PUBLIC_HELIUS_API_KEY!;
function LoadingSkeleton() {
const fakeWeeks = Array.from({ length: 53 }).map(() =>
Array.from({ length: 7 })
);
return (
<div className="overflow-x-auto pb-2">
<div className="inline-flex gap-[3px]">
{fakeWeeks.map((week, col) => (
<div key={col} className="flex flex-col gap-[3px]">
{week.map((_, row) => (
<motion.div
key={row}
initial={{ opacity: 0.4 }}
animate={{ opacity: 1 }}
transition={{
repeat: Infinity,
repeatType: "reverse",
duration: 1.2,
delay: col * 0.05,
}}
className="w-3 h-3 rounded-xs dark:bg-neutral-800 bg-neutral-200"
/>
))}
</div>
))}
</div>
</div>
);
}
type DayActivity = {
date: string;
count: number;
txs: string[];
};
export function WalletActivityHeatmap({ address }: { address: string }) {
const [activity, setActivity] = useState<DayActivity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if(!address) return;
async function fetchYearActivity() {
setLoading(true);
const startOfYear = dayjs().startOf("year").unix();
const activityMap: Record<string, { count: number; txs: string[] }> = {};
let before: string | undefined = undefined;
let stop = false;
while(!stop) {
const url: string = `https://api.helius.xyz/v0/addresses/${address}/transactions?api-key=${HELIUS_KEY}&${before ? `&before=${before}` : ""}`;
const res: Response = await fetch(url);
const data: any = await res.json();
if(!Array.isArray(data) || data.length === 0) break;
for(const tx of data) {
if(tx.timestamp < startOfYear) {
stop = true;
break;
}
const date = dayjs.unix(tx.timestamp).format("YYYY-MM-DD");
if(!activityMap[date]) activityMap[date] = { count: 0, txs: [] };
activityMap[date].count++;
activityMap[date].txs.push(tx.signature);
}
if(stop) break;
before = data[data.length - 1]?.signature;
if(!before) break;
}
const yearDays: DayActivity[] = [];
const start = dayjs().startOf("year");
const end = dayjs().endOf("year");
let cursor = start;
while(cursor.isBefore(end) || cursor.isSame(end)) {
const dateStr = cursor.format("YYYY-MM-DD");
yearDays.push({
date: dateStr,
count: activityMap[dateStr]?.count || 0,
txs: activityMap[dateStr]?.txs || [],
});
cursor = cursor.add(1, "day");
}
setActivity(yearDays);
setLoading(false);
}
fetchYearActivity();
}, [address]);
const weeks = useMemo(() => {
if(activity.length === 0) return [];
const weeks: (DayActivity | null)[][] = [];
let currentWeek: (DayActivity | null)[] = [];
const jan1Day = dayjs().startOf("year").day();
for(let i = 0; i < jan1Day; i++) currentWeek.push(null);
activity.forEach((day) => {
currentWeek.push(day);
if(currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
});
if(currentWeek.length > 0) {
while(currentWeek.length < 7) currentWeek.push(null);
weeks.push(currentWeek);
}
return weeks;
}, [activity]);
const getColor = (count: number) => {
if(count === 0) return "dark:bg-neutral-800 bg-neutral-200";
if(count < 3) return "bg-purple-700";
if(count < 6) return "bg-purple-600";
return "bg-purple-400";
};
const tileVariants = {
hidden: { opacity: 0, scale: 0.4 },
visible: (col: number) => ({
opacity: 1,
scale: 1,
transition: { duration: 0.25, delay: col * 0.05 },
}),
};
return (
<TooltipProvider>
{loading ? (
<LoadingSkeleton />
) : (
<div className="overflow-x-auto pb-2">
<div className="inline-flex gap-[3px]">
{weeks.map((week, col) => (
<div key={col} className="flex flex-col gap-[3px]">
{week.map((day, row) =>
day ? (
<Tooltip key={row}>
<TooltipTrigger asChild>
<motion.div
custom={col}
variants={tileVariants}
initial="hidden"
animate="visible"
className={`w-3 h-3 rounded-xs ${getColor(day.count)} cursor-pointer`}
/>
</TooltipTrigger>
<TooltipContent className="text-xs p-2 max-w-2xl">
<p className="font-medium mb-1">{day.date}</p>
<p className="opacity-75 mb-2">{day.count} transactions</p>
<ScrollArea className="max-h-72 p-1 flex flex-col gap-1 overflow-auto">
{day.txs.map((sig, idx) => (
<div key={idx} className="flex items-center justify-between rounded px-1 py-0.5">
<p className="truncate text-[10px] max-w-[140px]">{sig}</p>
<a href={`https://solscan.io/tx/${sig}`} target="_blank" rel="noopener noreferrer">
<Button variant="secondary" size="sm" className="h-5 px-2 text-[10px]">
View
</Button>
</a>
</div>
))}
</ScrollArea>
</TooltipContent>
</Tooltip>
) : (
<div key={row} className="w-3 h-3 rounded-xs dark:bg-neutral-900 bg-neutral-300 opacity-30" />
)
)}
</div>
))}
</div>
</div>
)}
</TooltipProvider>
);
}