-
Notifications
You must be signed in to change notification settings - Fork 9
Guide 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.
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
| 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.
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.
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) |
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.
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.
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.
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();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.
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.
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));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.
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.
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.
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.
-
Default to
AnimatedVisibility. Every composite already has it. Reach for hand-rolled animation only when the composite model doesn't fit. -
Use
theme::tokens.motion::*for durations so theme switching retimes all your animations. - Keep animations short. 100–300 ms covers almost every UI case. Modals can stretch to 350–400 ms.
-
Pick easing by intent:
- entrance:
ease_out_* - exit:
ease_in_* - equal emphasis:
ease_in_out_* - loading / continuous:
ease_linearorease_in_out - attention-grabbing:
ease_out_back/ease_out_elastic
- entrance:
-
Clamp overshoot easings with the
*_clampedvariants when passing to gpui'sAnimation::with_easing. -
Don't run side effects in render. Spawning animation
timers goes in event handlers, not in
Render::render.
-
Guide-Notification — the toast host
uses
with_animationfor slide-in -
Guide-Composing-UI §3 — the
modal-pinned layout with
AnimatedVisibility -
codex-skills/yororen-ui-app-core§7 —AnimatedVisibilityoverview -
crates/yororen-ui-core/src/animation/visibility.rs— the authoritative definition -
crates/yororen-ui-core/src/animation/config.rs— the authoritativeAnimationConfig+from_motiondefinition -
crates/yororen-ui-core/src/animation/preset.rs— the preset functions andPresetAnimation/AnimationType/SlideDirectiontypes
Yororen UI v0.3.0 · repository · Apache-2.0 · This wiki documents Yororen UI v0.3.0.
This wiki documents Yororen UI v0.3.0 — the headless-core, swappable-renderer build.