Skip to content

Guide Animation

MeowLynxSea edited this page Jun 13, 2026 · 5 revisions

Animation

Yororen UI v0.3 unifies component animation around a single data type — AnimatedVisibility — owned by every stateful composite (SelectState, ModalState, PopoverState, TooltipState, DropdownMenuState, ComboBoxState, MenuState). The renderer reads the visibility's progress; the orchestrator drives the timer.

This page covers the lifecycle, the preset helpers, and how the default + brutalism renderers read it to paint enter / exit transitions.

The single concept: AnimatedVisibility

Source: crates/yororen-ui-core/src/animation/visibility.rs.

pub struct AnimatedVisibility {
    pub target: bool,      // desired end state
    pub progress: f32,     // current normalized visibility [0.0, 1.0]
    pub enter_config: AnimationConfig,
    pub exit_config: AnimationConfig,
}

The lifecycle is uniform across every composite:

state.update(cx, |s, _| s.open())    // user clicks trigger
   → AnimatedVisibility::show()      // target = true
   → orchestrator advances progress 0 → 1 over `enter_config.duration`
   → renderer paints enter animation (fade / slide / scale)

state.update(cx, |s, _| s.close())   // user picks / presses Escape
   → AnimatedVisibility::hide()      // target = false
   → orchestrator advances progress 1 → 0 over `exit_config.duration`
   → renderer paints exit animation

Methods on AnimatedVisibility

Method What it does
AnimatedVisibility::new() Create closed visibility with default enter / exit configs (200 ms ease-out quadratic).
AnimatedVisibility::open() Create open visibility, progress already at 1.0.
.with_config(cfg) Use cfg for both directions.
.with_enter(cfg) Set the enter config.
.with_exit(cfg) Set the exit config.
.show() Target true.
.hide() Target false.
.toggle() Flip the target.
.is_open() The desired end state.
.is_visible() target || progress > 0.0 (stays mounted during exit).
.is_animating() Still transitioning.
.phase() Enter | Open | Exit | Closed.
.update(dt) Advance progress by dt; returns true if still running.

The orchestrator in crates/yororen-ui-core/src/animation/orchestrator.rs drives update from window.request_animation_frame. The renderer reads is_visible() / progress() / phase() to paint.

Lifecycle phases

pub enum AnimationPhase {
    Enter,   // transitioning from closed to open
    Open,    // fully open and idle
    Exit,    // transitioning from open to closed
    Closed,  // fully closed and idle
}

A composite stays mounted while is_visible() is true, even during the exit phase — the renderer just paints the fading-out visual until progress reaches 0.

Composite lifecycle

Every stateful composite implements AnimatedPresenceState (so renderers can read visibility() / visibility_mut()) and owns an animation: AnimatedVisibility field. Concretely:

Composite State struct Methods
Select SelectState .open() / .close() / .toggle()
ComboBox ComboBoxState .open() / .close() / .toggle()
Modal ModalState .open() / .close() + .invoke_close(reason, …)
Popover PopoverState .open() / .close() / .toggle()
DropdownMenu DropdownMenuState .open() / .close() / .toggle()
Tooltip TooltipState .open() / .close() + .set_delay_ms(ms)
Menu MenuState (no open/close — items-only)

Select example

use yororen_ui::headless::select::{SelectOption, SelectState};

let state: gpui::Entity<SelectState> = SelectState::new(&mut *cx);
let state_for_btn = state.clone();

button("select-trigger", cx)
    .on_click(move |_ev, _w, cx| {
        state_for_btn.update(cx, |s, _cx| s.toggle());
    })
    .render(cx)
    .child("Pick…");

select("select-id", state).render(&mut *cx)

When the user clicks, state.toggle() flips both state.open and state.animation.target. The orchestrator sees the change, starts ticking, and the renderer reads state.animation.progress() each frame to paint a fade + slide.

Modal example

use yororen_ui::headless::modal::{ModalCloseReason, ModalState, modal};

let state: gpui::Entity<ModalState> = ModalState::new(&mut *cx);
let state_for_btn = state.clone();

button("open-modal", cx)
    .on_click(move |_ev, _w, cx| {
        state_for_btn.update(cx, |s, _cx| s.open());
    })
    .render(cx)
    .child("Open");

modal("settings", state.clone())
    .child(/* title */)
    .child(/* body */)
    .child(/* footer with close button */)
    .render(cx)

// In the close button:
state.update(cx, |s, _cx| s.close());

// Or, with a reason (drives on_close):
state.update(cx, |s, cx| {
    s.invoke_close(ModalCloseReason::Programmatic, window, &mut *cx);
});

&mut *cx is the in-place coercion from &mut Context<App> to &mut App (via DerefMut). It's required because invoke_close needs &mut App to schedule the animation tick.

AnimationConfig

Source: crates/yororen-ui-core/src/animation/config.rs.

pub struct AnimationConfig {
    pub duration: Duration,
    pub easing: EasingFn,    // fn(f32) -> f32
    pub repeat: bool,
}

Default: 200 ms, ease_out_quad, no repeat.

Builder methods

use std::time::Duration;
use yororen_ui::animation::{AnimationConfig, ease_out_cubic};

let cfg = AnimationConfig::new()
    .with_duration(Duration::from_millis(300))
    .with_easing(ease_out_cubic)
    .with_repeat();

from_motion(duration, easing)two arguments

The factory helper takes a duration + easing pair (NOT three arguments — it does not take a MotionTokens struct):

use std::time::Duration;
use yororen_ui::animation::{AnimationConfig, ease_out_cubic};

let cfg = AnimationConfig::from_motion(
    Duration::from_millis(200),  // <-- duration
    ease_out_cubic,              // <-- easing function
);

This is the path theme-aware renderers use — they read tokens.motion.duration_normal and tokens.motion.easing_standard from the theme and pass them through.

to_gpui_animation()

Converts the config to a gpui::Animation honoring the configured duration, easing, and repeat. Note that delay and reverse (yoyo) are intentionally not part of AnimationConfig — for those, use the orchestrator module.

Animation presets

The yororen_ui::animation::preset module provides ready-to-use animation effects. New code should reach for AnimationConfig::from_motion (which reads theme tokens) rather than the preset helpers, but the presets remain convenient for prototyping.

Function What it does
FadeIn / FadeOut Opacity 0 → 1 (or 1 → 0)
ScaleIn / ScaleOut Opacity + scale fade (opacity acts as the visible cue — gpui lacks transform())
fade_slide_in / fade_slide_out Opacity + 10-px slide — matches the menu open preset
fade_slide_in_from(direction, distance) Fade + slide from any direction
fade_slide_in_left/_right/_up/_down(distance) Direction-specific fade + slide
fade_scale_in / fade_scale_out Combined fade + scale (opacity-based)
bounce_in_left/_right/_up/_down(distance) Bounce from a direction (ease_out_bounce)
bounce_out_to(direction, distance) Bounce out to a direction
elastic_scale_in / elastic_scale_out Elastic bounce in / out
pulse(duration) Opacity pulse for loading states

SlideDirection is Left | Right | Up | Down.

use yororen_ui::animation::{
    fade_slide_in_right, SlideDirection,
};
use gpui::px;

// Use a preset directly (good for prototyping).
let preset = fade_slide_in_right(px(16.0));

Preset durations

The preset::preset_duration module exposes named durations:

Constant Value
preset_duration::VERY_FAST 100 ms
preset_duration::FAST 150 ms
preset_duration::NORMAL 200 ms
preset_duration::SLOW 300 ms
preset_duration::VERY_SLOW 400 ms
preset_duration::INSTANT 0 ms

Theme tokens (tokens.motion.duration_*) are the source of truth in real apps; the preset constants stay convenient for ad-hoc use.

Easing functions

The yororen_ui::animation::easing module exposes standard easing functions, all fn(f32) -> f32:

Family Functions
Linear ease_linear
Quadratic ease_in_quad, ease_out_quad, ease_in_out_quad
Cubic ease_in_cubic, ease_out_cubic, ease_in_out_cubic
Quartic ease_in_quart, ease_out_quart, ease_in_out_quart
Quintic ease_in_quint, ease_out_quint, ease_in_out_quint
Sine ease_in_sine, ease_out_sine, ease_in_out_sine
Exponential ease_in_expo, ease_out_expo, ease_in_out_expo
Circular ease_in_circ, ease_out_circ, ease_in_out_circ
Back (overshoot) ease_out_back, ease_in_back, ease_in_out_back + *_clamped
Elastic ease_out_elastic, ease_in_elastic, ease_in_out_elastic + *_clamped
Bounce ease_out_bounce, ease_in_bounce, ease_in_out_bounce
Basic ease_in, ease_out, ease_in_out

The *_clamped variants are pinned to [0.0, 1.0] — gpui's Animation::with_easing expects values in that range, and classic back / elastic easings overshoot by design.

How renderers read the visibility

The default + brutalism renderers both implement AnimatedPresenceState consumers — they walk the registered composite renderer, call state.read(cx).animation.is_visible(), and if false skip painting entirely. If true, they read progress() to apply the appropriate visual transformation.

Concretely, in yororen-ui-default-renderer:

1. renderer reads composite state from Entity<…State>
2. if state.animation.is_visible() is false → return empty div
3. else:
   progress = state.animation.progress()
   target   = state.animation.is_open()
   phase    = state.animation.phase()
   apply the composite-specific animation transform
   (modal: scale + fade, popover: fade + slide, etc.)
4. request the next animation frame if is_animating()

You don't have to write this logic yourself — the renderer does it for you. Just call state.update(cx, |s, _| s.open()) / close() / toggle() and the renderer handles the rest.

Manual animation (custom gpui::Element)

If you need a one-off animation that doesn't fit a composite (e.g. a custom painter, an AnimateExt micro-interaction), use gpui's with_animation directly:

use gpui::{Animation, AnimationExt};
use yororen_ui::animation::{ease_out_cubic, fade_slide_in_right};
use std::time::Duration;

div.with_animation(
    "card-enter",
    Animation::new(Duration::from_millis(200))
        .with_easing(ease_out_cubic),
    fade_slide_in_right(gpui::px(16.0)),
)

See codex-skills/yororen-ui-recipes §7 and the MaterialRippleElement in crates/yororen-ui-demos/layers_demo/src/material_button.rs for a worked example of a hand-rolled Element that uses request_animation_frame to advance its own f32 progress.

Best practices

  1. Default to AnimatedVisibility. Every composite already has it. Reach for hand-rolled animation only when the composite model doesn't fit.
  2. Use theme::tokens.motion::* for durations so theme switching retimes all your animations.
  3. Keep animations short. 100–300 ms covers almost every UI case. Modals can stretch to 350–400 ms.
  4. Pick easing by intent:
    • entrance: ease_out_*
    • exit: ease_in_*
    • equal emphasis: ease_in_out_*
    • loading / continuous: ease_linear or ease_in_out
    • attention-grabbing: ease_out_back / ease_out_elastic
  5. Clamp overshoot easings with the *_clamped variants when passing to gpui's Animation::with_easing.
  6. Don't run side effects in render. Spawning animation timers goes in event handlers, not in Render::render.

See also

  • Guide-Notification — the toast host uses with_animation for slide-in
  • Guide-Composing-UI §3 — the modal-pinned layout with AnimatedVisibility
  • codex-skills/yororen-ui-app-core §7 — AnimatedVisibility overview
  • crates/yororen-ui-core/src/animation/visibility.rs — the authoritative definition
  • crates/yororen-ui-core/src/animation/config.rs — the authoritative AnimationConfig + from_motion definition
  • crates/yororen-ui-core/src/animation/preset.rs — the preset functions and PresetAnimation / AnimationType / SlideDirection types

Clone this wiki locally