Trellis
A GitHub-style contribution heatmap in a recessed well, with a tooltip that glides between cells.
0 contributions in the last year
MonWedFri
JulAugSepOctNovDecJanFebMarAprMayJunJul
53 weeks of activity
LessMore
Sample data is generated with a deterministic hash keyed off the date, so the render is identical on server and client with no hydration drift. Cells bloom in on a diagonal wave from the top-left, the total count counts up on first paint, and hovering a cell glides one shared tooltip to its position instead of re-popping a new one each time. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/trellis.tsx.
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;export type TrellisDay = { date: string; count: number;};export type TrellisProps = { data?: TrellisDay[]; weeks?: number; accent?: string; title?: string; className?: string;};const WEEKDAYS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];const MONTHS = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',];const LEVEL_MIX = [0, 28, 50, 74, 100];function levelFor(count: number): number { if (count <= 0) return 0; if (count <= 2) return 1; if (count <= 5) return 2; if (count <= 9) return 3; return 4;}function seeded(n: number): number { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x);}function generate(weeks: number): TrellisDay[] { const days: TrellisDay[] = []; const today = new Date(); today.setHours(0, 0, 0, 0); const end = new Date(today); end.setDate(end.getDate() - end.getDay() + 6); const total = weeks * 7; for (let i = total - 1; i >= 0; i--) { const d = new Date(end); d.setDate(end.getDate() - i); const r = seeded(d.getTime() / 8.64e7); const weekend = d.getDay() === 0 || d.getDay() === 6; let count = 0; if (r > 0.55) count = Math.round((r - 0.55) * (weekend ? 12 : 26)); days.push({ date: d.toISOString().slice(0, 10), count }); } return days;}function formatDate(iso: string): string { const [y, m, day] = iso.split('-').map(Number); return `${MONTHS[m - 1]} ${day}, ${y}`;}type Hover = { x: number; y: number; below: boolean; count: number; date: string;} | null;export function Trellis({ data, weeks = 53, accent = '#3ecf8e', title = 'contributions in the last year', className,}: TrellisProps) { const reduce = useReducedMotion(); const gridRef = React.useRef<HTMLDivElement>(null); const [hover, setHover] = React.useState<Hover>(null); const days = React.useMemo(() => data ?? generate(weeks), [data, weeks]); const total = React.useMemo( () => days.reduce((sum, d) => sum + d.count, 0), [days], ); const [shownTotal, setShownTotal] = React.useState(0); React.useEffect(() => { if (reduce) { setShownTotal(total); return; } let raf = 0; const duration = 900; let startTs = 0; const tick = (ts: number) => { if (!startTs) startTs = ts; const p = Math.min((ts - startTs) / duration, 1); const eased = 1 - Math.pow(1 - p, 3); setShownTotal(Math.round(total * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [total, reduce]); const columns = React.useMemo(() => { const out: TrellisDay[][] = []; for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7)); return out; }, [days]); const monthLabels = React.useMemo(() => { const labels: { col: number; label: string }[] = []; let last = -1; columns.forEach((week, col) => { const starter = week.find((d) => { const dom = Number(d.date.slice(8, 10)); return dom >= 1 && dom <= 7; }); if (!starter) return; const month = Number(starter.date.slice(5, 7)) - 1; if (month !== last) { labels.push({ col, label: MONTHS[month] }); last = month; } }); return labels; }, [columns]); const showTip = (e: React.MouseEvent<HTMLElement>, day: TrellisDay) => { const host = gridRef.current; if (!host) return; const cell = e.currentTarget.getBoundingClientRect(); const box = host.getBoundingClientRect(); const topInBox = cell.top - box.top; const below = topInBox < 40; setHover({ x: cell.left - box.left + cell.width / 2, y: below ? cell.bottom - box.top : topInBox, below, count: day.count, date: day.date, }); }; return ( <div style={{ '--trellis-accent': accent } as React.CSSProperties} className={cn( 'bloom-edge inline-block rounded-[14px] p-2', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]', className, )} > <div className="flex items-baseline justify-between gap-4 px-1.5 pb-2 pt-1"> <h2 className="text-[13px] font-medium tracking-[-0.01em]"> <span className="text-neutral-900 tabular-nums dark:text-white">{shownTotal.toLocaleString()}</span>{' '} <span className="text-neutral-500 dark:text-[#929292]">{title}</span> </h2> </div> <div ref={gridRef} className={cn( 'bloom-edge relative rounded-[11px] p-2.5', 'bg-black/[0.02] dark:bg-black/20', 'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]', )} onMouseLeave={() => setHover(null)} > <div className="depth-scroll overflow-x-auto py-1"> <div className="flex gap-2"> <div className="grid shrink-0 grid-rows-[repeat(7,11px)] gap-[3px] pt-[18px]"> {WEEKDAYS.map((d, i) => ( <span key={i} className="h-[11px] text-right text-[9px] leading-[11px] text-neutral-400 dark:text-[#8b8b8b]" > {d} </span> ))} </div> <div className="relative"> <div className="relative mb-1 h-[12px]"> {monthLabels.map(({ col, label }) => ( <span key={`${col}-${label}`} className="absolute top-0 text-[9px] leading-[12px] text-neutral-400 dark:text-[#8b8b8b]" style={{ left: col * 14 }} > {label} </span> ))} </div> <div className="flex gap-[3px]"> {columns.map((week, col) => ( <div key={col} className="grid grid-rows-[repeat(7,11px)] gap-[3px]"> {week.map((day, row) => { const level = levelFor(day.count); const delay = reduce ? 0 : Math.min((col + row) * 0.008, 0.6); return ( <motion.div key={day.date} role="img" aria-label={`${day.count} contributions on ${formatDate(day.date)}`} initial={ reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 } } animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.32, ease: EASE_OUT, delay }} whileHover={reduce ? undefined : { scale: 1.18 }} whileTap={reduce ? undefined : { scale: 0.9 }} onMouseEnter={(e) => showTip(e, day)} className={cn( 'relative z-0 size-[11px] rounded-[2px] transition-shadow duration-150 ease-out hover:z-10', level === 0 ? 'bg-black/[0.05] hover:shadow-[0_0_0_1.5px_var(--trellis-accent)] dark:bg-white/[0.04]' : 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12)] hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),0_0_0_1.5px_var(--trellis-accent)]', )} style={ level > 0 ? { backgroundColor: `color-mix(in srgb, var(--trellis-accent) ${LEVEL_MIX[level]}%, transparent)`, } : undefined } /> ); })} </div> ))} </div> </div> </div> </div> <AnimatePresence> {hover && ( <motion.div key="tip" className="pointer-events-none absolute left-0 top-0 z-50" style={{ x: '-50%', y: hover.below ? '0px' : '-100%' }} initial={false} animate={{ left: hover.x, top: hover.below ? hover.y + 8 : hover.y - 8 }} transition={{ duration: 0.18, ease: EASE_IN_OUT }} > <motion.div initial={{ opacity: 0, y: hover.below ? -4 : 4, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.1, ease: EASE_OUT } }} transition={{ duration: 0.16, ease: EASE_OUT }} style={{ transformOrigin: hover.below ? 'top center' : 'bottom center' }} className={cn( 'bloom-edge whitespace-nowrap rounded-lg px-2 py-1 text-[11px] font-medium tracking-[-0.01em]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#272727] dark:to-[#191919] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_6px_16px_-6px_rgba(0,0,0,0.2)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_6px_16px_-10px_rgba(0,0,0,0.45)]', )} > <span className="text-neutral-900 dark:text-white"> {hover.count === 0 ? 'No contributions' : `${hover.count} contribution${hover.count === 1 ? '' : 's'}`} </span> <span className="text-neutral-500 dark:text-[#929292]"> · {formatDate(hover.date)}</span> </motion.div> </motion.div> )} </AnimatePresence> </div> <div className="flex items-center justify-between gap-4 px-1.5 pb-1 pt-2"> <span className="text-[11px] text-neutral-400 dark:text-[#8b8b8b]"> {columns.length} weeks of activity </span> <div className="flex items-center gap-1.5"> <span className="text-[11px] text-neutral-400 dark:text-[#8b8b8b]">Less</span> {[0, 1, 2, 3, 4].map((level) => ( <motion.span key={level} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.5 + level * 0.05 }} whileHover={reduce ? undefined : { scale: 1.25 }} className={cn( 'size-[11px] rounded-[2px]', level === 0 && 'bg-black/[0.05] dark:bg-white/[0.04]', level > 0 && 'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12)]', )} style={ level > 0 ? { backgroundColor: `color-mix(in srgb, var(--trellis-accent) ${LEVEL_MIX[level]}%, transparent)`, } : undefined } /> ))} <span className="text-[11px] text-neutral-400 dark:text-[#8b8b8b]">More</span> </div> </div> </div> );}Usage
import { Trellis } from '@/components/ui/trellis';
export default function Example() {
return <Trellis />;
}Examples
A shorter cyan-accented window with a custom title.
0 contributions this season
MonWedFri
DecJanFebMarAprMayJunJul
30 weeks of activity
LessMore
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | TrellisDay[] | - | Flat list of { date, count } days, oldest to newest. Generates a deterministic year of sample data when omitted. |
weeks | number | 53 | Number of weeks (columns) to show when generating sample data. |
accent | string | "#3ecf8e" | Accent that drives the filled-cell scale and focus outline. |
title | string | "contributions in the last year" | Header text, prefixed automatically with the count. |
className | string | - | Forwarded to the root. |