A modern SwiftUI calendar heatmap and contribution graph component — visualize time-series data the way GitHub shows your contributions.
From a single contribution grid to a full habit-tracker UI — both screens above are built with CalendarHeatmap, no extra views.
Most existing heatmap libraries for Apple platforms target UIKit and have not been updated for years. HeatmapKit is built SwiftUI-first, supports the full Apple platform family (iOS / macOS / watchOS / tvOS / visionOS), and ships modern interactions like horizontal scrolling, automatic locale-aware month labels, and tap callbacks.
- 🍎 Pure SwiftUI — declarative API, no UIKit bridging
- 📅 Calendar heatmap — GitHub-style 7×N grid, perfect for contributions / habits / activity
- 📐 Adaptive layout — opt-in
.fitToWidth(minCellSize:)sizes cells to the container; falls back to horizontal scrolling (anchored to the most recent week) when even the floor doesn't fit - 🎨 6 built-in palettes with auto light / dark variants (green mirrors github.com), plus full custom-color support
- ⚖️ Auto or custom thresholds — let HeatmapKit bucket values from
data.max(), or supply your own cutoffs - 🌍 Localized labels — month/weekday labels follow
Calendar.current.locale - 👆 Tap-to-detail — opt-in callback per cell, plus a built-in popover tooltip
- ♿ VoiceOver-ready — every cell ships an accessibility label, customizable per app
- 📸 Share as image —
.snapshot(scale:background:)returns aCGImageready forShareLink - 🪶 Zero dependencies — Apple frameworks only
| Platform | Minimum |
|---|---|
| iOS | 17.0 |
| macOS | 14.0 |
| watchOS | 10.0 |
| tvOS | 17.0 |
| visionOS | 1.0 |
| Swift | 5.9 |
| Xcode | 15 |
Add the dependency in Package.swift:
dependencies:[.package(url:"https://github.com/jacklv-coder/HeatmapKit", from:"0.5.0")]Or in Xcode: File → Add Package Dependencies… and paste the URL.
The repo ships with a runnable iOS sample app under Demo/:
git clone https://github.com/jacklv-coder/HeatmapKit
cd HeatmapKit
open Demo/HeatmapKitDemo/HeatmapKitDemo.xcodeprojThe project already references the parent HeatmapKit folder as a local Swift package — pick the HeatmapKitDemo scheme, run on an iOS Simulator, and you'll see the heatmap with palette switching, tooltips, accessibility labels, and a Share button wired through snapshot(scale:background:).
import SwiftUI
import HeatmapKit
structContentView:View{varbody:someView{
// Defaults to the last 365 days ending today.
CalendarHeatmap(contributions: sampleData)}privatevarsampleData:[Date:Double]{
// Map each day to a value (e.g. minutes focused, commits made, etc.)
lettoday=Calendar.current.startOfDay(for:Date())return[today:35.0]}}letcal=Calendar.current
lettoday= cal.startOfDay(for:Date())letstart= cal.date(byAdding:.day, value:-90, to: today)!
CalendarHeatmap(contributions: data, dateRange: start...today)If your data isn't already a [Date: Double] map, point HeatmapKit at any value type using key paths:
structSession{vardate:Datevarminutes:Double}letsessions:[Session]=...CalendarHeatmap(
data: sessions,
dateKey: \.date,
valueKey: \.minutes,
aggregation:.sum // .sum / .count / .max / .min / .average
)Multiple items on the same day are combined per aggregation.
CalendarHeatmap(contributions: data).cellSize(14).cellSpacing(3).cellCornerRadius(3).levels(.orange) // .green / .orange / .blue / .purple / .red / .grayscale
.thresholds([1,5,10,20]) // explicit cutoffs (count = levels.count - 1)
.firstWeekday(.monday).showMonthLabels(true).showWeekdayLabels(false).todayHighlightColor(.primary) // opt in to outline today's cell (default is nil, matching github.com)
.scrollEnabled(true).defaultScrollEdge(.trailing) // anchor to most recent on first appearance
.onCellTap{ date, value inprint("\(date): \(value)")}.tooltipOnTap{ date, value in
// Built-in popover. Tap the same cell again — or outside — to dismiss.
// Default emits "{long date}\n{value}"; this example overrides it.
letday= date.formatted(date:.abbreviated, time:.omitted)return"\(day) — \(Int(value)) min"}.accessibilityCellLabel{ date, value in
// VoiceOver announces this for each in-range cell.
// Default emits "{long date}, {value}" — override to inject units / wording.
letday= date.formatted(date:.abbreviated, time:.omitted)return value ==0?"\(day), no activity":"\(day), \(Int(value)) minutes focused"}By default the heatmap renders at a static cellSize (14pt) wrapped in a horizontal ScrollView. On wider screens (iPad, macOS) that wastes space and forces unnecessary scrolling; on narrower screens it can clip awkwardly at the edges. Opt into adaptive sizing:
CalendarHeatmap(contributions: data).cellSize(14) // upper bound: cells never grow past this
.fitToWidth(minCellSize:10) // lower bound: cells shrink down to thisThree-stage behavior driven by container width:
| Width | Cell size | Scroll? |
|---|---|---|
| Wide (≥ ~900pt for a 53-week year) | 14pt (capped) | No — full year visible |
| Mid (~500–900pt) | Adaptive between 10 and 14pt | No — full year visible |
| Narrow (< ~500pt, typical iPhone) | Sized so N whole weeks exactly fill the container (cell ≥ minCellSize) | Yes — scroll older history |
In the narrow case the trailing-anchored initial render shows N whole columns with no partial cell at the leading edge; subsequent scroll positions snap to week boundaries via .scrollTargetBehavior(.viewAligned). The scroll fallback engages automatically whenever even minCellSize doesn't fit all weeks, regardless of .scrollEnabled(_:).
CalendarHeatmap(contributions: data).levels([Color.gray.opacity(0.15), // empty / no-data
Color.pink.opacity(0.4),Color.pink.opacity(0.7),Color.pink,])The first color is the empty / no-data shade; the rest are progressively more intense. The number of colors you pass determines the level count. Without .thresholds(_:), HeatmapKit splits data.max() evenly across the remaining buckets.
Note: built-in palettes (
.green,.orange, …) automatically swap between light- and dark-mode variants based on the surroundingcolorSchemeenvironment. Custom palettes passed via.levels([Color])are static — gate on@Environment(\.colorScheme)yourself if you need them to adapt.
import SwiftUI
import HeatmapKit
structShareableHeatmap:View{letdata:[Date:Double]varbody:someView{letheatmap=CalendarHeatmap(contributions: data).levels(.green)VStack{
heatmap
iflet cg = heatmap.snapshot(scale:3, background:Color.black){letimage=Image(decorative: cg, scale:3)ShareLink(
item: image,
preview:SharePreview("My activity", image: image)){Label("Share", systemImage:"square.and.arrow.up")}}}}}snapshot(scale:background:) renders the full grid without its scroll wrapper, so the entire date range is captured at intrinsic width — even if the on-screen heatmap would normally scroll. Pass background: to fill behind the grid (with 12pt padding) so the output looks intentional outside the dark-mode app it lives in. The returned CGImage works with ShareLink, Image(decorative:scale:), UIImage(cgImage:), or any CGImageDestination for save-to-disk use cases.
- v0.1 —
CalendarHeatmapcore: grid, horizontal scroll, 6 palettes, custom thresholds, tap callback, today highlight, locale-aware month/weekday labels - v0.2 — VoiceOver labels per cell, customizable via
.accessibilityCellLabel - v0.3 — Built-in detail tooltip on tap, customizable via
.tooltipOnTap - v0.4 — Shareable image renderer via
.snapshot(scale:background:)(works withShareLink) - v0.5 — Adaptive
fitToWidth(minCellSize:)layout; cells size to the container, scroll only when the floor doesn't fit - v0.6 — Additional layouts: weekly heatmap, hour×weekday matrix
- Localization audit (RTL, non-Gregorian calendars)
Issues and pull requests are welcome. The project is in its early days, so feedback on API shape is especially valuable.
- The visual language — 7-row grid, light/dark palette, today outline behavior — is patterned on GitHub's contribution graph.
- The Boards tab in the demo app is inspired by the habit-tracking UI of Streaks.
- Thanks to everyone filing issues and feedback during the 0.x series.
See CHANGELOG.md for release notes.
MIT © 2026 jacklv-coder

