<script setup lang="ts">
import { ref, computed } from 'vue'
import {
Heatmap,
HeatmapHeader,
HeatmapContent,
HeatmapGrid,
HeatmapCell,
HeatmapFooter,
HeatmapLegend,
HeatmapMain,
HeatmapRow,
HeatmapMonths,
HeatmapWeekdays,
type HeatmapCellProp,
type HeatmapPalette,
} from '~/components/ui/contribution-heatmap'
import {
Select,
SelectContent,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '~/components/ui/select'
const palettes: Record<string, HeatmapPalette | undefined> = {
default: undefined,
Indigo: [
'bg-muted',
'bg-indigo-100 dark:bg-indigo-950',
'bg-indigo-300 dark:bg-indigo-800',
'bg-indigo-500 dark:bg-indigo-600',
'bg-indigo-700 dark:bg-indigo-400',
],
Rose: [
'bg-muted',
'bg-rose-100 dark:bg-rose-950',
'bg-rose-300 dark:bg-rose-800',
'bg-rose-500 dark:bg-rose-600',
'bg-rose-700 dark:bg-rose-400',
],
Amber: [
'bg-muted',
'bg-amber-100 dark:bg-amber-950',
'bg-amber-300 dark:bg-amber-800',
'bg-amber-500 dark:bg-amber-600',
'bg-amber-700 dark:bg-amber-400',
],
Slate: [
'bg-muted',
'bg-slate-200 dark:bg-slate-800',
'bg-slate-400 dark:bg-slate-600',
'bg-slate-600 dark:bg-slate-400',
'bg-slate-800 dark:bg-slate-200',
],
}
const selectedPaletteName = ref<keyof typeof palettes>('default')
const activePalette = computed(() => palettes[selectedPaletteName.value])
const mockData = ref<Record<string, number>>({})
const today = new Date()
const startDate = new Date(today)
startDate.setFullYear(today.getFullYear() - 1)
// Deterministic pseudo-random function to ensure identical data on server and client in nuxt.
const pseudoRandom = (seed: number) => {
const x = Math.sin(seed) * 100000
return x - Math.floor(x)
}
const newMockData: Record<string, number> = {}
for (let i = 0; i < 365; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().split('T')[0] as string
if (pseudoRandom(i) > 0.4) {
newMockData[key] = Math.floor(pseudoRandom(i + 1) * 10)
}
}
mockData.value = newMockData
const onCellClick = (cell: HeatmapCellProp) => {
alert(`${cell.dateLabel}: ${cell.contributions} contributions`)
}
</script>
<template>
<div class="flex w-full flex-wrap items-start gap-2">
<div class="me-2 text-sm">
<SelectLabel>Palette {{ selectedPaletteName }}</SelectLabel>
<Select v-model="selectedPaletteName">
<SelectTrigger>
<SelectValue placeholder="Select a palette" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="name in Object.keys(palettes)" :key="name" :value="name">
{{ name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Heatmap :data="mockData" :start-date="startDate" :end-date="today" :palette="activePalette">
<HeatmapHeader v-slot="{ totalContributions }">
<span class="text-xs font-semibold sm:text-sm">Contribution Activity</span>
<span class="text-[10px] text-muted-foreground sm:text-xs"
>{{ totalContributions?.toLocaleString() }} contributions in the last year</span
>
</HeatmapHeader>
<HeatmapContent>
<HeatmapMain>
<HeatmapMonths />
<HeatmapWeekdays />
<HeatmapGrid v-slot="{ cellGrid }">
<HeatmapRow v-for="(row, rowIdx) in cellGrid" :key="rowIdx">
<HeatmapCell
v-for="cell in row"
:key="`${cell.key}-${cell.contributions}`"
:cell="cell"
@click="onCellClick(cell)"
>
<template #tooltip="{ cell: targetCell }">
<div class="flex flex-col gap-1 px-1 py-0.5 text-left">
<span class="font-medium text-xs">
{{ targetCell.contributions }}
{{ targetCell.contributions === 1 ? 'contribution' : 'contributions' }}
</span>
<span class="text-[10px] text-muted-foreground">
{{ targetCell.dateLabel }}
</span>
</div>
</template>
</HeatmapCell>
</HeatmapRow>
</HeatmapGrid>
</HeatmapMain>
</HeatmapContent>
<HeatmapFooter>
<HeatmapLegend label="Learn how we count contributions" />
</HeatmapFooter>
</Heatmap>
</div>
</template>The ContributionHeatmap component provides a powerful and flexible way to visualize temporal data density, similar to the GitHub contribution graph. It supports local data providers, automatic GitHub activity fetching, keyboard navigation, and highly customizable sub-components.
Installation
pnpm dlx sulaf@latest add contribution-heatmap
Usage
Basic Usage with Local Data
Provide a data object where keys are ISO date strings and values are numbers.
<script setup lang="ts">
import {
Heatmap, HeatmapHeader, HeatmapContent, HeatmapMain,
HeatmapMonths, HeatmapWeekdays, HeatmapGrid, HeatmapCell,
HeatmapRow, HeatmapFooter, HeatmapLegend
} from '@/components/ui/contribution-heatmap'
const myData = { '2024-01-01': 10, '2024-01-02': 5 }
</script>
<template>
<Heatmap :data="myData">
<HeatmapHeader v-slot="{ totalContributions }">
<h3 class="text-sm font-semibold">Activity: {{ totalContributions }}</h3>
</HeatmapHeader>
<HeatmapContent>
<HeatmapMain>
<HeatmapMonths />
<HeatmapWeekdays class="row-start-2" />
<HeatmapGrid class="row-start-2" v-slot="{ cellGrid }">
<HeatmapRow v-for="(row, rowIdx) in cellGrid" :key="rowIdx">
<HeatmapCell v-for="cell in row" :key="cell.key" :cell="cell" />
</HeatmapRow>
</HeatmapGrid>
</HeatmapMain>
</HeatmapContent>
<HeatmapFooter>
<HeatmapLegend />
</HeatmapFooter>
</Heatmap>
</template>Examples
GitHub Integration
Simply pass a githubUsername to fetch real data. You can also access the user's profile information via the root component's scoped slot.
Binary Activity
For scenarios where you only want to track "Active" vs "Inactive" (e.g., daily check-ins), you can simplify the level logic and the legend.
<script setup lang="ts">
import { ref } from 'vue'
import {
Heatmap,
HeatmapHeader,
HeatmapContent,
HeatmapGrid,
HeatmapCell,
HeatmapFooter,
HeatmapLegend,
HeatmapMain,
HeatmapMonths,
HeatmapWeekdays,
} from '~/components/ui/contribution-heatmap'
const mockData = ref<Record<string, number>>({})
const today = new Date()
const startDate = new Date(today)
startDate.setFullYear(today.getFullYear() - 1)
// Generate mock data (0 or 1)
for (let i = 0; i < 365; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().split('T')[0]
if (Math.random() > 0.7) {
mockData.value[key!] = 1
}
}
// Binary: Level 0 for 0, Level 4 for anything else
const binaryGetLevel = (count: number) => (count > 0 ? 4 : 0)
const binaryGetContributionsForLevel = (level: number) => (level === 0 ? 0 : 1)
</script>
<template>
<Heatmap
:data="mockData"
:start-date="startDate"
:end-date="today"
:get-level="binaryGetLevel"
:get-contributions-for-level="binaryGetContributionsForLevel"
:palette="['bg-muted', 'bg-primary/20', 'bg-primary/40', 'bg-primary/60', 'bg-primary']"
:max-level="4"
>
<HeatmapHeader>
<div class="flex flex-col">
<span class="text-sm font-semibold">Binary Activity</span>
<span class="text-xs text-muted-foreground">Showing simple active/inactive states</span>
</div>
</HeatmapHeader>
<HeatmapContent>
<HeatmapMain>
<HeatmapMonths />
<HeatmapWeekdays class="row-start-2" />
<HeatmapGrid v-slot="{ cellGrid }">
<HeatmapRow v-for="(row, rowIdx) in cellGrid" :key="rowIdx">
<HeatmapCell v-for="cell in row" :key="cell.key" :cell="cell">
<template #tooltip="{ cell: targetCell }">
<div class="p-1 text-xs">
{{ targetCell.contributions > 0 ? 'Active' : 'No activity' }}
on
{{ targetCell.dateLabel }}
</div>
</template>
</HeatmapCell>
</HeatmapRow>
</HeatmapGrid>
</HeatmapMain>
</HeatmapContent>
<HeatmapFooter>
<HeatmapLegend :levels="[0, 4]">
<template #before>
<span class="mr-1 text-[10px]">Inactive</span>
</template>
<template #after>
<span class="ml-1 text-[10px]">Active</span>
</template>
</HeatmapLegend>
</HeatmapFooter>
</Heatmap>
</template>Custom Mapping Logic
To define your own mapping between contribution counts and levels, provide getLevel and getContributionsForLevel. This is useful when you have specific data thresholds or want to customize the intensity scale.
<script setup lang="ts">
import { ref } from 'vue'
import {
Heatmap,
HeatmapHeader,
HeatmapContent,
HeatmapGrid,
HeatmapCell,
HeatmapFooter,
HeatmapLegend,
HeatmapMain,
HeatmapMonths,
HeatmapWeekdays,
} from '~/components/ui/contribution-heatmap'
const mockData = ref<Record<string, number>>({})
const today = new Date()
const startDate = new Date(today)
startDate.setFullYear(today.getFullYear() - 1)
// Generate some sparse data
for (let i = 0; i < 365; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().split('T')[0]
if (Math.random() > 0.4) {
mockData.value[key!] = Math.floor(Math.random() * 50)
}
}
/**
* Custom level logic:
* 0: No activity
* 1: 1-10 (Low)
* 2: 11-20 (Medium)
* 3: 21-40 (High)
* 4: 41+ (Very High)
*/
const customGetLevel = (count: number) => {
if (count === 0) return 0
if (count <= 10) return 1
if (count <= 20) return 2
if (count <= 40) return 3
return 4
}
/**
* Custom legend labels:
* Returns the minimum value to be displayed for each level in the legend
*/
const customGetContributionsForLevel = (level: number) => {
return [0, 1, 11, 21, 41][level] ?? 0
}
</script>
<template>
<Heatmap
:data="mockData"
:start-date="startDate"
:end-date="today"
:get-level="customGetLevel"
:get-contributions-for-level="customGetContributionsForLevel"
>
<HeatmapHeader v-slot="{ totalContributions }">
<div class="flex flex-col">
<span class="text-sm font-semibold">Custom Thresholds</span>
<span class="text-xs text-muted-foreground">
Defining specific ranges for contribution levels
</span>
</div>
</HeatmapHeader>
<HeatmapContent>
<HeatmapMain>
<HeatmapMonths />
<HeatmapWeekdays class="row-start-2" />
<HeatmapGrid v-slot="{ cellGrid }">
<HeatmapRow v-for="(row, rowIdx) in cellGrid" :key="rowIdx">
<HeatmapCell v-for="cell in row" :key="cell.key" :cell="cell" />
</HeatmapRow>
</HeatmapGrid>
</HeatmapMain>
</HeatmapContent>
<HeatmapFooter>
<HeatmapLegend label="Legend follows custom thresholds" />
</HeatmapFooter>
</Heatmap>
</template>Interaction & Tooltips
Use the click:cell event to handle user selection and the tooltip slot on HeatmapCell to customize the hover experience.
<script setup lang="ts">
import { ref } from 'vue'
import {
Heatmap,
HeatmapHeader,
HeatmapContent,
HeatmapGrid,
HeatmapCell,
HeatmapFooter,
HeatmapLegend,
HeatmapMain,
HeatmapMonths,
HeatmapWeekdays,
type HeatmapCellProp,
} from '~/components/ui/contribution-heatmap'
import { Badge } from '~/components/ui/badge'
const mockData = ref<Record<string, number>>({})
const today = new Date()
const startDate = new Date(today)
startDate.setFullYear(today.getFullYear() - 1)
// Generate mock data
for (let i = 0; i < 365; i++) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const key = d.toISOString().split('T')[0]
if (Math.random() > 0.5) {
mockData.value[key!] = Math.floor(Math.random() * 15)
}
}
// Emoji mappings based on contribution levels
const getEmoji = (count: number): string => {
if (count === 0) return '😴'
if (count <= 2) return '🌱'
if (count <= 5) return '🔥'
if (count <= 9) return '⚡'
if (count <= 12) return '🚀'
return '👑'
}
const getAchievement = (count: number): string => {
if (count === 0) return 'Taking a break'
if (count <= 2) return 'Getting started'
if (count <= 5) return 'Building momentum'
if (count <= 9) return 'On fire'
if (count <= 12) return 'Unstoppable'
return 'Legendary'
}
const lastClicked = ref<HeatmapCellProp | null>(null)
const onCellClick = (cell: HeatmapCellProp) => {
lastClicked.value = cell
}
</script>
<template>
<div class="flex flex-col gap-6 w-full">
<Heatmap :data="mockData" :start-date="startDate" :end-date="today" @click:cell="onCellClick">
<HeatmapHeader>
<div class="flex flex-col">
<span class="text-sm font-semibold">Interaction Demo</span>
<span class="text-xs text-muted-foreground">Custom tooltips and click events</span>
</div>
</HeatmapHeader>
<HeatmapContent>
<HeatmapMain>
<HeatmapMonths />
<HeatmapWeekdays class="row-start-2" />
<HeatmapGrid v-slot="{ cellGrid }">
<HeatmapRow v-for="(row, rowIdx) in cellGrid" :key="rowIdx">
<HeatmapCell v-for="cell in row" :key="cell.key" :cell="cell">
<!-- Custom Tooltip Slot -->
<template #tooltip="{ cell: targetCell }">
<div class="flex flex-col gap-2 p-2">
<!-- Status Badge -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="text-lg">{{ getEmoji(targetCell.contributions) }}</span>
<span class="font-bold text-xs capitalize">
{{ getAchievement(targetCell.contributions) }} |
<span class="text-xs font-mono font-semibold">
{{ targetCell.contributions }}
</span>
</span>
</div>
</div>
<!-- Date Display -->
<div class="flex items-center gap-1.5 px-1">
<span class="text-[10px] opacity-60">📅</span>
<span class="text-xs font-medium opacity-80">{{ targetCell.dateLabel }}</span>
</div>
</div>
</template>
</HeatmapCell>
</HeatmapRow>
</HeatmapGrid>
</HeatmapMain>
</HeatmapContent>
<HeatmapFooter>
<HeatmapLegend label="Tasks completed" />
</HeatmapFooter>
</Heatmap>
<!-- Display click result -->
<div
v-if="lastClicked"
class="flex items-center gap-3 p-4 rounded-lg border bg-card text-card-foreground shadow-sm"
>
<div class="text-2xl">{{ getEmoji(lastClicked.contributions) }}</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-muted-foreground tracking-wider">
Last Selected
</span>
<div class="flex items-center gap-2">
<span class="font-semibold">{{ lastClicked.dateLabel }}</span>
<Badge variant="secondary">{{ lastClicked.contributions }} contributions</Badge>
</div>
</div>
</div>
<div v-else class="text-sm text-muted-foreground italic px-4 flex items-center gap-2">
<span class="text-base">👆</span>
Click a cell to see details here...
</div>
</div>
</template>Color Palettes
The palette prop controls the background color of cells at each contribution level. Pass a 5-element tuple of Tailwind CSS class strings — index 0 is the "no activity" color and index 4 is the highest intensity.
Using a preset palette
<script setup lang="ts">
import { Heatmap } from '@/components/ui/contribution-heatmap'
import type { HeatmapPalette } from '@/components/ui/contribution-heatmap'
const palette: HeatmapPalette = [
'bg-muted', // level 0 — no activity
'bg-blue-200 dark:bg-blue-800', // level 1
'bg-blue-400', // level 2
'bg-blue-600', // level 3
'bg-blue-800', // level 4 — highest
]
</script>
<template>
<Heatmap :palette="palette" />
</template>The default palette uses emerald shades:
const emeraldPalette: HeatmapPalette = [
'bg-muted',
'bg-emerald-200',
'bg-emerald-400',
'bg-emerald-600',
'bg-emerald-800',
]API Reference
The component is broken down into several sub-components to allow for maximum flexibility in layout and styling.
Heatmap (Root)
The root component manages the data fetching logic and provides the context for all sub-components.
| Prop | Type | Default | Description |
|---|---|---|---|
startDate | Date | start of year | The start date of the heatmap range. |
endDate | Date | end of year | The end date of the heatmap range. |
data | Record<string, number> | {} | Local data to populate cells. Key format: YYYY-MM-DD. |
githubUsername | string | undefined | If provided, automatically fetches contributions from GitHub. |
rows | number | 7 | Number of rows in the grid (usually 7 for days of the week). |
cols | number | 52 | Number of columns in the grid. |
maxLevel | number | 4 | The maximum intensity level (0 to maxLevel). |
palette | HeatmapPalette | emerald shades | A 5-element tuple of Tailwind class strings for levels 0–4. |
getLevel | (count: number) => number | Math.min(floor(count/2), 4) | Custom logic to determine the intensity level (0 to maxLevel) for a contribution count. |
getContributionsForLevel | (level: number, index: number) => number | level * 2 | Logic to determine the minimum count for a legend level. index is the position in the legend. |
Slots
default: Scoped slot providinggithubProfile(ifgithubUsernameis set).
Emits
click:cell: Fired when a cell is clicked. Provides theHeatmapCellPropobject.
HeatmapHeader
Optional header component for displaying titles and summaries.
Slots
default: Scoped slot providingtotalContributions.
HeatmapContent
The main container for the heatmap grid. Includes an internal ScrollArea for horizontal scrolling.
Slots
default: Scoped slot providingisLoading,isError, andcells.
HeatmapGrid
The grid engine that handles layout and keyboard navigation (Arrows).
Slots
default: Scoped slot providingcells,cellGrid(2D array),rows, andcols.
HeatmapCell
Represents a single day in the heatmap. Includes a built-in Tooltip.
| Prop | Type | Required | Description |
|---|---|---|---|
cell | HeatmapCellProp | Yes | The data for the specific cell. |
Slots
default: Scoped slot providingcellfor custom cell content.tooltip: Scoped slot providingcellfor custom tooltip content.
HeatmapLegend
Displays the intensity levels and their meaning. Automatically uses the active palette from context — no extra wiring needed.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | undefined | Optional label text on the left. |
levels | number[] | undefined | Specific levels to display in the legend. If not provided, it will show all levels from 0 to maxLevel. |
Slots
label: Custom label content.before: Content before the levels (defaults to "Less").after: Content after the levels (defaults to "More").
HeatmapWeekdays
Displays the weekday labels (Sun, Mon, etc.).
| Prop | Type | Default | Description |
|---|---|---|---|
showAll | boolean | false | If true, shows all weekdays. If false, shows Mon, Wed, Fri. |
HeatmapMain
The main layout container that organizes months, weekdays, and the grid using a 2-column CSS Grid. By default, it expects components like HeatmapMonths, HeatmapWeekdays, and HeatmapGrid as direct children.
Slots
default: The primary content area. Components likeHeatmapWeekdaysandHeatmapGridshould useclass="row-start-2"to align correctly belowHeatmapMonths.
Accessibility
The component is built with accessibility as a first-class citizen.
Keyboard Interactions
| Key | Action |
|---|---|
Tab | Focuses the first cell in the grid or the Legend tooltips. |
Arrow Keys | Navigates through the grid using a roving tabindex. Tooltips follow the focus. |
Enter | Triggers the click:cell event for the focused cell. |
ARIA Attributes
- The grid container uses
role="grid". - Each cell uses
role="gridcell"and provides anaria-labelwith the full date and contribution count. - Tooltips are linked to cells using appropriate ARIA relationship attributes.
- Loading states are announced using
aria-livewhere appropriate.
On This Page
InstallationUsageBasic Usage with Local DataExamplesGitHub IntegrationBinary ActivityCustom Mapping LogicInteraction & TooltipsColor PalettesUsing a preset paletteAPI ReferenceHeatmap (Root)HeatmapHeaderHeatmapContentHeatmapGridHeatmapCellHeatmapLegendHeatmapWeekdaysHeatmapMainAccessibilityKeyboard InteractionsARIA Attributes