Animation and motion
Start with implicit target changes, then compose keyframes, physics, color, and GPU layers when an interaction needs more control.
Animate a target, not a timer
AnimatedOpacity and AnimatedContainer retain the currently presented value. Rebuild with a new target and Argui performs the transition, including interruption-safe retargeting and automatic reduced-motion behavior.
AnimatedOpacity::new("details", if visible { 1.0 } else { 0.0 }, panel)
.duration(Duration::from_millis(220))
.curve(curves::EASE_OUT)
.build()Use the right level of control
Use implicit widgets for ordinary state changes, Motion<T> for directly controlled values, Timeline<T> for multi-stage keyframes, and Spring or Inertia for velocity-aware movement. All four levels share one monotonic frame clock.
Explore twenty focused examples
The complete Widget Gallery groups twenty examples into implicit animation, keyframes and orchestration, physics, and composition. They start automatically, loop forever, and share one Run / Stop toggle that resumes from the paused phase.
Reference files
These are the implementation and guide files used for this chapter.
docs/ui/animation.mddocs/rendering/effects.mdcrates/argui-widget-gallery/src/pages/motion.rsImplicit transitions and six responsive motion patterns
The Rust file below is imported verbatim by this page and compiled into the WebAssembly application running underneath it.
use argui::{
animation::{Duration, curves},
core::{ColorInterpolation, Transform2D, TransformOrigin},
paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId},
runtime::{Context, LayoutSnapshot, Render},
text::TextStyle,
ui::{
AlignItems, Axes, Element, FlexWrap, JustifyContent, Overflow, ScrollConfig, Sides, length,
percent,
},
vector::VectorLibrary,
widgets::{AnimatedContainer, AnimatedOpacity, Button, WidgetTheme, default_theme},
};
const ORBIT_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="3"/>
<circle cx="12" cy="4" r="2.5" fill="currentColor"/>
</svg>"#;
const SPARK_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 1.5l2.7 7.1 7.3 2.7-7.3 2.7L12 21.5 9.3 14 2 11.3l7.3-2.7z"/>
</svg>"#;
pub struct Example {
elapsed: f32,
running: bool,
reduced_motion: bool,
compact: bool,
expanded: bool,
vectors: VectorLibrary,
orbit: VectorId,
spark: VectorId,
}
impl Default for Example {
fn default() -> Self {
let mut vectors = VectorLibrary::new();
let orbit = vectors.insert_svg(ORBIT_SVG).expect("valid orbit SVG");
let spark = vectors.insert_svg(SPARK_SVG).expect("valid spark SVG");
Self {
elapsed: 0.0,
running: true,
reduced_motion: false,
compact: false,
expanded: true,
vectors,
orbit,
spark,
}
}
}
impl Example {
fn text(
&self,
value: impl Into<String>,
size: f32,
weight: u16,
color: argui::core::Color,
) -> Element {
Element::text(value.into()).text_style(TextStyle {
color,
font_size: size,
line_height: size * 1.35,
weight,
..TextStyle::default()
})
}
fn stage(&self, art: Element, theme: &WidgetTheme) -> Element {
Element::container([art])
.width(percent(1.0))
.height(length(132.0))
.padding(Sides::length(16.0))
.align_items(AlignItems::CENTER)
.justify_content(JustifyContent::CENTER)
.background(theme.muted)
.border(Border::all(1.0, theme.border))
.radius(CornerRadii::all(12.0))
.overflow(Axes {
x: Overflow::Hidden,
y: Overflow::Hidden,
})
}
fn card(
&self,
title: &'static str,
detail: &'static str,
art: Element,
theme: &WidgetTheme,
) -> Element {
Element::column([
self.text(title, 16.0, 700, theme.foreground),
self.text(detail, 12.0, 450, theme.muted_foreground),
self.stage(art, theme),
])
.width(if self.compact {
percent(1.0)
} else {
length(330.0)
})
.grow(1.0)
.padding(Sides::length(16.0))
.gap(9.0)
.background(theme.card)
.border(Border::all(1.0, theme.border))
.radius(CornerRadii::all(14.0))
}
fn cards(&self, theme: &WidgetTheme) -> Element {
let cycle = if self.reduced_motion {
0.35
} else {
(self.elapsed / 2.0).fract()
};
let angle = cycle * std::f32::consts::TAU;
let wave = angle.sin() * 0.5 + 0.5;
let travel = angle.sin() * if self.compact { 52.0 } else { 82.0 };
let combined = Element::container([])
.width(length(58.0))
.height(length(58.0))
.background(theme.primary)
.radius(CornerRadii::all(12.0 + wave * 12.0))
.transform(
Transform2D::IDENTITY
.translate(travel, 0.0)
.rotate(angle)
.scale(0.78 + wave * 0.42, 0.78 + wave * 0.42),
)
.transform_origin(TransformOrigin::CENTER);
let aura = self
.text("Production ready", 17.0, 700, theme.foreground)
.padding(Sides::length(18.0))
.background(theme.card)
.border(Border::all(1.0 + wave * 2.0, theme.primary))
.radius(CornerRadii::all(12.0))
.layer(
LayerStyle::new(Default::default()).shadow(
Shadow::glow(
10.0 + wave * 24.0,
theme.primary.with_alpha(0.18 + wave * 0.28),
)
.spread(wave * 4.0),
),
);
let morph_color = theme
.primary
.mix(theme.destructive, wave, ColorInterpolation::Oklab);
let morph = Element::container([])
.width(length(76.0 + wave * 112.0))
.height(length(58.0 + wave * 28.0))
.background(morph_color)
.radius(CornerRadii {
top_left: 8.0 + wave * 34.0,
top_right: 38.0 - wave * 28.0,
bottom_right: 8.0 + wave * 34.0,
bottom_left: 38.0 - wave * 28.0,
});
let orbit = Element::vector(self.orbit)
.vector_color(theme.primary)
.paint_opacity(1.0 - wave)
.width(length(72.0))
.height(length(72.0))
.transform(
Transform2D::IDENTITY
.rotate(angle)
.scale(1.0 - wave * 0.28, 1.0 - wave * 0.28),
);
let spark = Element::vector(self.spark)
.vector_color(theme.destructive)
.paint_opacity(wave)
.width(length(72.0))
.height(length(72.0))
.transform(
Transform2D::IDENTITY
.rotate(-angle * 0.35)
.scale(0.72 + wave * 0.28, 0.72 + wave * 0.28),
);
let vector_transition = Element::container([
orbit.absolute(Sides::length(0.0)),
spark.absolute(Sides::length(0.0)),
])
.width(length(72.0))
.height(length(72.0));
let loader = Element::row((0..5).map(|index| {
let local =
((cycle + index as f32 * 0.14).fract() * std::f32::consts::TAU).sin() * 0.5 + 0.5;
Element::container([])
.width(length(12.0))
.height(length(28.0 + local * 48.0))
.background(
theme
.primary
.mix(theme.destructive, local, ColorInterpolation::Oklab),
)
.radius(CornerRadii::all(999.0))
}))
.height(length(84.0))
.gap(9.0)
.align_items(AlignItems::CENTER);
let pulse = 0.82 + wave * 0.18;
let notification = Element::row([
Element::container([])
.width(length(12.0))
.height(length(12.0))
.background(theme.primary)
.radius(CornerRadii::all(999.0))
.layer(LayerStyle::new(Default::default()).shadow(Shadow::glow(
6.0 + wave * 18.0,
theme.primary.with_alpha(0.34),
))),
self.text("Sync complete", 15.0, 650, theme.foreground),
])
.padding(Sides::length(16.0))
.gap(10.0)
.align_items(AlignItems::CENTER)
.background(theme.card)
.border(Border::all(1.0, theme.border))
.radius(CornerRadii::all(999.0))
.transform(Transform2D::IDENTITY.scale(pulse, pulse));
let implicit_opacity = AnimatedOpacity::new(
"docs-implicit-opacity",
if self.expanded { 1.0 } else { 0.12 },
self.text("Target-driven fade", 17.0, 700, theme.primary),
)
.duration(Duration::from_millis(260))
.curve(curves::EASE_OUT)
.build();
let implicit_container = AnimatedContainer::new("docs-implicit-container", [])
.width(length(if self.expanded { 188.0 } else { 72.0 }))
.height(length(if self.expanded { 76.0 } else { 46.0 }))
.background(if self.expanded {
theme.destructive
} else {
theme.primary
})
.radius(CornerRadii::all(if self.expanded { 30.0 } else { 10.0 }))
.duration(Duration::from_millis(420))
.curve(curves::EMPHASIZED)
.build();
Element::row([
self.card(
"AnimatedOpacity",
"A rebuild changes only the target; retained state owns the fade.",
implicit_opacity,
theme,
),
self.card(
"AnimatedContainer",
"Size, color and radius transition with one declarative policy.",
implicit_container,
theme,
),
self.card(
"Composed transform",
"Translate, rotate, scale, and radius run in one two-second loop.",
combined,
theme,
),
self.card(
"Text aura",
"A practical focus treatment combines border width and GPU glow.",
aura,
theme,
),
self.card(
"Layout morph",
"Size, asymmetric radii, and Oklab color interpolate together.",
morph,
theme,
),
self.card(
"SVG transition",
"Two retained vector assets crossfade, rotate, and scale.",
vector_transition,
theme,
),
self.card(
"Sequenced loader",
"Phase offsets create a continuous multi-element loading rhythm.",
loader,
theme,
),
self.card(
"Status pulse",
"A restrained scale and aura draw attention without moving layout.",
notification,
theme,
),
])
.width(percent(1.0))
.padding(Sides {
left: length(16.0),
right: length(16.0),
top: length(16.0),
bottom: length(28.0),
})
.gap(12.0)
.flex_wrap(FlexWrap::Wrap)
}
}
impl Render for Example {
fn wants_animation_frame(&self) -> bool {
self.running && !self.reduced_motion
}
fn animation_frame(&mut self, frame: argui::animation::Frame, cx: &mut Context<Self>) {
if self.running && !self.reduced_motion {
self.elapsed = (self.elapsed + frame.elapsed.as_secs_f64().min(0.05) as f32) % 2.0;
cx.notify();
}
}
fn render(&mut self, cx: &mut Context<Self>) -> Element {
let environment = cx.environment();
self.reduced_motion = environment.reduced_motion;
let themes = default_theme(&environment);
let theme = themes.resolve(environment.color_scheme);
let status = if self.reduced_motion {
"Reduced motion is active"
} else if self.running {
"Six loops · two implicit examples"
} else {
"Animations paused"
};
let controls = Element::row([
Element::column([
self.text("Animation lab", 21.0, 760, theme.foreground),
self.text(status, 12.0, 500, theme.muted_foreground),
])
.gap(2.0)
.grow(1.0),
Button::new(
"toggle-animation",
if self.running {
"Pause animations"
} else {
"Resume animations"
},
theme.button(),
)
.enabled(!self.reduced_motion)
.on_click(cx.callback(|app| app.running = !app.running))
.build(),
Button::new(
"toggle-implicit",
"Retarget implicit examples",
theme.outline_button(),
)
.enabled(!self.reduced_motion)
.on_click(cx.callback(|app| app.expanded = !app.expanded))
.build(),
])
.width(percent(1.0))
.padding(Sides::length(14.0))
.gap(12.0)
.flex_wrap(FlexWrap::Wrap)
.align_items(AlignItems::CENTER)
.background(theme.card)
.border(Border {
widths: argui::paint::BorderWidths {
bottom: 1.0,
..argui::paint::BorderWidths::default()
},
color: theme.border,
});
let gallery = self
.cards(theme)
.keyed("animation-gallery")
.grow(1.0)
.min_height(length(0.0))
.overflow(Axes {
x: Overflow::Hidden,
y: Overflow::Auto,
})
.scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()));
Element::column([controls, gallery])
.width(percent(1.0))
.height(percent(1.0))
.min_height(length(0.0))
.background(theme.background)
}
fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context<Self>) {
let compact = layout.viewport_size().width < 620.0;
if self.compact != compact {
self.compact = compact;
cx.notify();
}
}
fn vector_assets(&self) -> Vec<VectorAsset> {
self.vectors.assets().to_vec()
}
}
Starting this lesson’s WebAssembly module…