Pattern

Agent figure playback

An illustration that plays once, step by step, when it comes into view, and can be paused or replayed.

01

Watch it

The Hirsla story’s figure (app mode) on the Agents page. Recorded from the live code.
02

Rules

Full spec, standalone files, and every live example: web/components/agent-figure/. Steps every 1.5s after a 700ms start, 300ms each; plays once from 40% in view, pauses while out of view and resumes when back; Pause, Play, and Replay. End state without script and under reduced motion. In print, steps show; a ledger row that closes during playback can print still open if the figure has not played (proposed fix for the site: add the closed state to the print rules).

03

Code

From site/assets/js/site.js, lines 102 to 134.

// ------------------------------------------------------------ agent figures
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
    document.querySelectorAll('main .agf').forEach((fig) => {
        const items = [...fig.querySelectorAll('[data-s]')];
        const closes = [...fig.querySelectorAll('[data-close]')];
        const total = Math.max(0, ...items.map((e) => +e.dataset.s), ...closes.map((e) => +e.dataset.close));
        const btn = fig.querySelector('.agf-btn');
        let step = 0;
        let timer = 0;
        let seen = !('IntersectionObserver' in window);
        let paused = false;
        const run = () => {
            clearTimeout(timer);
            fig.dataset.step = step;
            items.forEach((e) => e.classList.toggle('is-in', +e.dataset.s <= step));
            items.forEach((e) => e.classList.toggle('is-now', +e.dataset.s === step));
            closes.forEach((e) => e.classList.toggle('is-closed', +e.dataset.close <= step));
            if (btn) btn.textContent = paused ? 'Play' : step >= total ? 'Replay' : 'Pause';
            if (seen && !paused && step < total) timer = setTimeout(() => ((step += 1), run()), step ? 1500 : 700);
        };
        fig.classList.add('agf-on');
        if (btn) {
            btn.hidden = false;
            btn.addEventListener('click', () => {
                if (step >= total) step = 0;
                else paused = !paused;
                run();
            });
        }
        if (!seen) new IntersectionObserver(([e]) => ((seen = e.isIntersecting), run()), { threshold: 0.4 }).observe(fig);
        run();
    });
}