Motion

SDK baseline. Look customization. Typed control roles and consumer overrides.

Controls define roles; looks set defaults

The SDK owns interaction targets, accessibility, events, presence, and frame scheduling. Each control exposes a typed motion profile: Switch has state; Tabs has indicator, content, disclosure, and drop_layout. A look supplies transitions for those roles. Consumers override individual roles through the SDK’s .motion(...) builder method.

Shadcn and Radix use the same SDK profiles. A look author can change timing, easing, or physics without rewriting control behavior.

Build with the SDK alone

SDK controls have vanilla templates and motion defaults; an application does not need Shadcn or Radix. Motion values are authored in Rust in the SDK’s theme/stylesheet/motion.rs. CommonStylesheet::sdk_default() exposes the cached native stylesheet; MotionDefaults::vanilla() exposes its cached motion profiles. MotionDefaults::default() clones those profiles. Use CommonStylesheet::motion_defaults() to resolve application-authored settings, then supply a component profile through motion_defaults(...).

SDK-only application · integration example
fn notifications<M: 'static>(
    cx: &mut gpui::Context<M>,
) -> Result<xgpui_luma::controls::switch::Switch, Box<dyn std::error::Error>> {
    use xgpui_luma::{controls::switch, theme::stylesheet::CommonStylesheet};
    // No look dependency is required: switch::new("id").spawn(cx) works as-is.
    let sheet = CommonStylesheet::parse(r#"
[common.motion.switch.state]
kind = "spring"
stiffness = 300.0
damping = 28.0
"#)?;
    Ok(switch::new("notifications")
        .motion_defaults(sheet.motion_defaults().switch)
        .spawn(cx))
}

Configure roles in Rust

Both looks inherit the native SDK baseline before applying their own common stylesheet. Clone the baseline and set only the roles you want to change. Nested presence roles expose independent enter and exit definitions.

Rust · shared motion roles
fn configured_motion() -> xgpui_luma::theme::MotionDefaults {
    use xgpui_luma::{motion::Easing, theme::stylesheet::{CommonStylesheet, TransitionSpec}};
    let sheet = CommonStylesheet::sdk_default().clone().with(|sheet| {
        sheet.motion.switch.state = Some(TransitionSpec::Timed {
            duration_ms: 160,
            easing: Easing::OutCubic,
        });
        sheet.motion.tabs.indicator = Some(TransitionSpec::Spring {
            stiffness: 300.0,
            damping: 28.0,
        });
        sheet.motion.tabs.content = Some(TransitionSpec::Instant {});
        sheet.motion.progress.period_ms = Some(1200);
    });
    sheet.motion_defaults()
}

Each authored transition replaces its complete role. Missing roles inherit. Timed definitions specify duration and easing; springs use positive finite stiffness and damping. Instant transitions and zero durations settle immediately. A zero loop period stops the phase. External TOML loading remains available through the same typed validation path.

Set defaults and override a control

Shadcn · integration example
use std::time::Duration;
use gpui::{Context, Entity};
use xgpui_luma::{controls::tabs::Tabs, motion::{Easing, Transition}};
use xgpui_luma_look_shadcn::{self as shadcn, ShadcnLook};

fn settings<M: 'static>(look: &ShadcnLook, cx: &mut Context<M>) -> Entity<Tabs> {
    // Configure once when setting up the look, before creating controls.
    look.motion(|m| {
        m.switch.state = Transition::timed(
            Duration::from_millis(160), Easing::OutCubic,
        );
        m.tabs.indicator = Transition::spring(300.0, 28.0);
    });

    shadcn::Tabs::new("settings")
        .look(look)
        .motion(|m| m.content = Transition::instant())
        .spawn(cx)
}

The tab indicator inherits the look’s spring; only content motion becomes immediate. Radix’s Look::motion and Tabs builder expose the same configuration pattern.

Inheritance and lifetime

  1. The SDK’s native Rust baseline provides every motion profile.
  2. Authored common.motion roles override the SDK baseline.
  3. Look-level motion edits apply in registration order.
  4. The control snapshots the look profile at spawn.
  5. Instance .motion edits run in registration order.
  6. motion_enabled(false) makes all roles immediate while retaining the authored profile.

Assigning a role replaces its complete transition. Untouched roles inherit. The order of .look(...) and .motion(...) calls does not change precedence. Transition::default() explicitly means 200 ms cubic out; it does not mean “inherit.”

Existing controls retain their snapshot when the look changes; later controls receive the new defaults. Persistent ListBox controls capture defaults on first composition and reuse them. Look clones share edits; Radix forks and Shadcn independent copies isolate subsequent edits.

Choose a transition

Transition constructors
fn transition_choices() {
    use std::time::Duration;
    use xgpui_luma::motion::{Easing, Transition};
    
    let timed = Transition::timed(Duration::from_millis(180), Easing::OutCubic);
    let spring = Transition::spring(300.0, 28.0); // stiffness, damping; unit mass
    let immediate = Transition::instant();
}

The SDK stylesheet’s common.motion.transition defines Transition::default() for standalone helpers. Control profiles use their explicit roles from the same file.

Built-in easing is Linear or OutCubic. Springs support under-, critically-, and over-damped motion. Stiffness and damping must be positive and finite; invalid parameters settle immediately.

On interruption, motion starts from the last displayed position. Springs also retain velocity. Timed transitions begin a new easing curve from that position. Position may overshoot; bounded paint properties such as opacity clamp their displayed values.

Closing content stays painted until its exit transition completes, including when a spring crosses zero before settling. Completion snaps to the exact target and stops frame requests.

Disable motion or one role

Control-wide policy and a role-specific override
fn motion_policy() {
    use xgpui_luma::{controls::tabs::Tabs, motion::Transition};
    
    let tabs = Tabs::new("settings").motion_enabled(false);
    let tabs_with_instant_disclosure = Tabs::new("files")
        .motion(|m| m.disclosure = Transition::instant());
}

Control-wide disabling includes looping progress and TreeView positioning. Re-enabling uses the retained profile on subsequent interactions. Applications can route their reduced-motion preference through motion_enabled(false); the SDK does not detect platform preferences automatically.

To keep other roles animated, override only the role you want to disable. ControlGroup selection animation remains opt-in; enable it with motion_enabled(true). Slider dragging continues to snap directly.

Roles and preserved defaults

ControlRolesPreserved defaults
Checkbox, Radio, Switch, Togglestate200 ms cubic out
ControlGroup; IconGroup, checkbox/radio/menu groupsselection200 ms cubic out; selection animation remains opt-in
Sliderthumb200 ms cubic out; direct dragging still snaps
Progressvalue, period200 ms cubic out; indeterminate phase 1200 ms
Stepperselection200 ms cubic out
Tabsindicator, content, disclosure, drop_layoutIndicator/disclosure 200 ms; content instant in the SDK, 300 ms in Shadcn and Radix
Accordionexpansion, drop_layoutShared expansion/disclosure progress, 200 ms
TreeViewexpansion, position, drop_layout200 ms; virtualization still settles very large expansions
Sidebar and its internal engineexpansion, popup, drop_layoutExpansion and rail presence 200 ms
SplitView, ResizablePanelsextent200 ms
PopupMenupresence, highlight, disclosure, submenuPresence/disclosure/submenu 200 ms; highlight 180 ms
ContextMenupresence, submenu, disclosureMain/submenu enter and disclosure 200 ms; both exits immediate
PopoverButton/PopoverPanel; Radix HoverCardpresenceEnter/exit 200 ms
Selector, SearchSelector, Autocomplete, ComboBoxpresence; SearchSelector also disclosureEnter/disclosure 200 ms; immediate exit
Tabledrop_layout200 ms cubic out
ListBoxscroll, drop_layoutScroll and layout 200 ms cubic out
ScrollContainerscroll200 ms
SlidePanelStatepresenceEnter/exit 220 ms
TabsOverlayState (app-owned tab dropdown/overflow host)enter, exit200 ms

Collection drop_layout and Tabs content roles use the same typed configuration as other motion. period is a loop duration rather than a settling transition.

Hover delays, tooltip hide delays, and scrollbar auto-hide timers remain interaction policy.

Supplying defaults in a look

Look adapters supply an SDK builder’s motion_defaults(profile) from their resolved MotionDefaults, then forward consumer edits. The SDK resolves inheritance and overrides; look wrappers need no animation engines or per-parameter setters. MotionDefaults::from_stylesheet(&sheet) resolves the standard baseline. Each control’s Default profile reads the same cached SDK baseline, so values are not duplicated in Rust. Look authors with a custom base can overlay only authored roles using sheet.motion.apply_to(&mut defaults). These are public SDK APIs, available without either bundled look.

Both looks provide slide_panel_state and tabs_overlay_state helpers to seed app-owned state. Shadcn also provides scroll_container. Configure these states before first use. SDK-only composition can pass the appropriate component profile from look.motion_defaults() explicitly.

Plug in a custom implementation

SDK extension contract · signatures
pub trait MotionDefinition: std::fmt::Debug + Send + Sync {
    fn start(&self, current: MotionState, target: f32) -> Box<dyn RunningMotion>;

    fn retarget(
        &self, current: MotionState, target: f32, elapsed: Duration,
    ) -> Box<dyn RunningMotion> {
        self.start(current, target)
    }
}

pub trait RunningMotion: std::fmt::Debug + Send + Sync {
    fn sample(&self, elapsed: Duration) -> MotionSample;
}

Wrap your implementation with Transition::custom(definition) and assign it to any transition role. The control continues using the same runtime path as timed and spring motion.

  • MotionState contains scalar value and velocity in units per second.
  • MotionSample contains state and complete.
  • Sampling is deterministic and immutable, using absolute elapsed time since start rather than frame deltas.
  • Completion must return the exact target with zero velocity.
  • retarget handles changing measured geometry within an interaction. Timed transitions retain the original deadline; springs preserve velocity. Custom definitions choose their own policy.

The SDK’s VisualTransition owns the clock and delegates sampling. Controls do not branch on transition types. Invalid samples settle safely.

Timing versus panel rendering

A Transition defines scalar timing, easing or physics; it does not choose a panel layout or slide direction. For Tabs, assign timing to TabsMotion::content and supply custom presentation with TabsContentTransition. The SDK and Radix builders expose this renderer independently from TabsContentLayout.

Use TabsContentTransition::new for stateless rendering, or with_factory and TabsTransitionRenderer for per-instance state and interruption-aware retargeting. The SDK retains retiring panels and schedules only the body; renderer selection notifications expose the prior progress so the consumer can preserve visual positions. Disabled motion and zero-duration roles settle immediately. Bounds measurement is opt-in.

See Tabs content transitions for a compiled example, lifecycle, focus and interaction rules, and the Radix Studio implementation. Studio's local 400 ms screen-slide setting does not change the Radix look's default content timing.

Inspect motion

Use motion_profile() to inspect the authored role definitions. Use a role’s Transition::description() to inspect timed parameters, spring parameters, instant, or custom motion. The authored profile remains available while motion_enabled(false) makes interactions immediate.

On a spawned control, motion_enabled() reports its control-wide motion policy separately from motion_profile(). A disabled control keeps its authored transitions even though interactions settle immediately. An enabled policy can still contain individual instant roles.

Luma Studio’s Theme Inspector shows each displayed example’s captured motion profile alongside the current Look defaults. The example captures its profile at spawn, including per-instance overrides. Editing the Look later changes the default column; it does not rewrite the existing example’s captured profile. Compare both columns and the enabled policy when diagnosing a timing difference.

See also Create your own Look, Tabs, and Switch.