Animating Git visualizations with gifski
viz-git-gif.RmdThe data visualization functions in ghreadme return
ggplot objects, which means each call can be saved as a PNG
frame with ggsave() and then stitched into an animated GIF
with gifski.
This vignette walks through three patterns for turning a static plot
into an animation that highlights how your commit activity evolves over
time.
Prerequisites
install.packages(c("gifski", "ggplot2"))The recipes below also assume the data-collection and
ggplot-based plot helpers from ghreadme:
library(ghreadme)
stats <- collect_git_commits(
user = "your-username",
emails = "you@example.com",
include = c("commits", "stars", "issues", "prs")
)Calling collect_git_commits() with no
include argument returns the original commits-only tibble
shape, which is exactly what the plot functions expect. If you also want
star counts, authored issues, or authored pull requests in the same
call, see vignette("gh-stats");
the multi-include path returns a named list, in which case the recipes
here would use stats$commits in place of the bare
commits object.
How the approach works
Each recipe follows the same pattern:
- Create a temporary directory for the PNG frames.
- Loop, building one ggplot per frame and writing it with
ggsave(). - Call
make_gif()to assemble the frames into a GIF.
make_gif() is exported from ghreadme and
handles collecting the PNGs, building a per-frame delay vector, and
calling gifski::gifski(). The first_duration
and last_duration arguments let you hold the opening and
closing frames longer so readers have time to register the start and end
states.
The three recipes below all follow this pattern and only differ in what is plotted on each frame.
Recipe 1: cumulative commits, sliding the end date
cumulative_line_plot() already shows growth over time,
but moving the date_end forward one month at a time makes
that growth feel kinetic. The lines appear, lengthen, and overtake one
another as your repos accumulate commits.
Note: Start
date_seqone month afterdate_begin. Ifdate_endequalsdate_beginon the first iteration and no commits exist on that exact day,cumulative_line_plot()will error with “No commits after filtering.”
date_seq <- seq(
from = as.Date("2023-02-01"), # one month after date_begin
to = max(stats$commits$date),
by = "1 month"
)
cum_dir <- file.path(tempdir(), "cumulative-frames")
dir.create(cum_dir, showWarnings = FALSE, recursive = TRUE)
for (i in seq_along(date_seq)) {
p <- cumulative_line_plot(
stats$commits,
date_begin = "2023-01-01",
date_end = date_seq[[i]],
top_n = 8
)
ggsave(
filename = file.path(cum_dir, sprintf("%04d.png", i)),
plot = p,
width = 1000, height = 600, units = "px", dpi = 96, bg = "white"
)
}
make_gif(cum_dir, "cumulative.gif",
width = 1000, height = 600,
first_duration = 1, frame_duration = 0.4, last_duration = 4)Recipe 2: calendar heatmap, year by year
calendar_heatmap_plot() is a strong fit for a slow
reveal: each frame adds one more year of history, so the GIF builds
toward the present. Because the plot’s date_begin argument
is honored during filtering, all you need to do is bump
date_end to the end of the next year.
years <- sort(unique(stats$commits$year))
cal_dir <- file.path(tempdir(), "calendar-frames")
dir.create(cal_dir, showWarnings = FALSE, recursive = TRUE)
for (i in seq_along(years)) {
p <- calendar_heatmap_plot(
stats$commits,
date_begin = paste0(min(years), "-01-01"),
date_end = paste0(years[[i]], "-12-31"),
title = paste("Through", years[[i]])
)
ggsave(
filename = file.path(cal_dir, sprintf("%04d.png", i)),
plot = p,
width = 1100, height = 700, units = "px", dpi = 96, bg = "white"
)
}
make_gif(cal_dir, "calendar.gif",
width = 1100, height = 700,
first_duration = 1, frame_duration = 1.2, last_duration = 4)Recipe 3: punchcard carousel across repos
The third pattern cycles through repositories instead of through
time. The punchcard_plot() graph is rendered once per repo,
so the GIF feels like flipping through a deck of cards. The example
below ranks repos by commit count; see the “Rank by stars” tip below for
an API-backed alternative.
top_repos <- stats$commits |>
dplyr::count(repo, sort = TRUE) |>
dplyr::slice_head(n = 6) |>
dplyr::pull(repo)
# Alternative: rank by stars received (now that stats$stars is available)
# top_repos <- head(stats$stars$repo, 6)
punch_dir <- file.path(tempdir(), "punchcard-frames")
dir.create(punch_dir, showWarnings = FALSE, recursive = TRUE)
for (i in seq_along(top_repos)) {
p <- punchcard_plot(stats$commits, repo = top_repos[[i]], title = top_repos[[i]])
ggsave(
filename = file.path(punch_dir, sprintf("%04d.png", i)),
plot = p,
width = 1000, height = 500, units = "px", dpi = 96, bg = "white"
)
}
make_gif(punch_dir, "punchcard.gif",
width = 1000, height = 500,
first_duration = 1, frame_duration = 1.5, last_duration = 4)Tips
Frame count vs file size. GIFs are uncompressed across frames, so a monthly cadence over five years (60 frames) renders nicely; a daily cadence produces a multi-megabyte file. Pick a step that yields 20–80 frames.
Final-frame hold.
last_duration(in seconds) gives readers time to absorb the end state before the GIF loops. 3–5 seconds is a good range.Consistent axes. Some plots auto-scale per frame, which makes axes jitter as data is added. If that bothers you, pass an explicit
date_begin/date_endso the x-axis stays fixed across frames.Spiral plots.
spiral_points_plot(),spiral_heatmap_plot(), andspiral_horizon_plot()draw with thegrid/spiralizegraphics system rather than returning ggplot objects. They are already compatible with theggsave()+gifski::gifski()approach used here — save each frame withgrDevices::png()/dev.off()and pass the resulting file list directly togifski::gifski().-
Rank by stars. The punchcard recipe ranks repos by commit count. To rank by GitHub stars instead, ask
collect_git_commits()for both types at once and pull the carousel order from thestarselement:stats <- collect_git_commits( user = "your-username", emails = "you@example.com", include = c("commits", "stars") ) top_repos <- head(stats$stars$repo, 6) # then iterate with punchcard_plot(stats$commits, repo = top_repos[[i]], ...)
Embedding in your README
Drop the rendered GIF into your profile README.Rmd the
same way as any image — typically right above the badge block so it
plays as soon as someone loads your profile:

Re-knit README.Rmd to refresh README.md,
then commit the GIF alongside it. For an end-to-end setup (scaffolding
the Rmd, scheduling re-knits via GitHub Actions), see vignette("ghreadme").