diff --git a/CHANGELOG b/CHANGELOG
index 6834129b3a6dd1cba0c8367ae1d380529a722e78..6ddcbbf9b41b4a3956e002690602d82273fa0026 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Added `Tween<T>::then()` and `Sequence<T>::then()` to append a `Tweenable<T>` to a sequence (creating a new sequence in the case of `Tween<T>::then()`).
 - Added `tweenable()` and `tweenable_mut()` on the `Animator<T>` and `AssetAnimator<T>` to access their top-level `Tweenable<T>`.
 - Implemented `Default` for `Animator<T>` and `AssetAnimator<T>`, creating an animator without any tweenable item (no-op).
+- Added `Delay` tweenable for a time delay between other tweens.
 
 ### Changed
 
diff --git a/Cargo.toml b/Cargo.toml
index 9f0473ac1a1068cb9329f11065845d19c73a6c02..4fbc4d72b5515d463c3dc5d2a5b73cbfe5356d7c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,9 @@ version = "0.6"
 default-features = false
 features = [ "render" ]
 
+[dev-dependencies]
+bevy-inspector-egui = "0.8"
+
 [[example]]
 name = "colormaterial_color"
 required-features = [ "bevy/bevy_winit" ]
diff --git a/README.md b/README.md
index f71d9b9a745414954676ec9c8e4e9c68614a5cb7..c249c29c4b057878f3b5a59c550d778f09d7239d 100644
--- a/README.md
+++ b/README.md
@@ -144,6 +144,14 @@ The process is similar to custom components, creating a custom lens for the cust
 
 See the [`examples/`](https://github.com/djeedai/bevy_tweening/tree/main/examples) folder.
 
+### [`menu`](examples/menu.rs)
+
+```rust
+cargo run --example menu --features="bevy/bevy_winit"
+```
+
+![menu](https://raw.githubusercontent.com/djeedai/bevy_tweening/main/examples/menu.gif)
+
 ### [`sprite_color`](examples/sprite_color.rs)
 
 ```rust
diff --git a/examples/menu.gif b/examples/menu.gif
new file mode 100644
index 0000000000000000000000000000000000000000..9017c5932e79f71213d7ec0fdda4fb5ce1638e77
Binary files /dev/null and b/examples/menu.gif differ
diff --git a/examples/menu.rs b/examples/menu.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4f3dbf3072e0c9f06c7b853e80198c999065dcfb
--- /dev/null
+++ b/examples/menu.rs
@@ -0,0 +1,103 @@
+use bevy::prelude::*;
+use bevy_inspector_egui::WorldInspectorPlugin;
+use bevy_tweening::*;
+use std::time::Duration;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    App::default()
+        .insert_resource(WindowDescriptor {
+            title: "Menu".to_string(),
+            width: 800.,
+            height: 400.,
+            vsync: true,
+            ..Default::default()
+        })
+        .add_plugins(DefaultPlugins)
+        .add_plugin(TweeningPlugin)
+        .add_plugin(WorldInspectorPlugin::new())
+        .add_startup_system(setup)
+        .run();
+
+    Ok(())
+}
+
+fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
+    commands.spawn_bundle(UiCameraBundle::default());
+
+    let font = asset_server.load("fonts/FiraMono-Regular.ttf");
+
+    let container = commands
+        .spawn_bundle(NodeBundle {
+            style: Style {
+                position_type: PositionType::Absolute,
+                position: Rect::all(Val::Px(0.)),
+                margin: Rect::all(Val::Px(16.)),
+                padding: Rect::all(Val::Px(16.)),
+                flex_direction: FlexDirection::ColumnReverse,
+                align_content: AlignContent::Center,
+                align_items: AlignItems::Center,
+                align_self: AlignSelf::Center,
+                justify_content: JustifyContent::Center,
+                ..Default::default()
+            },
+            color: UiColor(Color::NONE),
+            ..Default::default()
+        })
+        .insert(Name::new("container"))
+        .id();
+
+    let mut start_time_ms = 0;
+    for text in &["Continue", "New Game", "Settings", "Quit"] {
+        let delay = Delay::new(Duration::from_millis(start_time_ms));
+        start_time_ms += 500;
+        let tween_scale = Tween::new(
+            EaseFunction::BounceOut,
+            TweeningType::Once,
+            Duration::from_secs(2),
+            TransformScaleLens {
+                start: Vec3::splat(0.01),
+                end: Vec3::ONE,
+            },
+        );
+        let seq = delay.then(tween_scale);
+        commands
+            .spawn_bundle(NodeBundle {
+                node: Node {
+                    size: Vec2::new(300., 80.),
+                },
+                style: Style {
+                    min_size: Size::new(Val::Px(300.), Val::Px(80.)),
+                    margin: Rect::all(Val::Px(8.)),
+                    padding: Rect::all(Val::Px(8.)),
+                    align_content: AlignContent::Center,
+                    align_items: AlignItems::Center,
+                    align_self: AlignSelf::Center,
+                    justify_content: JustifyContent::Center,
+                    ..Default::default()
+                },
+                color: UiColor(Color::rgb_u8(162, 226, 95)),
+                transform: Transform::from_scale(Vec3::splat(0.01)),
+                ..Default::default()
+            })
+            .insert(Name::new(format!("button:{}", text)))
+            .insert(Parent(container))
+            .insert(Animator::new(seq))
+            .with_children(|parent| {
+                parent.spawn_bundle(TextBundle {
+                    text: Text::with_section(
+                        text.to_string(),
+                        TextStyle {
+                            font: font.clone(),
+                            font_size: 48.0,
+                            color: Color::rgb_u8(83, 163, 130),
+                        },
+                        TextAlignment {
+                            vertical: VerticalAlign::Center,
+                            horizontal: HorizontalAlign::Center,
+                        },
+                    ),
+                    ..Default::default()
+                });
+            });
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 5ee676700fd9029caaaeeafb4a08d722ed7cbb18..29107f5bd46a26a60b903da40ee3a379ceb83d6b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -54,7 +54,7 @@
 //!         end: Vec3::new(1., 2., -4.),
 //!     },
 //! );
-//! 
+//!
 //! commands
 //!     // Spawn a Sprite entity to animate the position of.
 //!     .spawn_bundle(SpriteBundle {
@@ -106,8 +106,6 @@
 //! [`Sprite`]: https://docs.rs/bevy/0.6.0/bevy/sprite/struct.Sprite.html
 //! [`Transform`]: https://docs.rs/bevy/0.6.0/bevy/transform/components/struct.Transform.html
 
-use std::time::Duration;
-
 use bevy::{asset::Asset, prelude::*};
 
 use interpolation::Ease as IEase;
@@ -116,12 +114,14 @@ pub use interpolation::Lerp;
 
 mod lens;
 mod plugin;
+mod tweenable;
 
 pub use lens::{
     ColorMaterialColorLens, Lens, SpriteColorLens, TextColorLens, TransformPositionLens,
     TransformRotationLens, TransformScaleLens, UiPositionLens,
 };
 pub use plugin::{asset_animator_system, component_animator_system, TweeningPlugin};
+pub use tweenable::{Delay, Sequence, Tracks, Tween, Tweenable};
 
 /// Type of looping for a tween animation.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -232,331 +232,6 @@ pub enum TweenState {
     Ended,
 }
 
-/// An animatable entity, either a single [`Tween`] or a collection of them.
-pub trait Tweenable<T>: Send + Sync {
-    /// Get the total duration of the animation.
-    fn duration(&self) -> Duration;
-
-    /// Get the current progress in \[0:1\] of the animation.
-    fn progress(&self) -> f32;
-
-    /// Tick the animation, advancing it by the given delta time and mutating the
-    /// given target component or asset.
-    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState;
-
-    /// Stop the animation.
-    fn stop(&mut self);
-}
-
-impl<T> Tweenable<T> for Box<dyn Tweenable<T> + Send + Sync + 'static> {
-    fn duration(&self) -> Duration {
-        self.as_ref().duration()
-    }
-    fn progress(&self) -> f32 {
-        self.as_ref().progress()
-    }
-    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
-        self.as_mut().tick(delta, target)
-    }
-    fn stop(&mut self) {
-        self.as_mut().stop()
-    }
-}
-
-/// Trait for boxing a [`Tweenable`] trait object.
-pub trait IntoBoxDynTweenable<T> {
-    /// Convert the current object into a boxed [`Tweenable`].
-    fn into_box_dyn(this: Self) -> Box<dyn Tweenable<T> + Send + Sync + 'static>;
-}
-
-impl<T, U: Tweenable<T> + Send + Sync + 'static> IntoBoxDynTweenable<T> for U {
-    fn into_box_dyn(this: U) -> Box<dyn Tweenable<T> + Send + Sync + 'static> {
-        Box::new(this)
-    }
-}
-
-/// Single tweening animation instance.
-pub struct Tween<T> {
-    ease_function: EaseMethod,
-    timer: Timer,
-    state: TweenState,
-    tweening_type: TweeningType,
-    direction: TweeningDirection,
-    lens: Box<dyn Lens<T> + Send + Sync + 'static>,
-    on_started: Option<Box<dyn FnMut() + Send + Sync + 'static>>,
-    on_ended: Option<Box<dyn FnMut() + Send + Sync + 'static>>,
-}
-
-impl<T: 'static> Tween<T> {
-    /// Chain another [`Tweenable`] after this tween, making a sequence with the two.
-    pub fn then(self, tween: impl Tweenable<T> + Send + Sync + 'static) -> Sequence<T> {
-        Sequence::from_single(self).then(tween)
-    }
-}
-
-impl<T> Tween<T> {
-    /// Create a new tween animation.
-    pub fn new<L>(
-        ease_function: impl Into<EaseMethod>,
-        tweening_type: TweeningType,
-        duration: Duration,
-        lens: L,
-    ) -> Self
-    where
-        L: Lens<T> + Send + Sync + 'static,
-    {
-        Tween {
-            ease_function: ease_function.into(),
-            timer: Timer::new(duration, tweening_type != TweeningType::Once),
-            state: TweenState::Stopped,
-            tweening_type,
-            direction: TweeningDirection::Forward,
-            lens: Box::new(lens),
-            on_started: None,
-            on_ended: None,
-        }
-    }
-
-    /// The current animation direction.
-    ///
-    /// See [`TweeningDirection`] for details.
-    pub fn direction(&self) -> TweeningDirection {
-        self.direction
-    }
-
-    /// Set a callback invoked when the animation starts.
-    pub fn set_started<C>(&mut self, callback: C)
-    where
-        C: FnMut() + Send + Sync + 'static,
-    {
-        self.on_started = Some(Box::new(callback));
-    }
-
-    /// Clear the callback invoked when the animation starts.
-    pub fn clear_started(&mut self) {
-        self.on_started = None;
-    }
-
-    /// Set a callback invoked when the animation ends.
-    pub fn set_ended<C>(&mut self, callback: C)
-    where
-        C: FnMut() + Send + Sync + 'static,
-    {
-        self.on_ended = Some(Box::new(callback));
-    }
-
-    /// Clear the callback invoked when the animation ends.
-    pub fn clear_ended(&mut self) {
-        self.on_ended = None;
-    }
-
-    /// Is the animation playback looping?
-    pub fn is_looping(&self) -> bool {
-        self.tweening_type != TweeningType::Once
-    }
-}
-
-impl<T> Tweenable<T> for Tween<T> {
-    fn duration(&self) -> Duration {
-        self.timer.duration()
-    }
-
-    /// Current animation progress ratio between 0 and 1.
-    ///
-    /// For reversed playback ([`TweeningDirection::Backward`]), the ratio goes from 0 at the
-    /// end point (beginning of backward playback) to 1 at the start point (end of backward
-    /// playback).
-    fn progress(&self) -> f32 {
-        match self.direction {
-            TweeningDirection::Forward => self.timer.percent(),
-            TweeningDirection::Backward => self.timer.percent_left(),
-        }
-    }
-
-    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
-        let old_state = self.state;
-        if old_state == TweenState::Stopped {
-            self.state = TweenState::Running;
-            if let Some(cb) = &mut self.on_started {
-                cb();
-            }
-        }
-
-        self.timer.tick(delta);
-
-        // Toggle direction immediately, so self.progress() returns the correct ratio
-        if self.timer.just_finished() && self.tweening_type == TweeningType::PingPong {
-            self.direction = !self.direction;
-        }
-
-        let progress = self.progress();
-        let factor = self.ease_function.sample(progress);
-        self.lens.lerp(target, factor);
-
-        if self.timer.just_finished() {
-            self.state = TweenState::Ended;
-            // This is always true for non ping-pong, and is true for ping-pong when
-            // coming back to start after a full cycle start -> end -> start.
-            if self.direction == TweeningDirection::Forward {
-                if let Some(cb) = &mut self.on_ended {
-                    cb();
-                }
-            }
-        }
-
-        self.state
-    }
-
-    fn stop(&mut self) {
-        self.state = TweenState::Stopped;
-        self.timer.reset();
-    }
-}
-
-/// A sequence of tweens played back in order one after the other.
-pub struct Sequence<T> {
-    tweens: Vec<Box<dyn Tweenable<T> + Send + Sync + 'static>>,
-    index: usize,
-    state: TweenState,
-    duration: Duration,
-    time: Duration,
-}
-
-impl<T> Sequence<T> {
-    /// Create a new sequence of tweens.
-    pub fn new(items: impl IntoIterator<Item = impl IntoBoxDynTweenable<T>>) -> Self {
-        let tweens: Vec<_> = items
-            .into_iter()
-            .map(IntoBoxDynTweenable::into_box_dyn)
-            .collect();
-        let duration = tweens.iter().map(|t| t.duration()).sum();
-        Sequence {
-            tweens,
-            index: 0,
-            state: TweenState::Stopped,
-            duration,
-            time: Duration::from_secs(0),
-        }
-    }
-
-    /// Create a new sequence containing a single tween.
-    pub fn from_single(tween: impl Tweenable<T> + Send + Sync + 'static) -> Self {
-        let duration = tween.duration();
-        Sequence {
-            tweens: vec![Box::new(tween)],
-            index: 0,
-            state: TweenState::Stopped,
-            duration,
-            time: Duration::from_secs(0),
-        }
-    }
-
-    /// Append a [`Tweenable`] to this sequence.
-    pub fn then(mut self, tween: impl Tweenable<T> + Send + Sync + 'static) -> Self {
-        self.duration += tween.duration();
-        self.tweens.push(Box::new(tween));
-        self
-    }
-
-    /// Index of the current active tween in the sequence.
-    pub fn index(&self) -> usize {
-        self.index.min(self.tweens.len() - 1)
-    }
-
-    /// Get the current active tween in the sequence.
-    pub fn current(&self) -> &dyn Tweenable<T> {
-        self.tweens[self.index()].as_ref()
-    }
-}
-
-impl<T> Tweenable<T> for Sequence<T> {
-    fn duration(&self) -> Duration {
-        self.duration
-    }
-
-    fn progress(&self) -> f32 {
-        self.time.as_secs_f32() / self.duration.as_secs_f32()
-    }
-
-    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
-        if self.index < self.tweens.len() {
-            self.time = (self.time + delta).min(self.duration);
-            let tween = &mut self.tweens[self.index];
-            let state = tween.tick(delta, target);
-            if state == TweenState::Ended {
-                tween.stop();
-                self.index += 1;
-                if self.index >= self.tweens.len() {
-                    self.state = TweenState::Ended;
-                }
-            }
-        }
-        self.state
-    }
-
-    fn stop(&mut self) {
-        if self.state != TweenState::Stopped {
-            self.state = TweenState::Stopped;
-            if self.index < self.tweens.len() {
-                let tween = &mut self.tweens[self.index];
-                tween.stop();
-            }
-        }
-    }
-}
-
-/// A collection of [`Tweenable`] executing in parallel.
-pub struct Tracks<T> {
-    tracks: Vec<Box<dyn Tweenable<T> + Send + Sync + 'static>>,
-    duration: Duration,
-    time: Duration,
-}
-
-impl<T> Tracks<T> {
-    /// Create a new [`Tracks`] from an iterator over a collection of [`Tweenable`].
-    pub fn new(items: impl IntoIterator<Item = impl IntoBoxDynTweenable<T>>) -> Self {
-        let tracks: Vec<_> = items
-            .into_iter()
-            .map(IntoBoxDynTweenable::into_box_dyn)
-            .collect();
-        let duration = tracks.iter().map(|t| t.duration()).max().unwrap();
-        Tracks {
-            tracks,
-            duration,
-            time: Duration::from_secs(0),
-        }
-    }
-}
-
-impl<T> Tweenable<T> for Tracks<T> {
-    fn duration(&self) -> Duration {
-        self.duration
-    }
-
-    fn progress(&self) -> f32 {
-        self.time.as_secs_f32() / self.duration.as_secs_f32()
-    }
-
-    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
-        let mut any_running = true;
-        for tweenable in &mut self.tracks {
-            any_running = any_running && (tweenable.tick(delta, target) == TweenState::Running);
-        }
-        if any_running {
-            self.time = (self.time + delta).min(self.duration);
-            TweenState::Running
-        } else {
-            TweenState::Ended
-        }
-    }
-
-    fn stop(&mut self) {
-        for seq in &mut self.tracks {
-            seq.stop();
-        }
-    }
-}
-
 /// Component to control the animation of another component.
 #[derive(Component)]
 pub struct Animator<T: Component> {
@@ -701,142 +376,50 @@ impl<T: Asset> AssetAnimator<T> {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use std::sync::{Arc, Mutex};
-
-    /// Utility to compare floating-point values with a tolerance.
-    fn abs_diff_eq(a: f32, b: f32, tol: f32) -> bool {
-        (a - b).abs() < tol
-    }
 
-    /// Test ticking of a single tween in isolation.
+    /// Animator::new()
     #[test]
-    fn tween_tick() {
-        for tweening_type in &[
-            TweeningType::Once,
-            TweeningType::Loop,
+    fn animator_new() {
+        let tween = Tween::new(
+            EaseFunction::QuadraticInOut,
             TweeningType::PingPong,
-        ] {
-            // Create a linear tween over 1 second
-            let mut tween = Tween::new(
-                EaseMethod::Linear,
-                *tweening_type,
-                Duration::from_secs_f32(1.0),
-                TransformPositionLens {
-                    start: Vec3::ZERO,
-                    end: Vec3::ONE,
-                },
-            );
-
-            // Register callbacks to count started/ended events
-            let started_count = Arc::new(Mutex::new(0));
-            let ended_count = Arc::new(Mutex::new(0));
-            let sc = Arc::clone(&started_count);
-            let ec = Arc::clone(&ended_count);
-            tween.set_started(move || {
-                let mut sc = sc.lock().unwrap();
-                *sc += 1;
-            });
-            tween.set_ended(move || {
-                let mut ec = ec.lock().unwrap();
-                *ec += 1;
-            });
-            assert_eq!(*started_count.lock().unwrap(), 0);
-            assert_eq!(*ended_count.lock().unwrap(), 0);
-
-            // Loop over 2.2 seconds, so greater than one ping-pong loop
-            let mut transform = Transform::default();
-            let tick_duration = Duration::from_secs_f32(0.2);
-            for i in 1..=11 {
-                // Calculate expected values
-                let (ratio, ec, dir) = match tweening_type {
-                    TweeningType::Once => {
-                        let r = (i as f32 * 0.2).min(1.0);
-                        let ec = if i >= 5 { 1 } else { 0 };
-                        (r, ec, TweeningDirection::Forward)
-                    }
-                    TweeningType::Loop => {
-                        let r = (i as f32 * 0.2).fract();
-                        let ec = i / 5;
-                        (r, ec, TweeningDirection::Forward)
-                    }
-                    TweeningType::PingPong => {
-                        let i10 = i % 10;
-                        let r = if i10 >= 5 {
-                            (10 - i10) as f32 * 0.2
-                        } else {
-                            i10 as f32 * 0.2
-                        };
-                        let ec = i / 10;
-                        let dir = if i10 >= 5 {
-                            TweeningDirection::Backward
-                        } else {
-                            TweeningDirection::Forward
-                        };
-                        (r, ec, dir)
-                    }
-                };
-                println!("Expected; r={} ec={} dir={:?}", ratio, ec, dir);
-
-                // Tick the tween
-                tween.tick(tick_duration, &mut transform);
-
-                // Check actual values
-                assert_eq!(tween.direction(), dir);
-                assert!(abs_diff_eq(tween.progress(), ratio, 1e-5));
-                assert!(transform.translation.abs_diff_eq(Vec3::splat(ratio), 1e-5));
-                assert!(transform.rotation.abs_diff_eq(Quat::IDENTITY, 1e-5));
-                assert_eq!(*started_count.lock().unwrap(), 1);
-                assert_eq!(*ended_count.lock().unwrap(), ec);
-            }
-        }
-    }
-
-    /// Test ticking a sequence of tweens.
-    #[test]
-    fn seq_tick() {
-        let tween1 = Tween::new(
-            EaseMethod::Linear,
-            TweeningType::Once,
-            Duration::from_secs_f32(1.0),
-            TransformPositionLens {
-                start: Vec3::ZERO,
-                end: Vec3::ONE,
-            },
-        );
-        let tween2 = Tween::new(
-            EaseMethod::Linear,
-            TweeningType::Once,
-            Duration::from_secs_f32(1.0),
+            std::time::Duration::from_secs(1),
             TransformRotationLens {
                 start: Quat::IDENTITY,
-                end: Quat::from_rotation_x(180_f32.to_radians()),
+                end: Quat::from_axis_angle(Vec3::Z, std::f32::consts::PI / 2.),
             },
         );
-        let mut seq = Sequence::from_single(tween1).then(tween2);
-        let mut transform = Transform::default();
-        for i in 1..=11 {
-            seq.tick(Duration::from_secs_f32(0.2), &mut transform);
-            if i <= 5 {
-                let r = i as f32 * 0.2;
-                assert_eq!(transform, Transform::from_translation(Vec3::splat(r)));
-            } else if i <= 10 {
-                let alpha_deg = (36 * (i - 5)) as f32;
-                assert!(transform.translation.abs_diff_eq(Vec3::splat(1.), 1e-5));
-                assert!(transform
-                    .rotation
-                    .abs_diff_eq(Quat::from_rotation_x(alpha_deg.to_radians()), 1e-5));
-            } else {
-                assert!(transform.translation.abs_diff_eq(Vec3::splat(1.), 1e-5));
-                assert!(transform
-                    .rotation
-                    .abs_diff_eq(Quat::from_rotation_x(180_f32.to_radians()), 1e-5));
-            }
+        let animator = Animator::new(tween);
+        assert_eq!(animator.state, AnimatorState::default());
+        let tween = animator.tweenable().unwrap();
+        assert_eq!(tween.progress(), 0.);
+    }
+
+    /// Animator::with_state()
+    #[test]
+    fn animator_with_state() {
+        for state in [AnimatorState::Playing, AnimatorState::Paused] {
+            let tween = Tween::new(
+                EaseFunction::QuadraticInOut,
+                TweeningType::PingPong,
+                std::time::Duration::from_secs(1),
+                TransformRotationLens {
+                    start: Quat::IDENTITY,
+                    end: Quat::from_axis_angle(Vec3::Z, std::f32::consts::PI / 2.),
+                },
+            );
+            let animator = Animator::new(tween).with_state(state);
+            assert_eq!(animator.state, state);
         }
     }
 
-    /// Animator::new()
+    /// Animator::default() + Animator::set_tweenable()
     #[test]
-    fn animator_new() {
+    fn animator_default() {
+        let mut animator = Animator::<Transform>::default();
+        assert!(animator.tweenable().is_none());
+        assert!(animator.tweenable_mut().is_none());
+
         let tween = Tween::new(
             EaseFunction::QuadraticInOut,
             TweeningType::PingPong,
@@ -846,10 +429,9 @@ mod tests {
                 end: Quat::from_axis_angle(Vec3::Z, std::f32::consts::PI / 2.),
             },
         );
-        let animator = Animator::new(tween);
-        assert_eq!(animator.state, AnimatorState::default());
-        let tween = animator.tweenable().unwrap();
-        assert_eq!(tween.progress(), 0.);
+        animator.set_tweenable(tween);
+        assert!(animator.tweenable().is_some());
+        assert!(animator.tweenable_mut().is_some());
     }
 
     /// AssetAnimator::new()
@@ -869,4 +451,46 @@ mod tests {
         let tween = animator.tweenable().unwrap();
         assert_eq!(tween.progress(), 0.);
     }
+
+    /// AssetAnimator::with_state()
+    #[test]
+    fn asset_animator_with_state() {
+        for state in [AnimatorState::Playing, AnimatorState::Paused] {
+            let tween = Tween::new(
+                EaseFunction::QuadraticInOut,
+                TweeningType::PingPong,
+                std::time::Duration::from_secs(1),
+                ColorMaterialColorLens {
+                    start: Color::RED,
+                    end: Color::BLUE,
+                },
+            );
+            let animator =
+                AssetAnimator::new(Handle::<ColorMaterial>::default(), tween).with_state(state);
+            assert_eq!(animator.state, state);
+        }
+    }
+
+    /// AssetAnimator::default() + AssetAnimator::set_tweenable()
+    #[test]
+    fn asset_animator_default() {
+        let mut animator = AssetAnimator::<ColorMaterial>::default();
+        assert!(animator.tweenable().is_none());
+        assert!(animator.tweenable_mut().is_none());
+        assert_eq!(animator.handle(), Handle::<ColorMaterial>::default());
+
+        let tween = Tween::new(
+            EaseFunction::QuadraticInOut,
+            TweeningType::PingPong,
+            std::time::Duration::from_secs(1),
+            ColorMaterialColorLens {
+                start: Color::RED,
+                end: Color::BLUE,
+            },
+        );
+        animator.set_tweenable(tween);
+        assert!(animator.tweenable().is_some());
+        assert!(animator.tweenable_mut().is_some());
+        assert_eq!(animator.handle(), Handle::<ColorMaterial>::default());
+    }
 }
diff --git a/src/tweenable.rs b/src/tweenable.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1341253a75c0405dbc512e67a575ae5605451d28
--- /dev/null
+++ b/src/tweenable.rs
@@ -0,0 +1,552 @@
+use bevy::prelude::*;
+use std::time::Duration;
+
+use crate::{EaseMethod, Lens, TweenState, TweeningDirection, TweeningType};
+
+/// An animatable entity, either a single [`Tween`] or a collection of them.
+pub trait Tweenable<T>: Send + Sync {
+    /// Get the total duration of the animation.
+    fn duration(&self) -> Duration;
+
+    /// Get the current progress in \[0:1\] of the animation.
+    fn progress(&self) -> f32;
+
+    /// Tick the animation, advancing it by the given delta time and mutating the
+    /// given target component or asset.
+    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState;
+
+    /// Stop the animation.
+    fn stop(&mut self);
+}
+
+impl<T> Tweenable<T> for Box<dyn Tweenable<T> + Send + Sync + 'static> {
+    fn duration(&self) -> Duration {
+        self.as_ref().duration()
+    }
+    fn progress(&self) -> f32 {
+        self.as_ref().progress()
+    }
+    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
+        self.as_mut().tick(delta, target)
+    }
+    fn stop(&mut self) {
+        self.as_mut().stop()
+    }
+}
+
+/// Trait for boxing a [`Tweenable`] trait object.
+pub trait IntoBoxDynTweenable<T> {
+    /// Convert the current object into a boxed [`Tweenable`].
+    fn into_box_dyn(this: Self) -> Box<dyn Tweenable<T> + Send + Sync + 'static>;
+}
+
+impl<T, U: Tweenable<T> + Send + Sync + 'static> IntoBoxDynTweenable<T> for U {
+    fn into_box_dyn(this: U) -> Box<dyn Tweenable<T> + Send + Sync + 'static> {
+        Box::new(this)
+    }
+}
+
+/// Single tweening animation instance.
+pub struct Tween<T> {
+    ease_function: EaseMethod,
+    timer: Timer,
+    state: TweenState,
+    tweening_type: TweeningType,
+    direction: TweeningDirection,
+    lens: Box<dyn Lens<T> + Send + Sync + 'static>,
+    on_started: Option<Box<dyn FnMut() + Send + Sync + 'static>>,
+    on_ended: Option<Box<dyn FnMut() + Send + Sync + 'static>>,
+}
+
+impl<T: 'static> Tween<T> {
+    /// Chain another [`Tweenable`] after this tween, making a sequence with the two.
+    pub fn then(self, tween: impl Tweenable<T> + Send + Sync + 'static) -> Sequence<T> {
+        Sequence::from_single(self).then(tween)
+    }
+}
+
+impl<T> Tween<T> {
+    /// Create a new tween animation.
+    pub fn new<L>(
+        ease_function: impl Into<EaseMethod>,
+        tweening_type: TweeningType,
+        duration: Duration,
+        lens: L,
+    ) -> Self
+    where
+        L: Lens<T> + Send + Sync + 'static,
+    {
+        Tween {
+            ease_function: ease_function.into(),
+            timer: Timer::new(duration, tweening_type != TweeningType::Once),
+            state: TweenState::Stopped,
+            tweening_type,
+            direction: TweeningDirection::Forward,
+            lens: Box::new(lens),
+            on_started: None,
+            on_ended: None,
+        }
+    }
+
+    /// The current animation direction.
+    ///
+    /// See [`TweeningDirection`] for details.
+    pub fn direction(&self) -> TweeningDirection {
+        self.direction
+    }
+
+    /// Set a callback invoked when the animation starts.
+    pub fn set_started<C>(&mut self, callback: C)
+    where
+        C: FnMut() + Send + Sync + 'static,
+    {
+        self.on_started = Some(Box::new(callback));
+    }
+
+    /// Clear the callback invoked when the animation starts.
+    pub fn clear_started(&mut self) {
+        self.on_started = None;
+    }
+
+    /// Set a callback invoked when the animation ends.
+    pub fn set_ended<C>(&mut self, callback: C)
+    where
+        C: FnMut() + Send + Sync + 'static,
+    {
+        self.on_ended = Some(Box::new(callback));
+    }
+
+    /// Clear the callback invoked when the animation ends.
+    pub fn clear_ended(&mut self) {
+        self.on_ended = None;
+    }
+
+    /// Is the animation playback looping?
+    pub fn is_looping(&self) -> bool {
+        self.tweening_type != TweeningType::Once
+    }
+}
+
+impl<T> Tweenable<T> for Tween<T> {
+    fn duration(&self) -> Duration {
+        self.timer.duration()
+    }
+
+    /// Current animation progress ratio between 0 and 1.
+    ///
+    /// For reversed playback ([`TweeningDirection::Backward`]), the ratio goes from 0 at the
+    /// end point (beginning of backward playback) to 1 at the start point (end of backward
+    /// playback).
+    fn progress(&self) -> f32 {
+        match self.direction {
+            TweeningDirection::Forward => self.timer.percent(),
+            TweeningDirection::Backward => self.timer.percent_left(),
+        }
+    }
+
+    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
+        let old_state = self.state;
+        if old_state == TweenState::Stopped {
+            self.state = TweenState::Running;
+            if let Some(cb) = &mut self.on_started {
+                cb();
+            }
+        }
+
+        self.timer.tick(delta);
+
+        // Toggle direction immediately, so self.progress() returns the correct ratio
+        if self.timer.just_finished() && self.tweening_type == TweeningType::PingPong {
+            self.direction = !self.direction;
+        }
+
+        let progress = self.progress();
+        let factor = self.ease_function.sample(progress);
+        self.lens.lerp(target, factor);
+
+        if self.timer.just_finished() {
+            self.state = TweenState::Ended;
+            // This is always true for non ping-pong, and is true for ping-pong when
+            // coming back to start after a full cycle start -> end -> start.
+            if self.direction == TweeningDirection::Forward {
+                if let Some(cb) = &mut self.on_ended {
+                    cb();
+                }
+            }
+        }
+
+        self.state
+    }
+
+    fn stop(&mut self) {
+        self.state = TweenState::Stopped;
+        self.timer.reset();
+    }
+}
+
+/// A sequence of tweens played back in order one after the other.
+pub struct Sequence<T> {
+    tweens: Vec<Box<dyn Tweenable<T> + Send + Sync + 'static>>,
+    index: usize,
+    state: TweenState,
+    duration: Duration,
+    time: Duration,
+}
+
+impl<T> Sequence<T> {
+    /// Create a new sequence of tweens.
+    pub fn new(items: impl IntoIterator<Item = impl IntoBoxDynTweenable<T>>) -> Self {
+        let tweens: Vec<_> = items
+            .into_iter()
+            .map(IntoBoxDynTweenable::into_box_dyn)
+            .collect();
+        let duration = tweens.iter().map(|t| t.duration()).sum();
+        Sequence {
+            tweens,
+            index: 0,
+            state: TweenState::Stopped,
+            duration,
+            time: Duration::from_secs(0),
+        }
+    }
+
+    /// Create a new sequence containing a single tween.
+    pub fn from_single(tween: impl Tweenable<T> + Send + Sync + 'static) -> Self {
+        let duration = tween.duration();
+        Sequence {
+            tweens: vec![Box::new(tween)],
+            index: 0,
+            state: TweenState::Stopped,
+            duration,
+            time: Duration::from_secs(0),
+        }
+    }
+
+    /// Append a [`Tweenable`] to this sequence.
+    pub fn then(mut self, tween: impl Tweenable<T> + Send + Sync + 'static) -> Self {
+        self.duration += tween.duration();
+        self.tweens.push(Box::new(tween));
+        self
+    }
+
+    /// Index of the current active tween in the sequence.
+    pub fn index(&self) -> usize {
+        self.index.min(self.tweens.len() - 1)
+    }
+
+    /// Get the current active tween in the sequence.
+    pub fn current(&self) -> &dyn Tweenable<T> {
+        self.tweens[self.index()].as_ref()
+    }
+}
+
+impl<T> Tweenable<T> for Sequence<T> {
+    fn duration(&self) -> Duration {
+        self.duration
+    }
+
+    fn progress(&self) -> f32 {
+        self.time.as_secs_f32() / self.duration.as_secs_f32()
+    }
+
+    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
+        if self.index < self.tweens.len() {
+            self.time = (self.time + delta).min(self.duration);
+            let tween = &mut self.tweens[self.index];
+            let state = tween.tick(delta, target);
+            if state == TweenState::Ended {
+                tween.stop();
+                self.index += 1;
+                if self.index >= self.tweens.len() {
+                    self.state = TweenState::Ended;
+                }
+            }
+        }
+        self.state
+    }
+
+    fn stop(&mut self) {
+        if self.state != TweenState::Stopped {
+            self.state = TweenState::Stopped;
+            if self.index < self.tweens.len() {
+                let tween = &mut self.tweens[self.index];
+                tween.stop();
+            }
+        }
+    }
+}
+
+/// A collection of [`Tweenable`] executing in parallel.
+pub struct Tracks<T> {
+    tracks: Vec<Box<dyn Tweenable<T> + Send + Sync + 'static>>,
+    duration: Duration,
+    time: Duration,
+}
+
+impl<T> Tracks<T> {
+    /// Create a new [`Tracks`] from an iterator over a collection of [`Tweenable`].
+    pub fn new(items: impl IntoIterator<Item = impl IntoBoxDynTweenable<T>>) -> Self {
+        let tracks: Vec<_> = items
+            .into_iter()
+            .map(IntoBoxDynTweenable::into_box_dyn)
+            .collect();
+        let duration = tracks.iter().map(|t| t.duration()).max().unwrap();
+        Tracks {
+            tracks,
+            duration,
+            time: Duration::from_secs(0),
+        }
+    }
+}
+
+impl<T> Tweenable<T> for Tracks<T> {
+    fn duration(&self) -> Duration {
+        self.duration
+    }
+
+    fn progress(&self) -> f32 {
+        self.time.as_secs_f32() / self.duration.as_secs_f32()
+    }
+
+    fn tick(&mut self, delta: Duration, target: &mut T) -> TweenState {
+        let mut any_running = true;
+        for tweenable in &mut self.tracks {
+            any_running = any_running && (tweenable.tick(delta, target) == TweenState::Running);
+        }
+        if any_running {
+            self.time = (self.time + delta).min(self.duration);
+            TweenState::Running
+        } else {
+            TweenState::Ended
+        }
+    }
+
+    fn stop(&mut self) {
+        for seq in &mut self.tracks {
+            seq.stop();
+        }
+    }
+}
+
+
+/// A collection of [`Tweenable`] executing in parallel.
+pub struct Delay {
+    timer: Timer,
+}
+
+impl Delay {
+    /// Create a new [`Tracks`] from an iterator over a collection of [`Tweenable`].
+    pub fn new(duration: Duration) -> Self {
+        Delay {
+            timer: Timer::new(duration, false),
+        }
+    }
+
+    /// Chain another [`Tweenable`] after this tween, making a sequence with the two.
+    pub fn then<T>(self, tween: impl Tweenable<T> + Send + Sync + 'static) -> Sequence<T> {
+        Sequence::from_single(self).then(tween)
+    }
+}
+
+impl<T> Tweenable<T> for Delay {
+    fn duration(&self) -> Duration {
+        self.timer.duration()
+    }
+
+    fn progress(&self) -> f32 {
+        self.timer.percent()
+    }
+
+    fn tick(&mut self, delta: Duration, _: &mut T) -> TweenState {
+        self.timer.tick(delta);
+        if self.timer.finished() {
+            TweenState::Ended
+        } else {
+            TweenState::Running
+        }
+    }
+
+    fn stop(&mut self) {
+        self.timer.reset();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{TransformPositionLens, TransformRotationLens};
+    use std::sync::{Arc, Mutex};
+    use std::time::Duration;
+
+    /// Utility to compare floating-point values with a tolerance.
+    fn abs_diff_eq(a: f32, b: f32, tol: f32) -> bool {
+        (a - b).abs() < tol
+    }
+
+    /// Test ticking of a single tween in isolation.
+    #[test]
+    fn tween_tick() {
+        for tweening_type in &[
+            TweeningType::Once,
+            TweeningType::Loop,
+            TweeningType::PingPong,
+        ] {
+            // Create a linear tween over 1 second
+            let mut tween = Tween::new(
+                EaseMethod::Linear,
+                *tweening_type,
+                Duration::from_secs_f32(1.0),
+                TransformPositionLens {
+                    start: Vec3::ZERO,
+                    end: Vec3::ONE,
+                },
+            );
+
+            // Register callbacks to count started/ended events
+            let started_count = Arc::new(Mutex::new(0));
+            let ended_count = Arc::new(Mutex::new(0));
+            let sc = Arc::clone(&started_count);
+            let ec = Arc::clone(&ended_count);
+            tween.set_started(move || {
+                let mut sc = sc.lock().unwrap();
+                *sc += 1;
+            });
+            tween.set_ended(move || {
+                let mut ec = ec.lock().unwrap();
+                *ec += 1;
+            });
+            assert_eq!(*started_count.lock().unwrap(), 0);
+            assert_eq!(*ended_count.lock().unwrap(), 0);
+
+            // Loop over 2.2 seconds, so greater than one ping-pong loop
+            let mut transform = Transform::default();
+            let tick_duration = Duration::from_secs_f32(0.2);
+            for i in 1..=11 {
+                // Calculate expected values
+                let (ratio, ec, dir) = match tweening_type {
+                    TweeningType::Once => {
+                        let r = (i as f32 * 0.2).min(1.0);
+                        let ec = if i >= 5 { 1 } else { 0 };
+                        (r, ec, TweeningDirection::Forward)
+                    }
+                    TweeningType::Loop => {
+                        let r = (i as f32 * 0.2).fract();
+                        let ec = i / 5;
+                        (r, ec, TweeningDirection::Forward)
+                    }
+                    TweeningType::PingPong => {
+                        let i10 = i % 10;
+                        let r = if i10 >= 5 {
+                            (10 - i10) as f32 * 0.2
+                        } else {
+                            i10 as f32 * 0.2
+                        };
+                        let ec = i / 10;
+                        let dir = if i10 >= 5 {
+                            TweeningDirection::Backward
+                        } else {
+                            TweeningDirection::Forward
+                        };
+                        (r, ec, dir)
+                    }
+                };
+                println!("Expected; r={} ec={} dir={:?}", ratio, ec, dir);
+
+                // Tick the tween
+                tween.tick(tick_duration, &mut transform);
+
+                // Check actual values
+                assert_eq!(tween.direction(), dir);
+                assert!(abs_diff_eq(tween.progress(), ratio, 1e-5));
+                assert!(transform.translation.abs_diff_eq(Vec3::splat(ratio), 1e-5));
+                assert!(transform.rotation.abs_diff_eq(Quat::IDENTITY, 1e-5));
+                assert_eq!(*started_count.lock().unwrap(), 1);
+                assert_eq!(*ended_count.lock().unwrap(), ec);
+            }
+        }
+    }
+
+    /// Test ticking a sequence of tweens.
+    #[test]
+    fn seq_tick() {
+        let tween1 = Tween::new(
+            EaseMethod::Linear,
+            TweeningType::Once,
+            Duration::from_secs_f32(1.0),
+            TransformPositionLens {
+                start: Vec3::ZERO,
+                end: Vec3::ONE,
+            },
+        );
+        let tween2 = Tween::new(
+            EaseMethod::Linear,
+            TweeningType::Once,
+            Duration::from_secs_f32(1.0),
+            TransformRotationLens {
+                start: Quat::IDENTITY,
+                end: Quat::from_rotation_x(180_f32.to_radians()),
+            },
+        );
+        let mut seq = tween1.then(tween2);
+        let mut transform = Transform::default();
+        for i in 1..=16 {
+            seq.tick(Duration::from_secs_f32(0.2), &mut transform);
+            if i <= 5 {
+                let r = i as f32 * 0.2;
+                assert_eq!(transform, Transform::from_translation(Vec3::splat(r)));
+            } else if i <= 10 {
+                let alpha_deg = (36 * (i - 5)) as f32;
+                assert!(transform.translation.abs_diff_eq(Vec3::splat(1.), 1e-5));
+                assert!(transform
+                    .rotation
+                    .abs_diff_eq(Quat::from_rotation_x(alpha_deg.to_radians()), 1e-5));
+            } else {
+                assert!(transform.translation.abs_diff_eq(Vec3::splat(1.), 1e-5));
+                assert!(transform
+                    .rotation
+                    .abs_diff_eq(Quat::from_rotation_x(180_f32.to_radians()), 1e-5));
+            }
+        }
+    }
+
+    /// Test ticking parallel tracks of tweens.
+    #[test]
+    fn tracks_tick() {
+        let tween1 = Tween::new(
+            EaseMethod::Linear,
+            TweeningType::Once,
+            Duration::from_secs_f32(1.0),
+            TransformPositionLens {
+                start: Vec3::ZERO,
+                end: Vec3::ONE,
+            },
+        );
+        let tween2 = Tween::new(
+            EaseMethod::Linear,
+            TweeningType::Once,
+            Duration::from_secs_f32(0.8),
+            TransformRotationLens {
+                start: Quat::IDENTITY,
+                end: Quat::from_rotation_x(180_f32.to_radians()),
+            },
+        );
+        let mut tracks = Tracks::new([tween1, tween2]);
+        let mut transform = Transform::default();
+        for i in 1..=6 {
+            tracks.tick(Duration::from_secs_f32(0.2), &mut transform);
+            if i <= 5 {
+                let r = i as f32 * 0.2;
+                let alpha_deg = (45 * i.min(4)) as f32;
+                assert!(transform.translation.abs_diff_eq(Vec3::splat(r), 1e-5));
+                assert!(transform
+                    .rotation
+                    .abs_diff_eq(Quat::from_rotation_x(alpha_deg.to_radians()), 1e-5));
+            } else {
+                assert!(transform.translation.abs_diff_eq(Vec3::splat(1.), 1e-5));
+                assert!(transform
+                    .rotation
+                    .abs_diff_eq(Quat::from_rotation_x(180_f32.to_radians()), 1e-5));
+            }
+        }
+    }
+}