sulaf
DocsComponentsComposables
X
Sections
  • Get Started
  • Installation
  • Components
  • Composables
  • Animations
    Soon
Components
  • Autocomplete
    New
  • Show More
    New
  • Meter
    New
  • Contribution Heatmap
    New
  • Typography
    New
  • Code Block
    Soon
  • Code Snippet
    Soon
  • Guided Tour
    Soon
  • Star Rating
    Soon
Composables
  • useGithubProfile
    Beta
  • useIsMac
    New
Animations
Soon

Contribution Heatmap

PreviousNext

A GitHub-style contribution calendar component for visualizing activity over time.

Palette default
Contribution Activity1,129 contributions in the last year
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
Jan
Feb
Mar
Apr
MonWedFri
Learn how we count contributions
Less
More
<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.

GitHub activity for User0 contributions in the last year
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
Jan
Feb
Mar
Apr
MonWedFri
Data fetched from GitHub API
Less
More

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.

Binary ActivityShowing simple active/inactive states
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
Jan
Feb
Mar
Apr
MonWedFri
Inactive
Active
<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.

Custom Thresholds Defining specific ranges for contribution levels
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
Jan
Feb
Mar
Apr
MonWedFri
Legend follows custom thresholds
Less
More
<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.

Interaction DemoCustom tooltips and click events
May
Jun
Jul
Aug
Sep
Oct
Nov
Dec
Jan
Feb
Mar
Apr
MonWedFri
Tasks completed
Less
More
👆 Click a cell to see details here...
<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.

PropTypeDefaultDescription
startDateDatestart of yearThe start date of the heatmap range.
endDateDateend of yearThe end date of the heatmap range.
dataRecord<string, number>{}Local data to populate cells. Key format: YYYY-MM-DD.
githubUsernamestringundefinedIf provided, automatically fetches contributions from GitHub.
rowsnumber7Number of rows in the grid (usually 7 for days of the week).
colsnumber52Number of columns in the grid.
maxLevelnumber4The maximum intensity level (0 to maxLevel).
paletteHeatmapPaletteemerald shadesA 5-element tuple of Tailwind class strings for levels 0–4.
getLevel(count: number) => numberMath.min(floor(count/2), 4)Custom logic to determine the intensity level (0 to maxLevel) for a contribution count.
getContributionsForLevel(level: number, index: number) => numberlevel * 2Logic to determine the minimum count for a legend level. index is the position in the legend.

Slots

  • default: Scoped slot providing githubProfile (if githubUsername is set).

Emits

  • click:cell: Fired when a cell is clicked. Provides the HeatmapCellProp object.

HeatmapHeader

Optional header component for displaying titles and summaries.

Slots

  • default: Scoped slot providing totalContributions.

HeatmapContent

The main container for the heatmap grid. Includes an internal ScrollArea for horizontal scrolling.

Slots

  • default: Scoped slot providing isLoading, isError, and cells.

HeatmapGrid

The grid engine that handles layout and keyboard navigation (Arrows).

Slots

  • default: Scoped slot providing cells, cellGrid (2D array), rows, and cols.

HeatmapCell

Represents a single day in the heatmap. Includes a built-in Tooltip.

PropTypeRequiredDescription
cellHeatmapCellPropYesThe data for the specific cell.

Slots

  • default: Scoped slot providing cell for custom cell content.
  • tooltip: Scoped slot providing cell for custom tooltip content.

HeatmapLegend

Displays the intensity levels and their meaning. Automatically uses the active palette from context — no extra wiring needed.

PropTypeDefaultDescription
labelstringundefinedOptional label text on the left.
levelsnumber[]undefinedSpecific 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.).

PropTypeDefaultDescription
showAllbooleanfalseIf 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 like HeatmapWeekdays and HeatmapGrid should use class="row-start-2" to align correctly below HeatmapMonths.

Accessibility

The component is built with accessibility as a first-class citizen.

Keyboard Interactions

KeyAction
TabFocuses the first cell in the grid or the Legend tooltips.
Arrow KeysNavigates through the grid using a roving tabindex. Tooltips follow the focus.
EnterTriggers the click:cell event for the focused cell.

ARIA Attributes

  • The grid container uses role="grid".
  • Each cell uses role="gridcell" and provides an aria-label with the full date and contribution count.
  • Tooltips are linked to cells using appropriate ARIA relationship attributes.
  • Loading states are announced using aria-live where appropriate.
MeterTypography

On This Page

InstallationUsageBasic Usage with Local DataExamplesGitHub IntegrationBinary ActivityCustom Mapping LogicInteraction & TooltipsColor PalettesUsing a preset paletteAPI ReferenceHeatmap (Root)HeatmapHeaderHeatmapContentHeatmapGridHeatmapCellHeatmapLegendHeatmapWeekdaysHeatmapMainAccessibilityKeyboard InteractionsARIA Attributes
© 2026 - Built with shadcn-vue . The source code is available on GitHub.