Spring Physics
Spring animations use physics simulation for natural, dynamic motion. Unlike duration-based transitions, springs can overshoot and bounce.
Creating Spring Transitions
extern crate guido;
use guido::prelude::*;
fn main() {
Transition::spring(SpringConfig::BOUNCY)
;
}
Built-in Configurations
DEFAULT
Balanced spring - responsive without excessive bounce, settles in ~270ms:
extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::DEFAULT
;
}
SNAPPY
Quickest response with subtle overshoot, settles in ~185ms:
extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::SNAPPY
;
}
GENTLE
Slower and smooth - minimal overshoot, settles in ~325ms:
extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::GENTLE
;
}
BOUNCY
Energetic spring - visible bounce, settles in ~410ms:
extern crate guido;
use guido::prelude::*;
fn main() {
SpringConfig::BOUNCY
;
}
When to Use Springs
Springs excel at:
- Width/height animations - Expanding cards, accordions
- Transform animations - Scale, rotation, translation
- Physical feedback - Elements that should feel tangible
extern crate guido;
use guido::prelude::*;
fn main() {
// Width expansion with spring
let expanded = create_signal(false);
container()
.width(
(move || if expanded.get() { 600.0 } else { 50.0 })
.transition(Transition::spring(SpringConfig::DEFAULT)),
)
.on_click(move || expanded.update(|e| *e = !*e))
;
}
Spring vs Duration
Use Duration For:
- Color changes
- Opacity changes
- Border changes
- Subtle state transitions
Use Springs For:
- Size changes
- Position changes
- Scale transforms
- Anything that should feel physical
Examples
Bouncy Scale
extern crate guido;
use guido::prelude::*;
fn main() {
let scale_factor = create_signal(1.0f32);
let is_scaled = create_signal(false);
container()
.scale(scale_factor.transition(Transition::spring(SpringConfig::BOUNCY)))
.on_click(move || {
is_scaled.update(|s| *s = !*s);
let target = if is_scaled.get() { 1.3 } else { 1.0 };
scale_factor.set(target);
})
;
}
Smooth Expansion
extern crate guido;
use guido::prelude::*;
fn main() {
let expanded = create_signal(false);
container()
.width(
(move || at_least(if expanded.get() { 400.0 } else { 200.0 }))
.transition(Transition::spring(SpringConfig::GENTLE)),
)
.on_click(move || expanded.update(|e| *e = !*e))
;
}
Animated Rotation
extern crate guido;
use guido::prelude::*;
fn main() {
let rotation = create_signal(0.0f32);
container()
.rotate(rotation.transition(Transition::spring(SpringConfig::DEFAULT)))
.on_click(move || rotation.update(|r| *r += 90.0))
;
}
Combining Spring and Duration
Use different transition types for different properties:
extern crate guido;
use guido::prelude::*;
fn main() {
container()
// Spring for physical properties
.rotate(0.0.transition(Transition::spring(SpringConfig::BOUNCY)))
.width(200.0.transition(Transition::spring(SpringConfig::GENTLE)))
// Duration for color properties
.background(Color::WHITE.transition(Transition::new(200.0, TimingFunction::EaseOut)))
.border(1.0, Color::WHITE.transition(Transition::new(150.0, TimingFunction::EaseOut)))
;
}
Complete Example
extern crate guido;
use guido::prelude::*;
fn spring_button() -> Container {
let pressed = create_signal(false);
container()
.padding(20.0)
// Duration for color - smooth transition
.background(Color::rgb(0.3, 0.5, 0.8).transition(200.0))
.corners(12.0)
// Spring for scale - bouncy feedback
.scale(
(move || if pressed.get() { 1.1 } else { 1.0 })
.transition(Transition::spring(SpringConfig::BOUNCY)),
)
.when_hovered(|s| s.lighter(0.1))
.on_click(move || {
pressed.set(true);
// In real app: trigger action and reset
})
.child(container().child(text("Spring!").font_size(18.0).color(Color::WHITE)))
}
fn main() {}
Interruption
A spring that is retargeted mid-flight keeps its momentum: the new segment starts with the speed the old one had, so a value sent back where it came from turns around rather than stopping first. That is what makes a spring feel physical when a hover reverses halfway, and it is also the cheapest wobble there is — send a value out and then back, and the overshoot on the way home is a shake nobody had to choreograph.
It has to be two different frames, though. A press and its release are two, so a button shakes itself:
extern crate guido;
use guido::prelude::*;
fn nudge(angle: RwSignal<f32>) -> Container {
container()
.rotate((move || angle.get()).transition(Transition::spring(SpringConfig::BOUNCY)))
.on_mouse_down(move |_, _| angle.set(2.0))
.on_mouse_up(move |_, _| angle.set(0.0))
.child(text("shake me"))
}
fn main() {}
Two writes inside one frame would not do it: the loop reads the signal once, so it only ever sees the last of them and the spring is never sent anywhere.
Timed transitions have no momentum to keep: a retarget there starts from the current value at whatever the curve says, as it always has.
What it carries, and what it does not
The momentum that crosses into the new segment is the part of the old motion pointing along it. A translation interrupted by a pure scale change carries nothing, because the two have no direction in common — momentum stays in the channel it belonged to. And a spring that has already settled is at rest, so the next animation starts from a stop however small the residue was.
API Reference
// not compiled: a signature listing — these declarations have no bodies.
/// Spring configuration presets
pub struct SpringConfig {
pub stiffness: f32,
pub damping: f32,
pub mass: f32,
}
impl SpringConfig {
pub const DEFAULT: SpringConfig;
pub const GENTLE: SpringConfig;
pub const SNAPPY: SpringConfig;
pub const BOUNCY: SpringConfig;
}
/// Create spring transition
Transition::spring(config: SpringConfig) -> Transition