diff --git a/README.md b/README.md
index 40f920bf..5c1efdda 100644
--- a/README.md
+++ b/README.md
@@ -916,12 +916,32 @@ type. Element-owned painting remains outside that surface: for example, a
container, but it does not independently interpolate syntax tokens, diff rows,
or markdown runs.
-`durationMs` is required and uses milliseconds; `delayMs` defaults to `0`.
-`easing` accepts `linear`, `ease`, `easeIn`, `easeOut`, `easeInOut`, or a
-four-number cubic-bezier tuple. Pixel lengths interpolate with pixels and
-percentages with percentages. Incompatible endpoints such as `auto` to pixels
-snap to the new value. Malformed transition objects are rejected as a whole
-through the strict style-diagnostic channel.
+For a tween, `durationMs` is required and uses milliseconds; `delayMs` defaults
+to `0`. `easing` accepts `linear`, `ease`, `easeIn`, `easeOut`, `easeInOut`, or
+a four-number cubic-bezier tuple. A spring replaces that fixed-duration easing
+with the same object used by `motion.div`:
+
+```tsx
+transition: {
+ properties: ['width', 'opacity'],
+ delayMs: 40,
+ easing: { type: 'spring', stiffness: 400, damping: 28, mass: 0.9 },
+}
+```
+
+Spring defaults are `stiffness: 100`, `damping: 10`, `mass: 1`, and
+`velocity: 0`. `durationMs` may be omitted and is ignored when supplied; the
+native spring runs until every channel settles. Delay still applies. Pixel
+channels settle within `0.05px`, while opacity, percentage, and colour channels
+use a proportionally smaller threshold so the exact final value does not make a
+visible end-snap. An interrupted spring retargets from the painted value and
+carries its current channel velocity into the new trajectory.
+
+Pixel lengths interpolate with pixels and percentages with percentages.
+Incompatible endpoints such as `auto` to pixels snap to the new value.
+Malformed transition objects, including spring objects with unknown `type`
+values or invalid physical parameters, are rejected as a whole through the
+strict style-diagnostic channel.
Radius shorthand and corner longhands are resolved to four painted corners
before interpolation, so either form can override the other without a stale
@@ -961,6 +981,23 @@ function WelcomeCard() {
}
```
+Use the same spring easing object for a physics-driven target:
+
+```tsx
+
+```
+
+The spring is integrated inside the existing native motion track. React sends
+only the target; there is no JavaScript frame loop. Retargeting while it is
+moving keeps both the visible position and instantaneous velocity.
+
Set **`initial={false}`** when the element must mount at its first `animate`
target. Later `animate` changes still transition normally. If a target changes
while motion is active, the next transition starts from the current visible
@@ -983,10 +1020,32 @@ The **transition** uses seconds, like Motion for React:
|---|---:|---|
| `duration` | `0.3` | Non-negative seconds |
| `delay` | `0` | Non-negative seconds |
-| `ease` | `"easeOut"` | `"linear"`, `"ease"`, `"easeIn"`, `"easeOut"`, `"easeInOut"`, or `[x1, y1, x2, y2]` |
+| `ease` | `"easeOut"` | `"linear"`, `"ease"`, `"easeIn"`, `"easeOut"`, `"easeInOut"`, `[x1, y1, x2, y2]`, or a spring object |
+
+`duration` is ignored when `ease.type` is `"spring"`; settling derives the end
+time. Keyframes, variants, exit transitions, and shared layout animations are
+not available yet.
+
+### Browser mirror: sampled springs with CSS `linear()`
+
+The web-platform encoding of a sampled spring is the CSS `linear()` easing
+function. A browser mirror samples the same normalized spring trajectory from
+`0` until its channel-aware settling time, keeps overshoot samples above `1` or
+below `0`, and emits them as linear stops:
+
+```css
+.card {
+ transition-property: width, opacity;
+ transition-duration: var(--derived-spring-settling-time);
+ transition-delay: 40ms;
+ transition-timing-function: linear(0, 0.057, 0.198, 0.58, 0.994, 1.126, 1.075, 1);
+}
+```
-Springs, keyframes, variants, exit transitions, and shared layout animations
-are not available yet.
+The generated CSS duration is the sampling horizon, not the ignored author
+`duration` / `durationMs`. A browser implementation that retargets a sampled
+spring must regenerate the samples from the computed value and current
+velocity to match the native interruption semantics.
### Animate a sidebar
diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs
index 588f6427..d239a80b 100644
--- a/packages/native/src/motion.rs
+++ b/packages/native/src/motion.rs
@@ -6,7 +6,7 @@ use serde::Deserialize;
use web_time::Instant;
use crate::style::{
- DimensionValue, StyleDesc, StyleTransition, TransitionEasing, TransitionProperty,
+ DimensionValue, SpringEasing, StyleDesc, StyleTransition, TransitionEasing, TransitionProperty,
};
#[derive(Clone, Debug, PartialEq)]
@@ -16,6 +16,13 @@ enum TransitionValue {
Color([f32; 4]),
}
+#[derive(Clone, Debug, PartialEq)]
+enum TransitionVelocity {
+ Number(f64),
+ Dimension(f64),
+ Color([f64; 4]),
+}
+
impl TransitionValue {
fn from_style(style: &StyleDesc, property: TransitionProperty) -> Option {
use TransitionProperty::*;
@@ -135,11 +142,170 @@ impl TransitionValue {
_ => {}
}
}
+
+ fn initial_spring_velocity_to(
+ &self,
+ target: &Self,
+ velocity: f64,
+ ) -> Option {
+ match (self, target) {
+ (Self::Number(from), Self::Number(to)) if from != to => {
+ Some(TransitionVelocity::Number(velocity))
+ }
+ (
+ Self::Dimension(DimensionValue::Pixels(from)),
+ Self::Dimension(DimensionValue::Pixels(to)),
+ )
+ | (
+ Self::Dimension(DimensionValue::Percentage(from)),
+ Self::Dimension(DimensionValue::Percentage(to)),
+ ) if from != to => Some(TransitionVelocity::Dimension(velocity)),
+ (Self::Color(from), Self::Color(to)) if from != to => {
+ Some(TransitionVelocity::Color([velocity; 4]))
+ }
+ _ => None,
+ }
+ }
+
+ fn accepts_spring_velocity(&self, target: &Self, velocity: &TransitionVelocity) -> bool {
+ matches!(
+ (self, target, velocity),
+ (
+ Self::Number(_),
+ Self::Number(_),
+ TransitionVelocity::Number(_)
+ ) | (
+ Self::Dimension(DimensionValue::Pixels(_)),
+ Self::Dimension(DimensionValue::Pixels(_)),
+ TransitionVelocity::Dimension(_)
+ ) | (
+ Self::Dimension(DimensionValue::Percentage(_)),
+ Self::Dimension(DimensionValue::Percentage(_)),
+ TransitionVelocity::Dimension(_)
+ ) | (Self::Color(_), Self::Color(_), TransitionVelocity::Color(_))
+ )
+ }
+
+ fn spring_to(
+ &self,
+ target: &Self,
+ velocity: Option<&TransitionVelocity>,
+ elapsed: f64,
+ spring: &SpringEasing,
+ property: TransitionProperty,
+ ) -> (Self, Option, bool) {
+ match (self, target) {
+ (Self::Number(from), Self::Number(to)) => {
+ let velocity = match velocity {
+ Some(TransitionVelocity::Number(velocity)) => *velocity,
+ _ => 0.0,
+ };
+ let epsilon = if property == TransitionProperty::Opacity {
+ 0.000_5
+ } else {
+ 0.05
+ };
+ let sample = sample_spring(*from, *to, velocity, elapsed, spring, epsilon);
+ (
+ Self::Number(sample.position),
+ Some(TransitionVelocity::Number(sample.velocity)),
+ sample.active,
+ )
+ }
+ (
+ Self::Dimension(DimensionValue::Pixels(from)),
+ Self::Dimension(DimensionValue::Pixels(to)),
+ ) => {
+ let velocity = match velocity {
+ Some(TransitionVelocity::Dimension(velocity)) => *velocity,
+ _ => 0.0,
+ };
+ let sample = sample_spring(*from, *to, velocity, elapsed, spring, 0.05);
+ (
+ Self::Dimension(DimensionValue::Pixels(sample.position)),
+ Some(TransitionVelocity::Dimension(sample.velocity)),
+ sample.active,
+ )
+ }
+ (
+ Self::Dimension(DimensionValue::Percentage(from)),
+ Self::Dimension(DimensionValue::Percentage(to)),
+ ) => {
+ let velocity = match velocity {
+ Some(TransitionVelocity::Dimension(velocity)) => *velocity,
+ _ => 0.0,
+ };
+ let sample = sample_spring(*from, *to, velocity, elapsed, spring, 0.000_5);
+ (
+ Self::Dimension(DimensionValue::Percentage(sample.position)),
+ Some(TransitionVelocity::Dimension(sample.velocity)),
+ sample.active,
+ )
+ }
+ (Self::Color(from), Self::Color(to)) => {
+ let from = premultiply(*from);
+ let to = premultiply(*to);
+ let velocity = match velocity {
+ Some(TransitionVelocity::Color(velocity)) => *velocity,
+ _ => [0.0; 4],
+ };
+ let mut sampled = [0.0; 4];
+ let mut sampled_velocity = [0.0; 4];
+ let mut active = false;
+ for channel in 0..4 {
+ let sample = sample_spring(
+ from[channel],
+ to[channel],
+ velocity[channel],
+ elapsed,
+ spring,
+ 0.000_5,
+ );
+ sampled[channel] = sample.position;
+ sampled_velocity[channel] = sample.velocity;
+ active |= sample.active;
+ }
+ (
+ Self::Color(unpremultiply(sampled, to)),
+ Some(TransitionVelocity::Color(sampled_velocity)),
+ active,
+ )
+ }
+ _ => (target.clone(), None, false),
+ }
+ }
+}
+
+fn premultiply(color: [f32; 4]) -> [f64; 4] {
+ let alpha = f64::from(color[3]);
+ [
+ f64::from(color[0]) * alpha,
+ f64::from(color[1]) * alpha,
+ f64::from(color[2]) * alpha,
+ alpha,
+ ]
+}
+
+fn unpremultiply(color: [f64; 4], target: [f64; 4]) -> [f32; 4] {
+ let alpha = color[3];
+ let channel = |index: usize| {
+ if alpha.abs() > f64::from(f32::EPSILON) {
+ (color[index] / alpha) as f32
+ } else if target[3].abs() > f64::from(f32::EPSILON) {
+ (target[index] / target[3]) as f32
+ } else {
+ 0.0
+ }
+ };
+ [channel(0), channel(1), channel(2), alpha as f32]
}
#[derive(Clone, Debug, PartialEq)]
struct TransitionValues(Vec<(TransitionProperty, Option)>);
+#[derive(Clone, Debug, Default, PartialEq)]
+struct TransitionVelocities(Vec<(TransitionProperty, Option)>);
+
impl TransitionValues {
fn from_style(style: &StyleDesc, transition: &StyleTransition) -> Self {
let properties = canonical_transition_properties(transition);
@@ -189,6 +355,101 @@ impl TransitionValues {
}
}
}
+
+ fn spring_velocities(
+ &self,
+ target: &Self,
+ previous: &TransitionVelocities,
+ carry_previous: bool,
+ initial_velocity: f64,
+ ) -> TransitionVelocities {
+ TransitionVelocities(
+ target
+ .0
+ .iter()
+ .map(|(property, target)| {
+ let from = self
+ .0
+ .iter()
+ .find(|(candidate, _)| candidate == property)
+ .and_then(|(_, value)| value.as_ref());
+ let target = target.as_ref();
+ let carried = carry_previous
+ .then(|| previous.get(*property))
+ .flatten()
+ .filter(|velocity| {
+ from.zip(target).is_some_and(|(from, target)| {
+ from.accepts_spring_velocity(target, velocity)
+ })
+ })
+ .cloned();
+ let velocity = carried.or_else(|| {
+ from.zip(target).and_then(|(from, target)| {
+ from.initial_spring_velocity_to(target, initial_velocity)
+ })
+ });
+ (*property, velocity)
+ })
+ .collect(),
+ )
+ }
+
+ fn spring_sample(
+ &self,
+ target: &Self,
+ velocities: &TransitionVelocities,
+ elapsed: f64,
+ spring: &SpringEasing,
+ ) -> (Self, TransitionVelocities, bool) {
+ let mut active = false;
+ let mut sampled_velocities = Vec::with_capacity(target.0.len());
+ let values = target
+ .0
+ .iter()
+ .map(|(property, target)| {
+ let from = self
+ .0
+ .iter()
+ .find(|(candidate, _)| candidate == property)
+ .and_then(|(_, value)| value.as_ref());
+ let velocity = velocities.get(*property);
+ let (value, velocity, value_active) = match (from, target.as_ref()) {
+ (Some(from), Some(target)) => {
+ let (value, velocity, active) =
+ from.spring_to(target, velocity, elapsed, spring, *property);
+ (Some(value), velocity, active)
+ }
+ (_, target) => (target.cloned(), None, false),
+ };
+ active |= value_active;
+ sampled_velocities.push((*property, velocity));
+ (*property, value)
+ })
+ .collect();
+ (
+ Self(values),
+ TransitionVelocities(sampled_velocities),
+ active,
+ )
+ }
+}
+
+impl TransitionVelocities {
+ fn get(&self, property: TransitionProperty) -> Option<&TransitionVelocity> {
+ self.0
+ .iter()
+ .find(|(candidate, _)| *candidate == property)
+ .and_then(|(_, velocity)| velocity.as_ref())
+ }
+
+ fn is_zero(&self) -> bool {
+ self.0.iter().all(|(_, velocity)| match velocity {
+ None => true,
+ Some(TransitionVelocity::Number(value))
+ | Some(TransitionVelocity::Dimension(value)) => *value == 0.0,
+ Some(TransitionVelocity::Color(values)) => values.iter().all(|value| *value == 0.0),
+ })
+ }
}
#[derive(Clone, Copy, Debug, Default)]
@@ -205,6 +466,7 @@ pub(crate) struct StyleTransitionFrame {
pub(crate) struct StyleTransitionState {
from: TransitionValues,
target: TransitionValues,
+ velocities: TransitionVelocities,
target_style: StyleDesc,
transition: StyleTransition,
started: Instant,
@@ -228,6 +490,7 @@ impl StyleTransitionState {
Self {
from: target.clone(),
target,
+ velocities: TransitionVelocities::default(),
target_style,
transition,
started: now,
@@ -256,7 +519,10 @@ impl StyleTransitionState {
// Resolve the whole painted style before adopting the new property
// list. A property added to that list must start at the value it
// was already painting, not at its new target.
- let visible_style = self.frame(now, false).style;
+ let (visible_frame, previous_velocities) = self.frame_with_velocities(now, false);
+ let carry_velocity = matches!(self.transition.easing, TransitionEasing::Spring(_))
+ && visible_frame.active;
+ let visible_style = visible_frame.style;
let visible_style = resolve_transition_properties(
&visible_style,
state,
@@ -267,6 +533,15 @@ impl StyleTransitionState {
);
let visible = TransitionValues::from_style(&visible_style, &transition);
self.from = visible.interpolate(&target, 0.0);
+ self.velocities = match &transition.easing {
+ TransitionEasing::Spring(spring) => self.from.spring_velocities(
+ &target,
+ &previous_velocities,
+ carry_velocity,
+ spring.velocity,
+ ),
+ _ => TransitionVelocities::default(),
+ };
self.target = target;
self.started = now;
}
@@ -274,20 +549,60 @@ impl StyleTransitionState {
self.transition = transition;
if reduce_motion {
self.from = self.target.clone();
+ self.velocities = TransitionVelocities::default();
}
}
pub(crate) fn frame(&self, now: Instant, reduce_motion: bool) -> StyleTransitionFrame {
- if reduce_motion || self.from == self.target {
- return StyleTransitionFrame {
- style: self.target_style.clone(),
- active: false,
- };
+ self.frame_with_velocities(now, reduce_motion).0
+ }
+
+ fn frame_with_velocities(
+ &self,
+ now: Instant,
+ reduce_motion: bool,
+ ) -> (StyleTransitionFrame, TransitionVelocities) {
+ if reduce_motion || (self.from == self.target && self.velocities.is_zero()) {
+ return (
+ StyleTransitionFrame {
+ style: self.target_style.clone(),
+ active: false,
+ },
+ TransitionVelocities::default(),
+ );
}
let delay = milliseconds(self.transition.delay_ms);
- let duration = milliseconds(self.transition.duration_ms);
let elapsed = now.saturating_duration_since(self.started);
+ if let TransitionEasing::Spring(spring) = &self.transition.easing {
+ let spring_elapsed = elapsed
+ .checked_sub(delay)
+ .unwrap_or(Duration::ZERO)
+ .as_secs_f64();
+ let (values, velocities, active) =
+ self.from
+ .spring_sample(&self.target, &self.velocities, spring_elapsed, spring);
+ if !active {
+ return (
+ StyleTransitionFrame {
+ style: self.target_style.clone(),
+ active: false,
+ },
+ velocities,
+ );
+ }
+ let mut style = self.target_style.clone();
+ values.apply_to(&mut style);
+ return (
+ StyleTransitionFrame {
+ style,
+ active: true,
+ },
+ velocities,
+ );
+ }
+
+ let duration = milliseconds(self.transition.duration_ms);
let raw = if elapsed < delay {
0.0
} else if duration.is_zero() {
@@ -296,10 +611,13 @@ impl StyleTransitionState {
elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64()
};
if raw >= 1.0 {
- return StyleTransitionFrame {
- style: self.target_style.clone(),
- active: false,
- };
+ return (
+ StyleTransitionFrame {
+ style: self.target_style.clone(),
+ active: false,
+ },
+ TransitionVelocities::default(),
+ );
}
let mut style = self.target_style.clone();
@@ -309,10 +627,13 @@ impl StyleTransitionState {
transition_ease(raw.clamp(0.0, 1.0), &self.transition.easing),
)
.apply_to(&mut style);
- StyleTransitionFrame {
- style,
- active: true,
- }
+ (
+ StyleTransitionFrame {
+ style,
+ active: true,
+ },
+ TransitionVelocities::default(),
+ )
}
pub(crate) fn set_hovered(&mut self, hovered: bool) -> bool {
@@ -506,6 +827,9 @@ fn clear_transition_property(style: &mut StyleDesc, property: TransitionProperty
fn transition_ease(progress: f64, easing: &TransitionEasing) -> f64 {
let curve = match easing {
TransitionEasing::CubicBezier(curve) => *curve,
+ TransitionEasing::Spring(_) => {
+ unreachable!("spring easings are sampled by the native spring track")
+ }
TransitionEasing::Name(name) => match name.as_str() {
"linear" => return progress,
"easeIn" => [0.42, 0.0, 1.0, 1.0],
@@ -517,6 +841,61 @@ fn transition_ease(progress: f64, easing: &TransitionEasing) -> f64 {
cubic_bezier(progress, curve)
}
+#[derive(Clone, Copy, Debug)]
+struct SpringSample {
+ position: f64,
+ velocity: f64,
+ active: bool,
+}
+
+fn sample_spring(
+ from: f64,
+ target: f64,
+ velocity: f64,
+ elapsed: f64,
+ spring: &SpringEasing,
+ epsilon: f64,
+) -> SpringSample {
+ let displacement = from - target;
+ let natural_frequency = (spring.stiffness / spring.mass).sqrt();
+ let damping_ratio = spring.damping / (2.0 * (spring.stiffness * spring.mass).sqrt());
+ let (offset, velocity) = if damping_ratio < 1.0 - 1e-7 {
+ let damped_frequency = natural_frequency * (1.0 - damping_ratio.powi(2)).sqrt();
+ let decay = (-damping_ratio * natural_frequency * elapsed).exp();
+ let a = displacement;
+ let b = (velocity + damping_ratio * natural_frequency * displacement) / damped_frequency;
+ let sin = (damped_frequency * elapsed).sin();
+ let cos = (damped_frequency * elapsed).cos();
+ let wave = a * cos + b * sin;
+ let wave_velocity = -a * damped_frequency * sin + b * damped_frequency * cos;
+ (
+ decay * wave,
+ decay * (wave_velocity - damping_ratio * natural_frequency * wave),
+ )
+ } else if damping_ratio <= 1.0 + 1e-7 {
+ let decay = (-natural_frequency * elapsed).exp();
+ let a = displacement;
+ let b = velocity + natural_frequency * displacement;
+ let wave = a + b * elapsed;
+ (decay * wave, decay * (b - natural_frequency * wave))
+ } else {
+ let root = (damping_ratio.powi(2) - 1.0).sqrt();
+ let slow = -natural_frequency * (damping_ratio - root);
+ let fast = -natural_frequency * (damping_ratio + root);
+ let slow_amplitude = (velocity - fast * displacement) / (slow - fast);
+ let fast_amplitude = displacement - slow_amplitude;
+ let slow_wave = slow_amplitude * (slow * elapsed).exp();
+ let fast_wave = fast_amplitude * (fast * elapsed).exp();
+ (slow_wave + fast_wave, slow * slow_wave + fast * fast_wave)
+ };
+ let active = offset.abs() > epsilon || velocity.abs() > epsilon;
+ SpringSample {
+ position: if active { target + offset } else { target },
+ velocity: if active { velocity } else { 0.0 },
+ active,
+ }
+}
+
fn milliseconds(value: f64) -> Duration {
Duration::try_from_secs_f64(value / 1000.0)
.expect("style transition durations are validated when parsed")
@@ -579,6 +958,135 @@ impl MotionStyle {
style.border_radius = Some(value);
}
}
+
+ fn spring_sample(
+ self,
+ target: Self,
+ velocities: MotionVelocity,
+ elapsed: f64,
+ spring: &SpringEasing,
+ ) -> (Self, MotionVelocity, bool) {
+ let mut active = false;
+ let mut channel =
+ |from: Option, target: Option, velocity: Option, epsilon: f64| {
+ let Some(target) = target else {
+ return (None, None);
+ };
+ let sample = sample_spring(
+ from.unwrap_or(target),
+ target,
+ velocity.unwrap_or(0.0),
+ elapsed,
+ spring,
+ epsilon,
+ );
+ active |= sample.active;
+ (Some(sample.position), Some(sample.velocity))
+ };
+ let (width, width_velocity) = channel(self.width, target.width, velocities.width, 0.05);
+ let (height, height_velocity) =
+ channel(self.height, target.height, velocities.height, 0.05);
+ let (opacity, opacity_velocity) =
+ channel(self.opacity, target.opacity, velocities.opacity, 0.000_5);
+ let (top, top_velocity) = channel(self.top, target.top, velocities.top, 0.05);
+ let (right, right_velocity) = channel(self.right, target.right, velocities.right, 0.05);
+ let (bottom, bottom_velocity) =
+ channel(self.bottom, target.bottom, velocities.bottom, 0.05);
+ let (left, left_velocity) = channel(self.left, target.left, velocities.left, 0.05);
+ let (border_radius, border_radius_velocity) = channel(
+ self.border_radius,
+ target.border_radius,
+ velocities.border_radius,
+ 0.05,
+ );
+ (
+ Self {
+ width,
+ height,
+ opacity,
+ top,
+ right,
+ bottom,
+ left,
+ border_radius,
+ },
+ MotionVelocity {
+ width: width_velocity,
+ height: height_velocity,
+ opacity: opacity_velocity,
+ top: top_velocity,
+ right: right_velocity,
+ bottom: bottom_velocity,
+ left: left_velocity,
+ border_radius: border_radius_velocity,
+ },
+ active,
+ )
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+struct MotionVelocity {
+ width: Option,
+ height: Option,
+ opacity: Option,
+ top: Option,
+ right: Option,
+ bottom: Option,
+ left: Option,
+ border_radius: Option,
+}
+
+impl MotionVelocity {
+ fn retarget(
+ from: MotionStyle,
+ target: MotionStyle,
+ previous: Self,
+ carry_previous: bool,
+ initial_velocity: f64,
+ ) -> Self {
+ let channel = |from: Option, target: Option, previous: Option| {
+ target.and_then(|target| {
+ let from = from.unwrap_or(target);
+ if carry_previous && previous.is_some() {
+ previous
+ } else if from != target {
+ Some(initial_velocity)
+ } else {
+ None
+ }
+ })
+ };
+ Self {
+ width: channel(from.width, target.width, previous.width),
+ height: channel(from.height, target.height, previous.height),
+ opacity: channel(from.opacity, target.opacity, previous.opacity),
+ top: channel(from.top, target.top, previous.top),
+ right: channel(from.right, target.right, previous.right),
+ bottom: channel(from.bottom, target.bottom, previous.bottom),
+ left: channel(from.left, target.left, previous.left),
+ border_radius: channel(
+ from.border_radius,
+ target.border_radius,
+ previous.border_radius,
+ ),
+ }
+ }
+
+ fn is_zero(self) -> bool {
+ [
+ self.width,
+ self.height,
+ self.opacity,
+ self.top,
+ self.right,
+ self.bottom,
+ self.left,
+ self.border_radius,
+ ]
+ .into_iter()
+ .all(|velocity| velocity.is_none_or(|velocity| velocity == 0.0))
+ }
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -588,13 +1096,6 @@ enum MotionInitial {
Style(MotionStyle),
}
-#[derive(Clone, Debug, Deserialize, PartialEq)]
-#[serde(untagged)]
-enum MotionEase {
- Name(String),
- CubicBezier([f64; 4]),
-}
-
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct MotionTransition {
@@ -603,7 +1104,7 @@ struct MotionTransition {
#[serde(default)]
delay: f64,
#[serde(default = "default_ease")]
- ease: MotionEase,
+ ease: TransitionEasing,
}
impl Default for MotionTransition {
@@ -620,8 +1121,8 @@ fn default_duration() -> f64 {
0.3
}
-fn default_ease() -> MotionEase {
- MotionEase::Name("easeOut".to_string())
+fn default_ease() -> TransitionEasing {
+ TransitionEasing::Name("easeOut".to_string())
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -643,6 +1144,7 @@ pub(crate) struct MotionState {
source: serde_json::Value,
from: MotionStyle,
target: MotionStyle,
+ velocity: MotionVelocity,
transition: MotionTransition,
started: Instant,
valid: bool,
@@ -656,11 +1158,22 @@ impl MotionState {
Some(MotionInitial::Disabled(false)) | None => description.animate,
Some(MotionInitial::Disabled(true)) => unreachable!("validated above"),
};
+ let velocity = match &description.transition.ease {
+ TransitionEasing::Spring(spring) => MotionVelocity::retarget(
+ from,
+ description.animate,
+ MotionVelocity::default(),
+ false,
+ spring.velocity,
+ ),
+ _ => MotionVelocity::default(),
+ };
Ok(Self {
source: source.clone(),
from,
target: description.animate,
+ velocity,
transition: description.transition,
started: now,
valid: true,
@@ -672,6 +1185,7 @@ impl MotionState {
source: source.clone(),
from: MotionStyle::default(),
target: MotionStyle::default(),
+ velocity: MotionVelocity::default(),
transition: MotionTransition::default(),
started: now,
valid: false,
@@ -691,6 +1205,7 @@ impl MotionState {
if self.source == *source {
if reduce_motion {
self.from = self.target;
+ self.velocity = MotionVelocity::default();
}
return Ok(());
}
@@ -703,37 +1218,87 @@ impl MotionState {
return Err(error);
}
};
- self.from = if self.valid {
- self.frame(now, reduce_motion).style
+ let previous_was_spring = matches!(self.transition.ease, TransitionEasing::Spring(_));
+ let (previous_frame, previous_velocity) = if self.valid {
+ self.frame_with_velocity(now, reduce_motion)
} else {
- match description.initial {
- Some(MotionInitial::Style(style)) => style,
- Some(MotionInitial::Disabled(false)) | None => description.animate,
- Some(MotionInitial::Disabled(true)) => unreachable!("validated above"),
- }
+ (
+ MotionFrame {
+ style: match description.initial {
+ Some(MotionInitial::Style(style)) => style,
+ Some(MotionInitial::Disabled(false)) | None => description.animate,
+ Some(MotionInitial::Disabled(true)) => unreachable!("validated above"),
+ },
+ active: false,
+ },
+ MotionVelocity::default(),
+ )
};
+ self.from = previous_frame.style;
self.target = description.animate;
+ self.velocity = match &description.transition.ease {
+ TransitionEasing::Spring(spring) => MotionVelocity::retarget(
+ self.from,
+ self.target,
+ previous_velocity,
+ previous_was_spring && previous_frame.active,
+ spring.velocity,
+ ),
+ _ => MotionVelocity::default(),
+ };
self.transition = description.transition;
self.started = now;
self.source = source.clone();
self.valid = true;
if reduce_motion {
self.from = self.target;
+ self.velocity = MotionVelocity::default();
}
Ok(())
}
pub(crate) fn frame(&self, now: Instant, reduce_motion: bool) -> MotionFrame {
+ self.frame_with_velocity(now, reduce_motion).0
+ }
+
+ fn frame_with_velocity(
+ &self,
+ now: Instant,
+ reduce_motion: bool,
+ ) -> (MotionFrame, MotionVelocity) {
if reduce_motion {
- return MotionFrame {
- style: self.target,
- active: false,
- };
+ return (
+ MotionFrame {
+ style: self.target,
+ active: false,
+ },
+ MotionVelocity::default(),
+ );
}
let delay = seconds(self.transition.delay);
- let duration = seconds(self.transition.duration);
let elapsed = now.saturating_duration_since(self.started);
+ if let TransitionEasing::Spring(spring) = &self.transition.ease {
+ if self.from == self.target && self.velocity.is_zero() {
+ return (
+ MotionFrame {
+ style: self.target,
+ active: false,
+ },
+ MotionVelocity::default(),
+ );
+ }
+ let spring_elapsed = elapsed
+ .checked_sub(delay)
+ .unwrap_or(Duration::ZERO)
+ .as_secs_f64();
+ let (style, velocity, active) =
+ self.from
+ .spring_sample(self.target, self.velocity, spring_elapsed, spring);
+ return (MotionFrame { style, active }, velocity);
+ }
+
+ let duration = seconds(self.transition.duration);
let raw = if elapsed <= delay {
0.0
} else if duration.is_zero() {
@@ -742,12 +1307,15 @@ impl MotionState {
elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64()
};
let active = self.from != self.target && raw < 1.0;
- let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease);
+ let progress = transition_ease(raw.clamp(0.0, 1.0), &self.transition.ease);
- MotionFrame {
- style: self.from.interpolate(self.target, progress),
- active,
- }
+ (
+ MotionFrame {
+ style: self.from.interpolate(self.target, progress),
+ active,
+ },
+ MotionVelocity::default(),
+ )
}
}
@@ -762,9 +1330,11 @@ fn parse_description(source: &serde_json::Value) -> Result Result<(), String> {
Ok(())
}
-fn validate_ease(ease: &MotionEase) -> Result<(), String> {
+fn validate_ease(ease: &TransitionEasing) -> Result<(), String> {
match ease {
- MotionEase::Name(name)
+ TransitionEasing::Name(name)
if matches!(
name.as_str(),
"linear" | "ease" | "easeIn" | "easeOut" | "easeInOut"
) => {}
- MotionEase::Name(name) => return Err(format!("unknown motion easing: {name}")),
- MotionEase::CubicBezier([x1, y1, x2, y2]) => {
+ TransitionEasing::Name(name) => return Err(format!("unknown motion easing: {name}")),
+ TransitionEasing::CubicBezier([x1, y1, x2, y2]) => {
if ![x1, y1, x2, y2].iter().all(|value| value.is_finite())
|| !(0.0..=1.0).contains(x1)
|| !(0.0..=1.0).contains(x2)
@@ -826,6 +1396,13 @@ fn validate_ease(ease: &MotionEase) -> Result<(), String> {
);
}
}
+ TransitionEasing::Spring(spring) if spring.is_valid() => {}
+ TransitionEasing::Spring(_) => {
+ return Err(
+ "motion spring stiffness, damping, and mass must be positive finite 32-bit numbers and velocity must be finite"
+ .to_string(),
+ );
+ }
}
Ok(())
}
@@ -834,20 +1411,6 @@ fn seconds(value: f64) -> Duration {
Duration::try_from_secs_f64(value).expect("motion durations are validated when parsed")
}
-fn ease(progress: f64, ease: &MotionEase) -> f64 {
- let curve = match ease {
- MotionEase::CubicBezier(curve) => *curve,
- MotionEase::Name(name) => match name.as_str() {
- "linear" => return progress,
- "easeIn" => [0.42, 0.0, 1.0, 1.0],
- "easeInOut" => [0.42, 0.0, 0.58, 1.0],
- "ease" => [0.25, 0.1, 0.25, 1.0],
- _ => [0.0, 0.0, 0.58, 1.0],
- },
- };
- cubic_bezier(progress, curve)
-}
-
fn cubic_bezier(x: f64, [x1, y1, x2, y2]: [f64; 4]) -> f64 {
fn sample(t: f64, a: f64, b: f64) -> f64 {
let c = 3.0 * a;
@@ -1203,6 +1766,166 @@ mod tests {
);
}
+ #[test]
+ fn style_spring_retarget_carries_the_visible_velocity() {
+ let started = Instant::now();
+ let initial = style(serde_json::json!({
+ "width": 100,
+ "hover": { "width": 200 },
+ "transition": {
+ "properties": ["width"],
+ "easing": { "type": "spring" }
+ }
+ }));
+ let mut carried =
+ StyleTransitionState::new(&initial, StyleState::default(), false, started);
+ carried.set_hovered(true);
+ carried.sync(&initial, StyleState::default(), false, started, false);
+
+ let retargeted_at = started + Duration::from_millis(100);
+ let visible = match carried.frame(retargeted_at, false).style.width {
+ Some(DimensionValue::Pixels(width)) => width,
+ width => panic!("expected a visible pixel width, got {width:?}"),
+ };
+ let reversed = style(serde_json::json!({
+ "width": 100,
+ "hover": { "width": 50 },
+ "transition": {
+ "properties": ["width"],
+ "easing": { "type": "spring" }
+ }
+ }));
+ carried.sync(
+ &reversed,
+ StyleState::default(),
+ false,
+ retargeted_at,
+ false,
+ );
+
+ let zero_restart_style = style(serde_json::json!({
+ "width": visible,
+ "hover": { "width": 50 },
+ "transition": {
+ "properties": ["width"],
+ "easing": { "type": "spring", "velocity": 0 }
+ }
+ }));
+ let mut zero_restart = StyleTransitionState::new(
+ &zero_restart_style,
+ StyleState::default(),
+ false,
+ retargeted_at,
+ );
+ zero_restart.set_hovered(true);
+ zero_restart.sync(
+ &zero_restart_style,
+ StyleState::default(),
+ false,
+ retargeted_at,
+ false,
+ );
+
+ let sampled_at = retargeted_at + Duration::from_millis(16);
+ let width = |state: &StyleTransitionState| match state.frame(sampled_at, false).style.width
+ {
+ Some(DimensionValue::Pixels(width)) => width,
+ width => panic!("expected a sampled pixel width, got {width:?}"),
+ };
+ let carried_width = width(&carried);
+ let restarted_width = width(&zero_restart);
+ assert!(
+ carried_width > restarted_width + 1.0,
+ "state-style interruption must carry velocity: {carried_width} vs {restarted_width}"
+ );
+ }
+
+ #[test]
+ fn style_spring_holds_during_its_delay() {
+ let started = Instant::now();
+ let style = style(serde_json::json!({
+ "opacity": 0,
+ "hover": { "opacity": 1 },
+ "transition": {
+ "properties": ["opacity"],
+ "delayMs": 50,
+ "easing": { "type": "spring" }
+ }
+ }));
+ let mut state = StyleTransitionState::new(&style, StyleState::default(), false, started);
+ state.set_hovered(true);
+ state.sync(&style, StyleState::default(), false, started, false);
+
+ assert_eq!(
+ state
+ .frame(started + Duration::from_millis(49), false)
+ .style
+ .opacity,
+ Some(0.0)
+ );
+ let after_delay = state.frame(started + Duration::from_millis(100), false);
+ assert!(after_delay.style.opacity.unwrap() > 0.0);
+ assert!(after_delay.style.opacity.unwrap() < 1.0);
+ assert!(after_delay.active);
+ }
+
+ #[test]
+ fn style_spring_settles_proportional_channels_without_a_visible_snap() {
+ let started = Instant::now();
+ let style = style(serde_json::json!({
+ "width": "0%",
+ "opacity": 0,
+ "backgroundColor": "transparent",
+ "hover": {
+ "width": "100%",
+ "opacity": 1,
+ "backgroundColor": "#ffffff"
+ },
+ "transition": {
+ "properties": ["width", "opacity", "backgroundColor"],
+ "easing": { "type": "spring" }
+ }
+ }));
+ let mut state = StyleTransitionState::new(&style, StyleState::default(), false, started);
+ state.set_hovered(true);
+ state.sync(&style, StyleState::default(), false, started, false);
+
+ let mut last_active = None;
+ let mut settled = None;
+ for tick in 1..=5_000 {
+ let frame = state.frame(started + Duration::from_millis(tick), false);
+ if frame.active {
+ let width = match frame.style.width {
+ Some(DimensionValue::Percentage(width)) => width,
+ width => panic!("expected a percentage width, got {width:?}"),
+ };
+ let color =
+ TransitionValue::from_style(&frame.style, TransitionProperty::BackgroundColor)
+ .expect("the spring must keep painting a valid colour");
+ last_active = Some((width, frame.style.opacity.unwrap(), color));
+ } else {
+ settled = Some(frame);
+ break;
+ }
+ }
+
+ let (width, opacity, color) = last_active.expect("the spring must paint active frames");
+ assert!((width - 1.0).abs() <= 0.000_51);
+ assert!((opacity - 1.0).abs() <= 0.000_51);
+ let TransitionValue::Color(color) = color else {
+ panic!("expected a colour transition value")
+ };
+ assert!((color[3] - 1.0).abs() <= 0.000_51);
+
+ let settled = settled.expect("the default spring must settle");
+ assert_eq!(settled.style.width, Some(DimensionValue::Percentage(1.0)));
+ assert_eq!(settled.style.opacity, Some(1.0));
+ assert_eq!(
+ TransitionValue::from_style(&settled.style, TransitionProperty::BackgroundColor,),
+ Some(TransitionValue::Color([1.0, 1.0, 1.0, 1.0]))
+ );
+ }
+
#[test]
fn interpolates_and_retargets_from_the_visible_value() {
let started = Instant::now();
@@ -1310,4 +2033,133 @@ mod tests {
assert_eq!(next_midpoint.style.width, Some(150.0));
assert!(next_midpoint.active);
}
+
+ #[test]
+ fn motion_spring_overshoots_and_settles_without_a_visible_channel_snap() {
+ let started = Instant::now();
+ let description = serde_json::json!({
+ "initial": { "width": 0.0, "opacity": 0.0 },
+ "animate": { "width": 100.0, "opacity": 1.0 },
+ "transition": {
+ "duration": 1e300,
+ "ease": { "type": "spring" }
+ }
+ });
+ let state = MotionState::new(&description, started).unwrap();
+ let mut saw_width_overshoot = false;
+ let mut last_active = None;
+ let mut settled = None;
+
+ for tick in 1..=5_000 {
+ let frame = state.frame(started + Duration::from_millis(tick), false);
+ saw_width_overshoot |= frame.style.width.is_some_and(|width| width > 100.0);
+ if frame.active {
+ last_active = Some(frame);
+ } else {
+ settled = Some(frame);
+ break;
+ }
+ }
+
+ assert!(
+ saw_width_overshoot,
+ "the sampled width trajectory must overshoot"
+ );
+ let last_active = last_active.expect("the spring must emit intermediate frames");
+ assert!(
+ (last_active.style.width.unwrap() - 100.0).abs() <= 0.051,
+ "the final width snap must stay below 0.05px plus tick precision"
+ );
+ assert!(
+ (last_active.style.opacity.unwrap() - 1.0).abs() <= 0.000_51,
+ "opacity needs a proportionally smaller settling window"
+ );
+ let settled = settled.expect("the default spring must settle");
+ assert_eq!(settled.style.width, Some(100.0));
+ assert_eq!(settled.style.opacity, Some(1.0));
+ assert!(!settled.active);
+ }
+
+ #[test]
+ fn motion_spring_delay_applies_while_duration_is_ignored() {
+ let started = Instant::now();
+ let description = serde_json::json!({
+ "initial": { "width": 0.0 },
+ "animate": { "width": 100.0 },
+ "transition": {
+ "duration": 0.0,
+ "delay": 0.1,
+ "ease": { "type": "spring", "stiffness": 100, "damping": 10, "mass": 1 }
+ }
+ });
+ let state = MotionState::new(&description, started).unwrap();
+
+ assert_eq!(
+ state
+ .frame(started + Duration::from_millis(99), false)
+ .style
+ .width,
+ Some(0.0)
+ );
+ let after_delay = state.frame(started + Duration::from_millis(150), false);
+ assert!(after_delay.style.width.unwrap() > 0.0);
+ assert!(after_delay.style.width.unwrap() < 100.0);
+ assert!(after_delay.active);
+ }
+
+ #[test]
+ fn motion_spring_retarget_carries_the_visible_velocity() {
+ let started = Instant::now();
+ let initial = serde_json::json!({
+ "initial": { "width": 0.0 },
+ "animate": { "width": 100.0 },
+ "transition": { "ease": { "type": "spring" } }
+ });
+ let mut carried = MotionState::new(&initial, started).unwrap();
+ let retargeted_at = started + Duration::from_millis(100);
+ let visible = carried.frame(retargeted_at, false).style.width.unwrap();
+ let reversed = serde_json::json!({
+ "initial": false,
+ "animate": { "width": 0.0 },
+ "transition": { "ease": { "type": "spring" } }
+ });
+ carried.sync(&reversed, retargeted_at, false).unwrap();
+
+ let zero_restart_description = serde_json::json!({
+ "initial": { "width": visible },
+ "animate": { "width": 0.0 },
+ "transition": { "ease": { "type": "spring", "velocity": 0.0 } }
+ });
+ let zero_restart = MotionState::new(&zero_restart_description, retargeted_at).unwrap();
+ let sampled_at = retargeted_at + Duration::from_millis(16);
+ let carried_width = carried.frame(sampled_at, false).style.width.unwrap();
+ let restarted_width = zero_restart.frame(sampled_at, false).style.width.unwrap();
+
+ assert!(
+ carried_width > restarted_width + 1.0,
+ "carried velocity must produce a different post-retarget trajectory: {carried_width} vs {restarted_width}"
+ );
+ }
+
+ #[test]
+ fn motion_rejects_unknown_and_malformed_spring_easings() {
+ let now = Instant::now();
+ for ease in [
+ serde_json::json!({ "type": "bounce" }),
+ serde_json::json!({ "type": "spring", "stiffness": 0 }),
+ serde_json::json!({ "type": "spring", "damping": -1 }),
+ serde_json::json!({ "type": "spring", "mass": 0 }),
+ serde_json::json!({ "type": "spring", "velocity": 1e300 }),
+ serde_json::json!({ "type": "spring", "unknown": 1 }),
+ ] {
+ let description = serde_json::json!({
+ "animate": { "width": 100 },
+ "transition": { "ease": ease }
+ });
+ assert!(
+ MotionState::new(&description, now).is_err(),
+ "{description}"
+ );
+ }
+ }
}
diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs
index 49e37d3b..f70b5803 100644
--- a/packages/native/src/style.rs
+++ b/packages/native/src/style.rs
@@ -159,6 +159,51 @@ impl TransitionProperty {
pub enum TransitionEasing {
Name(String),
CubicBezier([f64; 4]),
+ Spring(SpringEasing),
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+enum SpringEasingType {
+ #[serde(rename = "spring")]
+ Spring,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct SpringEasing {
+ #[serde(rename = "type")]
+ _kind: SpringEasingType,
+ #[serde(default = "default_spring_stiffness")]
+ pub(crate) stiffness: f64,
+ #[serde(default = "default_spring_damping")]
+ pub(crate) damping: f64,
+ #[serde(default = "default_spring_mass")]
+ pub(crate) mass: f64,
+ #[serde(default)]
+ pub(crate) velocity: f64,
+}
+
+impl SpringEasing {
+ pub(crate) fn is_valid(&self) -> bool {
+ let fits_f32 = |value: f64| value.is_finite() && value.abs() <= f64::from(f32::MAX);
+ let positive_f32 = |value: f64| fits_f32(value) && value >= f64::from(f32::MIN_POSITIVE);
+ positive_f32(self.stiffness)
+ && positive_f32(self.damping)
+ && positive_f32(self.mass)
+ && fits_f32(self.velocity)
+ }
+}
+
+fn default_spring_stiffness() -> f64 {
+ 100.0
+}
+
+fn default_spring_damping() -> f64 {
+ 10.0
+}
+
+fn default_spring_mass() -> f64 {
+ 1.0
}
fn default_transition_easing() -> TransitionEasing {
@@ -1059,13 +1104,42 @@ fn parse_transition(
}
}
- let duration_ms = match object.get("durationMs").and_then(serde_json::Value::as_f64) {
- Some(value) if valid_transition_milliseconds(value) => value,
- _ => {
+ let easing_value = object.get("easing");
+ let parsed_easing = easing_value
+ .map(|value| serde_json::from_value::(value.clone()))
+ .transpose();
+ let valid_spring = parsed_easing
+ .as_ref()
+ .ok()
+ .and_then(|easing| easing.as_ref())
+ .is_some_and(
+ |easing| matches!(easing, TransitionEasing::Spring(spring) if spring.is_valid()),
+ );
+ // A spring-shaped easing does not acquire a fixed duration merely because
+ // one of its own fields is malformed. Report the easing itself and keep
+ // duration optional for the whole tagged-object branch.
+ let spring_shaped = easing_value.is_some_and(serde_json::Value::is_object);
+ let duration_ms = match object.get("durationMs") {
+ _ if valid_spring => 0.0,
+ None if spring_shaped => 0.0,
+ Some(value) => match value.as_f64() {
+ Some(value) if valid_transition_milliseconds(value) => value,
+ _ => {
+ reject(
+ problems,
+ "transition.durationMs",
+ value,
+ "expected a supported finite non-negative number of milliseconds",
+ );
+ valid = false;
+ 0.0
+ }
+ },
+ None => {
reject(
problems,
"transition.durationMs",
- object.get("durationMs").unwrap_or(&serde_json::Value::Null),
+ &serde_json::Value::Null,
"expected a supported finite non-negative number of milliseconds",
);
valid = false;
@@ -1088,10 +1162,10 @@ fn parse_transition(
}
},
};
- let easing = match object.get("easing") {
- None => default_transition_easing(),
- Some(value) => match serde_json::from_value::(value.clone()) {
- Ok(TransitionEasing::Name(name))
+ let easing = match (easing_value, parsed_easing) {
+ (None, _) => default_transition_easing(),
+ (Some(value), Ok(Some(easing))) => match easing {
+ TransitionEasing::Name(name)
if matches!(
name.as_str(),
"linear" | "ease" | "easeIn" | "easeOut" | "easeInOut"
@@ -1099,24 +1173,37 @@ fn parse_transition(
{
TransitionEasing::Name(name)
}
- Ok(TransitionEasing::CubicBezier(curve))
+ TransitionEasing::CubicBezier(curve)
if curve.iter().all(|value| value.is_finite())
&& (0.0..=1.0).contains(&curve[0])
&& (0.0..=1.0).contains(&curve[2]) =>
{
TransitionEasing::CubicBezier(curve)
}
+ TransitionEasing::Spring(spring) if spring.is_valid() => {
+ TransitionEasing::Spring(spring)
+ }
_ => {
reject(
problems,
"transition.easing",
value,
- "expected linear, ease, easeIn, easeOut, easeInOut, or a cubic-bezier tuple with x values from 0 through 1",
+ "expected linear, ease, easeIn, easeOut, easeInOut, a cubic-bezier tuple with x values from 0 through 1, or a valid spring easing",
);
valid = false;
default_transition_easing()
}
},
+ (Some(value), _) => {
+ reject(
+ problems,
+ "transition.easing",
+ value,
+ "expected linear, ease, easeIn, easeOut, easeInOut, a cubic-bezier tuple with x values from 0 through 1, or a valid spring easing",
+ );
+ valid = false;
+ default_transition_easing()
+ }
};
valid.then_some(StyleTransition {
@@ -2142,6 +2229,55 @@ mod tests {
assert_eq!(oversized.problems[0].property, "transition.durationMs");
}
+ #[test]
+ fn spring_easing_ignores_duration_but_malformed_springs_reject_the_transition() {
+ let spring = parse_style_value(&json!({
+ "opacity": 0.5,
+ "transition": {
+ "properties": ["opacity"],
+ "easing": { "type": "spring" }
+ }
+ }));
+ assert!(spring.problems.is_empty(), "{:?}", spring.problems);
+ assert!(spring.style.transition.is_some());
+
+ let spring_with_extreme_duration = parse_style_value(&json!({
+ "opacity": 0.5,
+ "transition": {
+ "properties": ["opacity"],
+ "durationMs": 1e300,
+ "easing": { "type": "spring" }
+ }
+ }));
+ assert!(
+ spring_with_extreme_duration.problems.is_empty(),
+ "{:?}",
+ spring_with_extreme_duration.problems
+ );
+ assert!(spring_with_extreme_duration.style.transition.is_some());
+
+ for easing in [
+ json!({ "type": "bounce" }),
+ json!({ "type": "spring", "stiffness": 0 }),
+ json!({ "type": "spring", "damping": -1 }),
+ json!({ "type": "spring", "mass": 0 }),
+ json!({ "type": "spring", "velocity": 1e300 }),
+ json!({ "type": "spring", "unknown": 1 }),
+ ] {
+ let parsed = parse_style_value(&json!({
+ "opacity": 0.5,
+ "transition": {
+ "properties": ["opacity"],
+ "easing": easing
+ }
+ }));
+ assert_eq!(parsed.style.opacity, Some(0.5));
+ assert_eq!(parsed.style.transition, None);
+ assert_eq!(parsed.problems.len(), 1, "{:?}", parsed.problems);
+ assert_eq!(parsed.problems[0].property, "transition.easing");
+ }
+ }
+
#[test]
fn hover_group_is_a_top_level_style_marker() {
let top_level = parse_style_value(&json!({ "hoverGroup": "destination-row" }));
diff --git a/packages/react/src/__tests__/style-desc.types.ts b/packages/react/src/__tests__/style-desc.types.ts
index 8d847bbd..65341b90 100644
--- a/packages/react/src/__tests__/style-desc.types.ts
+++ b/packages/react/src/__tests__/style-desc.types.ts
@@ -1,4 +1,10 @@
-import type { ImageSource, ImgProps, Props, StyleDesc } from "../types/host.js"
+import type {
+ ImageSource,
+ ImgProps,
+ MotionTransition,
+ Props,
+ StyleDesc,
+} from "../types/host.js"
const validStyle = {
display: "grid",
@@ -108,6 +114,23 @@ const missingTransitionDuration: StyleDesc = {
transition: { properties: ["opacity"] },
}
+const validSpringTransition: StyleDesc = {
+ transition: {
+ properties: ["width", "opacity"],
+ easing: { type: "spring", stiffness: 400, damping: 28, mass: 0.9, velocity: 12 },
+ },
+}
+
+const validMotionSpring: MotionTransition = {
+ duration: 0,
+ ease: { type: "spring", stiffness: 400, damping: 28, mass: 0.9, velocity: 12 },
+}
+
+const unknownSpringType: MotionTransition = {
+ // @ts-expect-error Tagged spring easings reject unknown type values.
+ ease: { type: "bounce" },
+}
+
const nestedTransition: StyleDesc = {
hover: {
// @ts-expect-error State refinements inherit the base transition declaration.
@@ -162,6 +185,9 @@ void invalidTextWrap
void invalidHoverStyle
void invalidTransitionProperty
void missingTransitionDuration
+void validSpringTransition
+void validMotionSpring
+void unknownSpringType
void nestedTransition
void nestedHoverGroup
void removedElementHoverGroup
diff --git a/packages/react/src/__tests__/transitions.test.tsx b/packages/react/src/__tests__/transitions.test.tsx
index 664eb965..ea1160a7 100644
--- a/packages/react/src/__tests__/transitions.test.tsx
+++ b/packages/react/src/__tests__/transitions.test.tsx
@@ -4,6 +4,7 @@ import React from "react"
import { describe, expect, it, vi } from "vitest"
import { createTestRoot, isNativeTestRendererAvailable } from "../testing.js"
+import { motion } from "../index.js"
import { expectScreenshotsDiffer, SHOTS_DIR } from "./test-utils.js"
const describeNative = isNativeTestRendererAvailable() ? describe : describe.skip
@@ -27,6 +28,48 @@ const customTransitionStyle = (expanded: boolean) => ({
})
describeNative("native style transitions", () => {
+ it("ignores an extreme duration for spring easing through state styles", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
+ const root = createTestRoot({ strictStyles: true })
+ try {
+ root.renderer.clockPause()
+ root.render(
+
+ )
+
+ const target = root.renderer.findByTestId("spring-hover-target")!
+ const [x, y, width, height] = root.renderer.getElementBounds(target.id)!
+ root.renderer.nativeSimulateMouseMove(x + width / 2, y + height / 2)
+
+ root.renderer.advanceAsyncClock(100)
+ expect(root.renderer.getResolvedStyle(target.id)?.width).toBeCloseTo(134.03, 1)
+ root.renderer.advanceAsyncClock(200)
+ expect(root.renderer.getResolvedStyle(target.id)?.width).toBeGreaterThan(200)
+ root.renderer.advanceAsyncClock(4_000)
+ expect(root.renderer.getResolvedStyle(target.id)).toMatchObject({ width: 200, opacity: 0.8 })
+ expect(warn).not.toHaveBeenCalled()
+ expect(root.renderer.drainStyleDiagnostics()).toEqual([])
+ } finally {
+ root.unmount()
+ warn.mockRestore()
+ }
+ })
+
it("interpolates React-driven style updates from the visible value", () => {
const root = createTestRoot()
const card = (expanded: boolean) => (
@@ -928,3 +971,34 @@ describeNative("native style transitions", () => {
}
})
})
+
+describeNative("motion spring easing", () => {
+ it("samples overshoot natively and ignores a fixed duration", () => {
+ const root = createTestRoot()
+ try {
+ root.renderer.clockPause()
+ root.render(
+
+ )
+
+ const target = root.renderer.findByTestId("motion-spring-target")!
+ root.renderer.advanceAsyncClock(100)
+ expect(root.renderer.getResolvedStyle(target.id)?.width).toBeCloseTo(34.03, 1)
+ root.renderer.advanceAsyncClock(200)
+ expect(root.renderer.getResolvedStyle(target.id)?.width).toBeGreaterThan(100)
+ root.renderer.advanceAsyncClock(4_000)
+ expect(root.renderer.getResolvedStyle(target.id)).toMatchObject({ width: 100, opacity: 1 })
+ } finally {
+ root.unmount()
+ }
+ })
+})
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 965d1767..b835e61e 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -249,6 +249,7 @@ export type {
HighlightSpec,
MotionEase,
MotionProps,
+ MotionSpringEase,
MotionStyle,
MotionTransition,
NativeStateStyle,
@@ -261,7 +262,9 @@ export type {
StyleDiagnostic,
NativeWindowInsets,
StyleDesc,
+ StyleSpringTransition,
TransitionProperty,
+ StyleTweenTransition,
} from "./types/host.js"
export { handleGpuixEvent } from "./reconciler/event-registry.js"
export type {
diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts
index 761f838a..893a01af 100644
--- a/packages/react/src/types/host.ts
+++ b/packages/react/src/types/host.ts
@@ -263,6 +263,18 @@ export interface MotionStyle {
borderRadius?: number
}
+export interface MotionSpringEase {
+ type: "spring"
+ /** Spring stiffness. Defaults to 100. */
+ stiffness?: number
+ /** Spring damping. Defaults to 10. */
+ damping?: number
+ /** Spring mass. Defaults to 1. */
+ mass?: number
+ /** Initial value velocity in units per second. Defaults to 0. */
+ velocity?: number
+}
+
export type MotionEase =
| "linear"
| "ease"
@@ -270,6 +282,7 @@ export type MotionEase =
| "easeOut"
| "easeInOut"
| [number, number, number, number]
+ | MotionSpringEase
export interface MotionTransition {
/** Duration in seconds. */
@@ -307,13 +320,24 @@ export type TransitionProperty =
| "borderBottomLeftRadius"
| "borderBottomRightRadius"
-export interface StyleTransition {
+interface StyleTransitionBase {
properties: TransitionProperty[]
- durationMs: number
delayMs?: number
- easing?: MotionEase
}
+export interface StyleTweenTransition extends StyleTransitionBase {
+ durationMs: number
+ easing?: Exclude
+}
+
+export interface StyleSpringTransition extends StyleTransitionBase {
+ /** Ignored for springs when supplied. */
+ durationMs?: number
+ easing: MotionSpringEase
+}
+
+export type StyleTransition = StyleTweenTransition | StyleSpringTransition
+
/**
* CSS `cursor` keywords GPUI can paint. An unlisted keyword is ignored, like
* every other invalid style value.