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
struct ContentView: View {
var body: some View {
// Defaults to the last 365 days ending today.
CalendarHeatmap(contributions: sampleData)
}
private var sampleData: [Date: Double] {
// Map each day to a value (e.g. minutes focused, commits made, etc.)
let today = Calendar.current.startOfDay(for: Date())
return [today: 35.0]
}
}let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let start = 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:
struct Session {
var date: Date
var minutes: Double
}
let sessions: [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 in
print("\(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.
let day = 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.
let day = 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
struct ShareableHeatmap: View {
let data: [Date: Double]
var body: some View {
let heatmap = CalendarHeatmap(contributions: data).levels(.green)
VStack {
heatmap
if let cg = heatmap.snapshot(scale: 3, background: Color.black) {
let image = 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

