Interactivity

Fragments & auto-animate

Emil Hvitfeldt

Objectives

  • What are fragments and how do we use them
  • Making our own fragments
  • Match elements across slides with auto-animate

Fragments

What are fragments?

The mechanism in revealjs that triggers something on the slides instead of advancing to the next slide

We have already seen fragments

my-deck.qmd

## Revealed one at a time

::: {.incremental}
- first
- second
- third
:::

The unit is .fragment

It goes on anything: a block, a list item, a span, an empty div.

my-deck.qmd

Always here.

::: {.fragment}
A paragraph that waits for a click.
:::

[or just a few words]{.fragment}

Each one costs a click, in the order they appear.

An incremental list is many fragments

my-deck.qmd

::: {.incremental}
- first
- second
- third
:::

my-deck.qmd

::: {.fragment}
- first
:::
::: {.fragment}
- second
:::
::: {.fragment}
- third
:::

.incremental is a convenience. It hands a .fragment to every item in the list, and nothing more.

Fragments do more than appear

my-deck.qmd

::: {.fragment .fade-in}
fade-in
:::

::: {.fragment .highlight-red}
highlight-red
:::

::: {.fragment .grow}
grow
:::

::: {.fragment .semi-fade-out}
semi-fade-out
:::

Order is not position

data-fragment-index says when, independent of where the element sits on the slide.

my-deck.qmd

::: {.fragment data-fragment-index="3"}
shown third
:::

::: {.fragment data-fragment-index="1"}
shown first
:::

::: {.fragment data-fragment-index="2"}
shown second
:::

Writing your own fragment

Two ways in

  • CSS, which styles the states an element passes through
  • JavaScript, which listens for the events reveal fires

we will start with the CSS way first

In CSS, a fragment has three states

  • before the click
  • the current fragment
  • after, once the next one has fired

Style each state and you have a new fragment. No JavaScript needed.

The signature

styles.scss

.reveal .slides section .fragment.my-name {
}

.reveal .slides section .fragment.my-name.visible {
}

.reveal .slides section .fragment.my-name.current-fragment {
}

The order matters: .visible and .current-fragment are applied at the same moment, so the cascade decides the winner.

Escaping the default

Reveal hides a fragment until it fires:

reveal.css

opacity: 0;
visibility: hidden;

Set both to unset when the element should be on screen the whole time and the fragment only changes it.

Highlight the current bullet

styles.scss

.reveal .slides section .fragment.highlight-last {
  opacity: 0; // default
  visibility: hidden; // default
  color: grey;
}

.reveal .slides section .fragment.highlight-last.visible {
  color: grey;
}

.reveal .slides section .fragment.highlight-last.current-fragment {
  color: #5500ff;
}

You do not owe all three states

No .current-fragment rule, so the highlight arrives and stays.

styles.scss

$theme-orange: #FFB81A;

.reveal .slides section .fragment.hl-orange {
  opacity: unset;
  visibility: unset;

  &.visible {
    background-color: $theme-orange;
  }
}

Four highlighters, one loop

#{$name} builds the class name, so four colors in a map become four fragment classes.

styles.scss

$colors: ("orange": #FFB81A, "yellow": #FFD571, "brown": #E2AE86, "pink": #FED7E1);

@each $name, $color in $colors {
  .reveal .slides section .fragment.hl-#{$name} {
    opacity: unset;
    visibility: unset;

    &.visible {
      background-color: lighten($color, 5%);
    }

    &.current-fragment {
      background-color: $color;
    }
  }
}

What is worth changing

color the text itself
background the fill behind it
opacity fade something back without removing it
border an underline, a box, a bracket
transform scale, rotate, nudge
filter blur, grayscale, brightness

Every one of these animates on the compositor, so reveal can tween them for free.

Careful with size and position

You can change anything, but width, height, margin, and font-size move the rest of the slide with them. That reflow reads as jitter.

transform: scale() grows a thing without touching its neighbours. Reach for that instead.

Color, background, and opacity are the safe playground.

That was all CSS

Reveal marked the element with a class, and your stylesheet reacted. Nothing ran, and going backwards undid itself.

  • Three states, because they are three selectors
  • Whatever CSS cannot express is out of reach

JavaScript has no states. It has two events, and undoing the change is your job.

Fragments in JavaScript

Reveal tells you when a fragment fires

_fragments.html

Reveal.on('fragmentshown', (event) => {
  if (event.fragment.classList.contains("my-class")) {
    // apply the change
  }
});

Reveal.on('fragmenthidden', (event) => {
  if (event.fragment.classList.contains("my-class")) {
    // undo the change
  }
});

Always write both directions

  • Presenters go backwards, in the middle of a talk, in front of people
  • fragmenthidden should be the exact inverse of fragmentshown
  • Scrolled down? Scroll back up. Switched a tab? Switch it back

If a change cannot be undone, it makes a bad fragment.

Wire it up

my-deck.qmd

format:
  revealjs:
    include-after-body:
      - "_fragments.html"

And _fragments.html is a <script> tag with your Reveal.on() calls inside.

An empty fragment still fires

A fragment does not have to contain what it changes.

my-deck.qmd

::: {.fragment .color data-color="orange"}
:::

event.fragment.dataset.color reads that value, so one class covers many slides.

Scroll a long output

Reveal cannot scroll for you, but a fragment can.

_scroll.html

Reveal.on('fragmentshown', (event) => {
  if (event.fragment.classList.contains("scroll")) {
    Reveal
      .getCurrentSlide()
      .querySelector(".cell-output code")
      .scrollTo({
        top: 1000,
        behavior: "smooth",
      })
  }
});

And the way back

fragmenthidden is the same handler with top: 0.

_scroll.html

Reveal.on('fragmenthidden', (event) => {
  if (event.fragment.classList.contains("scroll")) {
    Reveal
      .getCurrentSlide()
      .querySelector(".cell-output code")
      .scrollTo({
        top: 0,
        behavior: "smooth",
      })
  }
});

Two numbers apart. That is what “the exact inverse” buys you.

We have been using one all this time

Every embedded deck in this workshop is stepped by an empty fragment.

advance-embedded.html

Reveal
  .getCurrentSlide()
  .querySelector("iframe")
  .contentWindow
  .Reveal
  .right()

[]{.fragment .advance-slide} in the source, one per step of the inner deck.

You will meet these again

Many of the extensions in the next module are this same trick, packaged.

  • roughnotation circles and underlines a phrase on a click
  • more-fragments ships 90+ animations as fragment classes
  • tabset turns a Quarto tabset into keyboard-driven fragments

Nothing in them you could not have written here. They just save you the writing.

How far this goes

Eight empty fragments, one animation module, and a ggplot2 call comes apart layer by layer.

Auto-animate

Matching elements across a slide break

Mark two adjacent slides with auto-animate=true and reveal tweens the difference.

  • Position, size, color, corner radius, font size
  • Matching is automatic, but fragile
  • Give the pair the same data-id and it is not

One box, two slides

my-deck.qmd

## A single element {auto-animate=true}

::: {data-id="box" style="width:140px; height:140px;
  background:#5E7699; border-radius:14px;"}
:::

## The same data-id, changed {auto-animate=true}

::: {data-id="box" style="width:520px; height:260px;
  background:#FF9E8A; border-radius:48px;"}
:::

More advanced example

The pixel counting is over

This used to be the reason nobody did it: every panel is four numbers, and you found them by rendering, squinting, and editing again.

With AI agents this work is a little easier.

  • Describe the layout, let the model write the data-id divs and their coordinates
  • Paste the slide back and ask for the next state of it
  • You still judge whether the motion reads well

Your turn

Exercise

  1. Turn a list on one of your slides into an .incremental list
  2. Add .highlight-last and the SCSS for it, so the current bullet stands out
  3. Take one slide, copy it, mark both auto-animate=true, and change one thing
  4. Give the element you changed a data-id and watch the animation get steadier

Thank you

Back to the workshop materials.