Skip to content

Animation Guide

Pulp provides a first-class animation system integrated into the view layer. Animations are deterministic, theme-aware, and work seamlessly with both C++ widgets and the JS bridge.

Core Concepts

FrameClock

The FrameClock is the single authoritative time source for all view animations. It lives on the UI thread and is advanced once per frame by the window host.

#include <pulp/view/frame_clock.hpp>

FrameClock clock;
root_view.set_frame_clock(&clock);

// In your render loop:
clock.tick(dt_seconds);   // the MEASURED delta for this frame

Children access the clock via frame_clock(), which walks up the parent chain.

dt_seconds must be measured, never assumed. A hardcoded 1.0f/60.0f runs animations at double speed on a 120 Hz display and slow whenever frames are dropped or coalesced — motion duration becomes a function of the display and the machine. FrameClock::tick() integrates whatever it is handed and deliberately does not clamp (its time() is also the shader clock, so clamping would desynchronise it from wall time permanently).

HostFramePump — the frame-timing contract

Hosts don't call tick() directly. pulp/view/host_frame_pump.hpp turns the vsync source's own presentation timestamp (CVTimeStamp::hostTime, CADisplayLink.targetTimestamp) into a measured dt and delivers that ONE dt to every consumer — FrameClock subscribers, CSS animation timelines, widget animations, and wake-from-idle activity probes:

#include <pulp/view/host_frame_pump.hpp>

HostFramePump pump;   // owned by the host, lives as long as the render loop

// Once per host tick, with the vsync source's presentation timestamp:
const auto tick = pulp::view::begin_host_frame(&root, clock, pump, now_seconds,
                                               host_needs_repaint);
if (tick.should_render) {
    pulp::view::advance_host_frame(&root, clock, tick.dt);   // one dt, everywhere
    render();
}

The one place the pump does not pass the raw delta through is wake-from-idle: Pulp idles a static UI at 0 fps, so the wall-clock gap since the host stopped pumping is not elapsed animation time. A resume — the first frame, an explicit suspend(), a skipped vsync (below), or a gap larger than wake_threshold() (default 250 ms) — advances by one nominal frame instead. Otherwise an animation that starts on wake would teleport straight to its end state on its first frame. Everything else, including dropped and slow frames, passes through raw so animations track wall time.

Set pump.set_nominal_dt() from the display's real refresh period so the first frame and any resume advance by one frame of that display. On macOS that is CVDisplayLinkGetNominalOutputVideoRefreshPeriodnot the Actual... variant, which reports a measured period and returns 0 until the link has run, i.e. at exactly the moment a host seeds its pump. On iOS/iPadOS it is CADisplayLink.duration.

Skipped vsyncs

A host that idles a static tree at 0 fps typically leaves its display link running and simply declines to turn those vsyncs into frames. Those vsyncs are never measured, so the pump's last timestamp goes stale — and the next real frame would otherwise measure the whole idle gap and hand it to whatever animation just started (a 100 ms mouse-away followed by a hover would complete an 80 ms hover fade on frame one). Route that decision through should_dispatch_host_frame, which records the skip so the next frame resumes:

// On the vsync source's own thread (e.g. the CVDisplayLink thread):
if (!pulp::view::should_dispatch_host_frame(pump, needs_repaint, continuous,
                                            has_idle_callback))
    return;  // skip recorded: the next dispatched frame is a resume
dispatch_to_ui_thread(...);   // begin_host_frame / advance_host_frame as above

ValueAnimation

ValueAnimation is a lightweight, embeddable animator designed to be a member variable of a widget. No heap allocation.

#include <pulp/view/animation.hpp>

ValueAnimation opacity(0.0f);       // start at 0
opacity.animate_to(1.0f, 0.15f);    // fade in over 150ms
opacity.animate_to(1.0f, 0.15f, easing::ease_out_cubic); // with easing

// In your frame tick:
opacity.advance(dt);
float current = opacity.value();     // interpolated value
bool still_going = opacity.animating();

Easing Functions

Pulp includes these easing functions in pulp::view::easing:

Function Character
linear Constant speed
ease_in_quad Slow start
ease_out_quad Slow end
ease_in_out_quad Slow start and end
ease_in_cubic Slower start
ease_out_cubic Slower end (good default)
ease_in_out_cubic Smooth S-curve
ease_in_expo Exponential acceleration
ease_out_expo Exponential deceleration
ease_out_elastic Springy overshoot
ease_out_bounce Bouncing settle

Resolve by name with easing_by_name("ease_out_cubic").

Motion Tokens

Animation durations and easing choices are part of the design token system, not hardcoded constants. This means the AI Style Designer can change the "feel" of a plugin, not just its colors.

Duration Tokens

Token Dark default Pro Audio Purpose
motion.duration.fast 0.08s 0.06s Hover, focus ring
motion.duration.normal 0.15s 0.12s Toggle, button press
motion.duration.slow 0.30s 0.25s Panel open/close
motion.duration.meter_decay 0.30s 0.30s Meter RMS falloff
motion.duration.peak_hold 1.50s 1.50s Peak indicator hold

Easing Tokens

Token Default
motion.easing.interaction ease_out_cubic
motion.easing.enter ease_out_quad
motion.easing.exit ease_in_quad

Using Tokens in Widgets

void MyWidget::on_mouse_enter() {
    float dur = resolve_dimension("motion.duration.fast", 0.08f);
    hover_.animate_to(1.0f, dur, easing::ease_out_quad);
}

Built-in Widget Animations

These animations are built into Pulp's shipped widgets:

Widget Animation Token
Toggle Thumb slides between on/off motion.duration.normal
Toggle Hover highlight motion.duration.fast
Knob Hover glow ring motion.duration.fast
Fader Thumb scale on hover motion.duration.fast
Tooltip Fade in/out motion.duration.normal

JS Bridge Animation API

animate()

animate(widgetId, property, targetValue, durationMs, easingName);
// Example:
animate('volume', 'value', 0.75, 300, 'ease_out_cubic');

Motion Token Control

setMotionToken('motion.duration.fast', 0.05);  // make hover snappier
getMotionToken('motion.duration.fast');          // read current value

Widget Visibility

setVisible('panel', false);   // hide
setVisible('panel', true);    // show
removeWidget('panel');        // remove from tree entirely

Testing Animations

All animation tests use deterministic frame stepping -- no wall-clock timing:

FrameClock clock;
Toggle toggle;
toggle.set_on(true);

// Step through frames
for (int i = 0; i < 20; i++)
    toggle.advance_animations(0.016f);

// Assert settled state
REQUIRE(toggle.thumb_position() == Approx(1.0f));

Design Philosophy

  • Animation is subtle by default. The goal is polished, not flashy.
  • Motion timing belongs in the design system, not scattered constants.
  • Widget-local animations are cheap and ubiquitous -- they're member variables, not managed objects.
  • The FrameClock is deterministic -- tests never use wall-clock sleeps.