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(...).
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.
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
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
- The SDK’s native Rust baseline provides every motion profile.
- Authored
common.motionroles override the SDK baseline. - Look-level
motionedits apply in registration order. - The control snapshots the look profile at spawn.
- Instance
.motionedits run in registration order. 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
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
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
| Control | Roles | Preserved defaults |
|---|---|---|
| Checkbox, Radio, Switch, Toggle | state | 200 ms cubic out |
| ControlGroup; IconGroup, checkbox/radio/menu groups | selection | 200 ms cubic out; selection animation remains opt-in |
| Slider | thumb | 200 ms cubic out; direct dragging still snaps |
| Progress | value, period | 200 ms cubic out; indeterminate phase 1200 ms |
| Stepper | selection | 200 ms cubic out |
| Tabs | indicator, content, disclosure, drop_layout | Indicator/disclosure 200 ms; content instant in the SDK, 300 ms in Shadcn and Radix |
| Accordion | expansion, drop_layout | Shared expansion/disclosure progress, 200 ms |
| TreeView | expansion, position, drop_layout | 200 ms; virtualization still settles very large expansions |
| Sidebar and its internal engine | expansion, popup, drop_layout | Expansion and rail presence 200 ms |
| SplitView, ResizablePanels | extent | 200 ms |
| PopupMenu | presence, highlight, disclosure, submenu | Presence/disclosure/submenu 200 ms; highlight 180 ms |
| ContextMenu | presence, submenu, disclosure | Main/submenu enter and disclosure 200 ms; both exits immediate |
| PopoverButton/PopoverPanel; Radix HoverCard | presence | Enter/exit 200 ms |
| Selector, SearchSelector, Autocomplete, ComboBox | presence; SearchSelector also disclosure | Enter/disclosure 200 ms; immediate exit |
| Table | drop_layout | 200 ms cubic out |
| ListBox | scroll, drop_layout | Scroll and layout 200 ms cubic out |
| ScrollContainer | scroll | 200 ms |
| SlidePanelState | presence | Enter/exit 220 ms |
| TabsOverlayState (app-owned tab dropdown/overflow host) | enter, exit | 200 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.
Plug in a custom implementation
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.
MotionStatecontains scalarvalueandvelocityin units per second.MotionSamplecontainsstateandcomplete.- Sampling is deterministic and immutable, using absolute elapsed time since start rather than frame deltas.
- Completion must return the exact target with zero velocity.
retargethandles 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.