liten

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.

Terminal
bun add motion

Copy the component into components/ui/trellis.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
dataTrellisDay[]-Flat list of { date, count } days, oldest to newest. Generates a deterministic year of sample data when omitted.
weeksnumber53Number of weeks (columns) to show when generating sample data.
accentstring"#3ecf8e"Accent that drives the filled-cell scale and focus outline.
titlestring"contributions in the last year"Header text, prefixed automatically with the count.
classNamestring-Forwarded to the root.
On this page0%