
Sequential Storytelling with Keyframes
Source:vignettes/articles/sequential-storytelling.Rmd
sequential-storytelling.RmdKeyframes turn a chart into a short sequence of named data states. The chart uses its first registered frame initially. With two or more frames, myIO adds a separate previous, play/pause, and next control surface with the active label. Playback advances once, waits one second after each transition, and stops at the final frame.
A single-layer story
Build the layer first, then register each state with
addKeyframe(). A data frame is accepted directly when the
chart has exactly one serialized layer.
start <- data.frame(year = 2024:2026, value = c(18, 22, 25))
growth <- data.frame(year = 2024:2026, value = c(18, 28, 39))
outlook <- data.frame(year = 2024:2026, value = c(18, 31, 48))
myIO(start) |>
addIoLayer(
type = "line", label = "Revenue", color = "#4E79A7",
mapping = list(x_var = "year", y_var = "value")
) |>
addKeyframe(start, "Baseline") |>
addKeyframe(growth, "Growth case") |>
addKeyframe(outlook, "Outlook") |>
setTransition(duration = 500, easing = "cubic") |>
setAxisFormat(xLabel = "Year", yLabel = "Revenue")Labels must be unique, non-empty strings. Registering a keyframe before any layer exists is an error, as is passing a data frame after the chart has more than one serialized layer.
Complete multi-layer snapshots
For a multi-layer chart, pass a named list keyed by existing layer labels. Each stored keyframe is a complete snapshot. If a frame omits a layer, that layer retains its data from the preceding frame.
actual_1 <- data.frame(month = 1:3, value = c(10, 13, 17))
actual_2 <- data.frame(month = 1:4, value = c(10, 13, 17, 22))
target_1 <- data.frame(month = 1:3, value = c(12, 15, 18))
target_2 <- data.frame(month = 1:4, value = c(12, 15, 18, 21))
myIO() |>
addIoLayer(
type = "line", label = "Actual", data = actual_1,
mapping = list(x_var = "month", y_var = "value")
) |>
addIoLayer(
type = "line", label = "Target", data = target_1,
mapping = list(x_var = "month", y_var = "value")
) |>
addKeyframe(
list(Actual = actual_1, Target = target_1),
"Quarter opening"
) |>
addKeyframe(
list(Actual = actual_2),
"Latest actuals"
) |>
addKeyframe(
list(Actual = actual_2, Target = target_2),
"Revised target"
)The Target layer in Latest actuals carries
forward from Quarter opening. Unknown layer names, unnamed
lists, non-data-frame values, and duplicate frame labels fail in R
before a widget is serialized.
Transforms are applied per frame
Keyframe inputs are source data. myIO applies the layer’s configured R transform before storing the snapshot, exactly as it does when the layer is created.
observed <- data.frame(x = 1:20, y = (1:20) + sin(1:20))
projected <- data.frame(x = 1:20, y = (1:20) * 1.4 + cos(1:20))
myIO(observed) |>
addIoLayer(
type = "line", label = "Trend", transform = "lm",
mapping = list(x_var = "x", y_var = "y")
) |>
addKeyframe(observed, "Observed trend") |>
addKeyframe(projected, "Projected trend")Select and step from Shiny
The proxy methods use the same chart instance registry as
updateMyIOData(). setKeyframe() accepts a
unique label or a one-based index. Steps clamp at the first and final
frames.
ui <- shiny::fluidPage(
myIOOutput("story"),
shiny::actionButton("next_frame", "Next"),
shiny::actionButton("show_baseline", "Baseline")
)
server <- function(input, output, session) {
output$story <- renderMyIO({
myIO(start) |>
addIoLayer("line", label = "Revenue",
mapping = list(x_var = "year", y_var = "value")) |>
addKeyframe(start, "Baseline") |>
addKeyframe(growth, "Growth case") |>
addKeyframe(outlook, "Outlook")
})
shiny::observeEvent(input$next_frame, {
myIOProxy("story", session) |> stepKeyframe("next")
})
shiny::observeEvent(input$show_baseline, {
myIOProxy("story", session) |> setKeyframe("Baseline")
})
}
shiny::shinyApp(ui, server)Motion, accessibility, and export
Buttons support normal keyboard activation, show a visible focus indicator, and announce the active label through a polite live region. The controls wrap on narrow containers and are excluded from print and chart exports.
setTransition(duration = 0) renders each state
immediately. The same zero-duration behavior is applied when the viewer
requests reduced motion; manual stepping and one-pass playback remain
available in both cases. Timers are cleared on pause, widget re-render,
and destruction. Legend visibility, brush, zoom, and toggle state are
preserved because frame changes use the existing in-place data update
path.