diff --git a/.changeset/view-transitions.md b/.changeset/view-transitions.md
new file mode 100644
index 00000000..7eced04c
--- /dev/null
+++ b/.changeset/view-transitions.md
@@ -0,0 +1,31 @@
+---
+"@gpuix/native": minor
+"@gpuix/react": minor
+---
+
+Add the View Transitions API.
+
+`startViewTransition(renderer, update, options)` captures every element that
+carries a `viewTransitionName`, applies the React update synchronously, and
+animates each name from its old place to its new one. The renderer clones the
+named subtrees before the update and paints the frozen copies over the live
+tree while the transition runs, so the leaving screen stays visible under, or
+over, the arriving one.
+
+Options take a duration, a delay, and an ease per name, plus `translateX`,
+`translateY`, `opacity` and `blur` ranges for the old side and the new side.
+Percent lengths resolve against the size of the named element, so
+`translateX: ["100%", "0%"]` slides a screen in from the right at any width.
+A name with no options crossfades. A name that only enters animates against
+its own bounds. A name that only leaves paints its frozen copy over the tree
+while the `old` side runs, without the clip of its former ancestors.
+
+The `motion` prop takes a `blur` field too: a `filter: blur()` sigma in
+pixels that interpolates like `opacity`.
+
+The new side moves through the motion channel, so the live element and its
+hitboxes move together, and input lands where the screen paints. The frozen
+copy takes fresh ids where the live tree still uses them, so a surviving
+element and its copy never share GPUI element state.
+
+Limits in this version: the frozen copy takes no input.
diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx
index 60a97b98..7c925d65 100644
--- a/examples/demo.test.tsx
+++ b/examples/demo.test.tsx
@@ -23,6 +23,7 @@ import { Inheritance } from "./demo/inheritance"
import { Lengths } from "./demo/lengths"
import { motion } from "@gpuix/react"
import { Motion } from "./demo/motion-panel"
+import { Navigation } from "./demo/navigation"
import { IntoView, Scrollbars } from "./demo/scrollbars"
import { Selectors } from "./demo/selectors"
import { Variables } from "./demo/variables"
@@ -51,6 +52,7 @@ const PANELS = [
["selectors", ],
["motion", ],
["scrollbars", ],
+ ["navigation", ],
] as const
describeNative("demo panels", () => {
@@ -322,6 +324,50 @@ describeNative("the scrollbars panel", () => {
})
})
+describeNative("the navigation panel", () => {
+ it("pushes the General screen from the right and pops it back", () => {
+ const test = root()
+ test.render(
+
+
+
+ )
+ test.renderer.clockPause()
+
+ const general = test.renderer.findByTestId("nav-row-General")!
+ const [gx, gy] = test.renderer.getElementBounds(general.id)!
+ test.renderer.nativeSimulateClick(gx + 4, gy + 4)
+
+ // At the start of the push, the General screen sits one screen width to
+ // the right of where it will rest. The phone is 320 wide with a 1px
+ // border on each side, so the screen is 318.
+ const about = test.renderer.findByText("About")!
+ const startX = test.renderer.getElementBounds(about.id)![0]
+ test.renderer.clockFastForward(600)
+ const endX = test.renderer.getElementBounds(about.id)![0]
+ expect(startX - endX).toBeCloseTo(318, 0)
+
+ const back = test.renderer.findByTestId("nav-back")!
+ const [bx, by] = test.renderer.getElementBounds(back.id)!
+ test.renderer.nativeSimulateClick(bx + 4, by + 4)
+ test.renderer.clockFastForward(600)
+ expect(test.renderer.findByTestId("nav-row-General")).toBeDefined()
+ expect(test.renderer.findByText("About")).toBeUndefined()
+
+ test.renderer.clockResume()
+ test.unmount()
+ })
+})
+
describeNative("the whole application", () => {
/// Walk the sidebar and paint each section, so the whole application is
/// covered rather than the one it opens on. The test renderer has the frame
@@ -331,7 +377,7 @@ describeNative("the whole application", () => {
test.render( )
expect(test.renderer.getPaintedText()).toContain("GPUIX")
- for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Performance", "Colours"]) {
+ for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Navigation", "Performance", "Colours"]) {
const item = test.renderer.findByText(title)
expect(item, `no sidebar item named ${title}`).toBeDefined()
const bounds = test.renderer.getElementBounds(item!.id)
diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx
index d5f7a2e2..d5a9c874 100644
--- a/examples/demo/app.tsx
+++ b/examples/demo/app.tsx
@@ -16,6 +16,7 @@ import { Effects } from "./effects.js"
import { Inheritance } from "./inheritance.js"
import { Lengths } from "./lengths.js"
import { Motion } from "./motion-panel.js"
+import { Navigation } from "./navigation.js"
import { frameOverlay, Perf } from "./perf.js"
import { Scrollbars } from "./scrollbars.js"
import { Selectors } from "./selectors.js"
@@ -80,6 +81,7 @@ const SECTIONS = [
{ id: "selectors", title: "Selectors", render: () => },
{ id: "motion", title: "Motion", render: () => },
{ id: "scrollbars", title: "Scrollbars", render: () => },
+ { id: "navigation", title: "Navigation", render: () => },
] as const
type SectionId = (typeof SECTIONS)[number]["id"] | "perf"
diff --git a/examples/demo/navigation.tsx b/examples/demo/navigation.tsx
new file mode 100644
index 00000000..9ddf384c
--- /dev/null
+++ b/examples/demo/navigation.tsx
@@ -0,0 +1,220 @@
+/// View transitions, shown as the push and pop of the iOS Settings app.
+///
+/// The header stays mounted the whole time, and only its content takes part
+/// in the transition. The back button enters and leaves through a blur and
+/// opacity pair. The title of each screen carries the name "nav-title", so
+/// the old text blurs and fades out in place while the new text sharpens in,
+/// a text morph. The screens slide under the header as a pair, and a
+/// backdrop blur with an eased mask blurs the rows progressively where they
+/// pass under it.
+
+import React, { useState } from "react"
+import { startViewTransition, useGpuix } from "@gpuix/react"
+import type { NativeRenderer, ViewTransitionOptions } from "@gpuix/react"
+import { Panel } from "./ui.js"
+
+const HEADER_HEIGHT = 56
+/// How far past the bar the backdrop blur fades out.
+const BLUR_TAIL = 28
+/// A spring without bounce: fast out of the gate, a long soft landing,
+/// and no overshoot, like a critically damped UIKit spring.
+const SPRING: [number, number, number, number] = [0.36, 0.66, 0.04, 1]
+
+const PUSH: ViewTransitionOptions = {
+ duration: 0.45,
+ ease: SPRING,
+ groups: {
+ screen: {
+ old: { translateX: ["0%", "-30%"] },
+ new: { translateX: ["100%", "0%"] },
+ },
+ "nav-back": { new: { opacity: [0, 1], blur: [6, 0] } },
+ "nav-title": {
+ old: { opacity: [1, 0], blur: [0, 4] },
+ new: { opacity: [0, 1], blur: [4, 0] },
+ },
+ },
+}
+
+const POP: ViewTransitionOptions = {
+ duration: 0.45,
+ ease: SPRING,
+ groups: {
+ screen: {
+ old: { translateX: ["0%", "100%"], onTop: true },
+ new: { translateX: ["-30%", "0%"] },
+ },
+ "nav-back": { old: { opacity: [1, 0], blur: [0, 6] } },
+ "nav-title": {
+ old: { opacity: [1, 0], blur: [0, 4] },
+ new: { opacity: [0, 1], blur: [4, 0] },
+ },
+ },
+}
+
+const ROOT_ROWS = [
+ "General", "Display", "Sound", "Focus", "Battery",
+ "Privacy", "Wallpaper", "Siri", "Wallet", "Accounts",
+ "App Store", "Game Center", "Developer",
+]
+const GENERAL_ROWS = [
+ "About", "Software Update", "Storage", "AppleCare", "AirDrop",
+ "AirPlay", "Picture in Picture", "CarPlay", "Keyboard", "Fonts",
+ "Language", "Dictionary", "VPN", "Legal",
+]
+
+function NavRow({ label, detail, onClick }: {
+ label: string
+ detail?: string
+ onClick?: () => void
+}) {
+ return (
+
+ {label}
+ {detail ?? (onClick ? ">" : "")}
+
+ )
+}
+
+/// The header that never unmounts. The first layer is the progressive blur:
+/// a backdrop blur whose eased mask fades it out past the bar, so the rows
+/// blur where they pass under it. The title and the back button sit on top
+/// of that layer, and each carries its own view transition name.
+function Header({ screen, onBack }: {
+ screen: "root" | "general"
+ onBack: () => void
+}) {
+ const title = screen === "root" ? "Settings" : "General"
+ return (
+ <>
+
+
+
+ {screen === "general" ? (
+
+ {"< Settings"}
+
+ ) : null}
+
+ >
+ )
+}
+
+/// One screen of the stack. The name pairs it with the screen it replaces,
+/// and the key makes React mount a new element instead of an update in
+/// place, the way a real navigation swaps components. The top padding puts
+/// the first row under the header, and the rows scroll under it.
+function Screen({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function Phone({ renderer }: { renderer: NativeRenderer | null }) {
+ const [screen, setScreen] = useState<"root" | "general">("root")
+ const go = (next: "root" | "general", options: ViewTransitionOptions) => {
+ if (renderer) {
+ startViewTransition(renderer, () => setScreen(next), options)
+ } else {
+ setScreen(next)
+ }
+ }
+
+ return (
+
+ {screen === "root" ? (
+
+ {ROOT_ROWS.map((label) => (
+ go("general", PUSH) : undefined}
+ />
+ ))}
+
+ ) : (
+
+ {GENERAL_ROWS.map((label) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+export function Navigation() {
+ const { renderer } = useGpuix()
+ return (
+
+
+
+ )
+}
diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts
index eaf27b81..117f49f7 100644
--- a/packages/native/index.d.ts
+++ b/packages/native/index.d.ts
@@ -75,6 +75,17 @@ export declare class GpuixRenderer {
* box apply.
*/
scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void
+ /**
+ * Clone every element that has a `viewTransitionName`, with its painted
+ * bounds. Call this before the React update, then `viewTransitionStart`
+ * after it. `startViewTransition` in `@gpuix/react` does both.
+ */
+ viewTransitionCapture(): void
+ /**
+ * Animate every captured name toward its new element. `options` is the
+ * JSON of a `ViewTransitionOptions` value, or nothing for a crossfade.
+ */
+ viewTransitionStart(options?: string | undefined | null): void
/** Hidden → minimal → full → hidden. */
cycleDebugFrameOverlay(): string
getDebugFrameOverlay(): string
@@ -337,6 +348,16 @@ export declare class TestGpuixRenderer {
* web scrollIntoView. Call flush() after to apply and re-render.
*/
scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void
+ /**
+ * Clone every element that has a `viewTransitionName`, with its painted
+ * bounds. Call flush() first, so the bounds are current.
+ */
+ viewTransitionCapture(): void
+ /**
+ * Animate every captured name toward its new element. Call flush()
+ * after, and move the automation clock to step through the frames.
+ */
+ viewTransitionStart(options?: string | undefined | null): void
/**
* Scroll a child into view by its index in the children list.
* Call flush() after to apply and re-render. For a `` the
diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs
index 69d0e99c..816dcc44 100644
--- a/packages/native/src/motion.rs
+++ b/packages/native/src/motion.rs
@@ -15,6 +15,8 @@ pub(crate) struct MotionStyle {
pub width: Option,
pub height: Option,
pub opacity: Option,
+ /// A `filter: blur()` sigma in pixels, on the element and its children.
+ pub blur: Option,
pub top: Option,
pub right: Option,
pub bottom: Option,
@@ -180,7 +182,7 @@ impl MotionHeight {
}
/// One step of a linear interpolation.
-fn mix(from: f64, to: f64, progress: f64) -> f64 {
+pub(crate) fn mix(from: f64, to: f64, progress: f64) -> f64 {
from + (to - from) * progress
}
@@ -198,6 +200,7 @@ impl MotionStyle {
.height
.map(|to| self.height.unwrap_or(to).mix(to, progress)),
opacity: value(self.opacity, target.opacity, progress),
+ blur: value(self.blur, target.blur, progress),
top: value(self.top, target.top, progress),
right: value(self.right, target.right, progress),
bottom: value(self.bottom, target.bottom, progress),
@@ -221,6 +224,9 @@ impl MotionStyle {
if let Some(value) = self.opacity {
style.opacity = Some(value.into());
}
+ if let Some(value) = self.blur {
+ style.filter = Some(format!("blur({value}px)"));
+ }
if let Some(value) = self.top {
style.top = Some(value.into());
}
@@ -251,7 +257,7 @@ enum MotionInitial {
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(untagged)]
-enum MotionEase {
+pub(crate) enum MotionEase {
Name(String),
CubicBezier([f64; 4]),
}
@@ -329,6 +335,39 @@ impl MotionFrame {
pub(crate) fn measured_height(&self) -> Option {
self.style.height.filter(|height| height.needs_content())
}
+
+ /// A frame a view transition composes for the arriving element of a pair.
+ /// It carries the opacity and the blur of this animation frame. The
+ /// transition element applies the movement at paint.
+ pub(crate) fn view_transition_frame(opacity: Option, blur: Option) -> Self {
+ Self {
+ style: MotionStyle {
+ opacity,
+ blur,
+ ..MotionStyle::default()
+ },
+ active: true,
+ content: None,
+ measured: ContentHeight::default(),
+ }
+ }
+
+ /// Fold a view transition into this frame. The transition owns the
+ /// element while it runs, so its values replace the motion ones.
+ pub(crate) fn with_view_transition(
+ mut self,
+ opacity: Option,
+ blur: Option,
+ ) -> Self {
+ if opacity.is_some() {
+ self.style.opacity = opacity;
+ }
+ if blur.is_some() {
+ self.style.blur = blur;
+ }
+ self.active = true;
+ self
+ }
}
pub(crate) struct MotionState {
@@ -500,6 +539,7 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> {
("width", style.width),
("height", style.height.map(|height| height.pixels)),
("opacity", style.opacity),
+ ("blur", style.blur),
("top", style.top),
("right", style.right),
("bottom", style.bottom),
@@ -513,8 +553,9 @@ fn validate_style(style: &MotionStyle) -> Result<(), String> {
if style.width.is_some_and(|value| value < 0.0)
|| style.height.is_some_and(|height| height.pixels < 0.0)
|| style.border_radius.is_some_and(|value| value < 0.0)
+ || style.blur.is_some_and(|value| value < 0.0)
{
- return Err("motion sizes and borderRadius must be non-negative".to_string());
+ return Err("motion sizes, borderRadius and blur must be non-negative".to_string());
}
if style
.opacity
@@ -534,7 +575,7 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> {
Ok(())
}
-fn validate_ease(ease: &MotionEase) -> Result<(), String> {
+pub(crate) fn validate_ease(ease: &MotionEase) -> Result<(), String> {
match ease {
MotionEase::Name(name)
if matches!(
@@ -561,7 +602,7 @@ 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 {
+pub(crate) fn ease(progress: f64, ease: &MotionEase) -> f64 {
let curve = match ease {
MotionEase::CubicBezier(curve) => *curve,
MotionEase::Name(name) => match name.as_str() {
@@ -664,6 +705,25 @@ mod tests {
);
}
+ #[test]
+ fn blur_interpolates_and_folds_into_the_filter() {
+ let started = Instant::now();
+ let spec = serde_json::json!({
+ "initial": { "blur": 0.0 },
+ "animate": { "blur": 8.0 },
+ "transition": { "duration": 1.0, "ease": "linear" }
+ });
+ let state = MotionState::new(&spec, started).unwrap();
+ let frame = state.frame(started + Duration::from_millis(500));
+ assert_eq!(frame.style.blur, Some(4.0));
+ let mut style = StyleDesc::default();
+ frame.style.apply_to(&mut style);
+ assert_eq!(style.filter.as_deref(), Some("blur(4px)"));
+
+ let bad = serde_json::json!({ "animate": { "blur": -1.0 }, "transition": {} });
+ assert!(MotionState::new(&bad, started).is_err());
+ }
+
#[test]
fn disabled_initial_state_starts_at_the_target() {
let now = Instant::now();
diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs
index 213054f5..09bafe35 100644
--- a/packages/native/src/renderer.rs
+++ b/packages/native/src/renderer.rs
@@ -50,6 +50,7 @@ mod batch;
mod frame;
pub(crate) mod scroll_into_view;
pub(crate) mod scrollbar;
+pub(crate) mod view_transition;
mod virtual_list;
pub use batch::apply_batch_to_tree;
@@ -346,6 +347,10 @@ enum UiCommand {
block: scroll_into_view::Align,
inline: scroll_into_view::Align,
},
+ ViewTransitionCapture,
+ ViewTransitionStart {
+ options: String,
+ },
GetScrollOffset {
id: u64,
response: SyncSender>,
@@ -494,6 +499,19 @@ async fn run_ui_commands(
.ok();
refresh_ui_window(window, cx)
}
+ UiCommand::ViewTransitionCapture => window.update(cx, |view, _window, _cx| {
+ view.view_transition_capture();
+ }),
+ UiCommand::ViewTransitionStart { options } => {
+ window
+ .update(cx, move |view, _window, _cx| {
+ if let Err(error) = view.view_transition_start(&options) {
+ log::warn!("Invalid view transition options: {error}");
+ }
+ })
+ .ok();
+ refresh_ui_window(window, cx)
+ }
UiCommand::GetScrollOffset { id, response } => {
let offset = VIRTUAL_LIST_STATES
.with(|cell| {
@@ -1382,6 +1400,51 @@ impl GpuixRenderer {
Err(Error::from_reason("Unsupported operating system"))
}
+ /// Clone every element that has a `viewTransitionName`, with its painted
+ /// bounds. Call this before the React update, then `viewTransitionStart`
+ /// after it. `startViewTransition` in `@gpuix/react` does both.
+ #[napi]
+ pub fn view_transition_capture(&self) -> Result<()> {
+ #[cfg(target_os = "macos")]
+ return update_window(|view, _window, _cx| view.view_transition_capture());
+
+ #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))]
+ return self.send_ui_command(UiCommand::ViewTransitionCapture);
+
+ #[cfg(not(any(
+ target_os = "macos",
+ target_os = "windows",
+ target_os = "linux",
+ target_os = "freebsd"
+ )))]
+ Err(Error::from_reason("Unsupported operating system"))
+ }
+
+ /// Animate every captured name toward its new element. `options` is the
+ /// JSON of a `ViewTransitionOptions` value, or nothing for a crossfade.
+ #[napi]
+ pub fn view_transition_start(&self, options: Option) -> Result<()> {
+ let options = options.unwrap_or_else(|| "{}".to_string());
+ #[cfg(target_os = "macos")]
+ {
+ update_window(move |view, _window, _cx| {
+ view.view_transition_start(&options).map_err(Error::from_reason)
+ })??;
+ return invalidate_window();
+ }
+
+ #[cfg(any(target_os = "windows", target_os = "linux", target_os = "freebsd"))]
+ return self.send_ui_command(UiCommand::ViewTransitionStart { options });
+
+ #[cfg(not(any(
+ target_os = "macos",
+ target_os = "windows",
+ target_os = "linux",
+ target_os = "freebsd"
+ )))]
+ Err(Error::from_reason("Unsupported operating system"))
+ }
+
/// Hidden → minimal → full → hidden.
#[napi]
pub fn cycle_debug_frame_overlay(&self) -> Result {
@@ -2930,6 +2993,10 @@ pub(crate) struct GpuixView {
/// Resolved `highlight` state, keyed by the element that declared it.
/// Empty in every app that does not use search.
highlights: HashMap,
+ /// The running view transition, when one runs.
+ view_transition: Option,
+ /// Captures `viewTransitionCapture` took, waiting for the start call.
+ pending_view_transition: Option>,
}
/// Two-level cache for one element's `highlight`.
@@ -3063,9 +3130,33 @@ impl GpuixView {
clock: crate::automation::AutomationClock::new(),
root_cascade: RefCell::new(None),
highlights: HashMap::new(),
+ view_transition: None,
+ pending_view_transition: None,
}
}
+ /// Clone every named element and its painted bounds, before the tree
+ /// swaps. `view_transition_start` consumes the result.
+ pub(crate) fn view_transition_capture(&mut self) {
+ let tree = self.tree.lock().unwrap();
+ self.pending_view_transition = Some(view_transition::capture(&tree));
+ }
+
+ /// Start the transition against the tree as it is now. A start without a
+ /// capture still animates the names the new tree carries.
+ pub(crate) fn view_transition_start(
+ &mut self,
+ options_json: &str,
+ ) -> std::result::Result<(), String> {
+ let options = view_transition::VtOptions::parse(options_json)?;
+ let captures = self.pending_view_transition.take().unwrap_or_default();
+ let tree = self.tree.lock().unwrap();
+ let state = view_transition::VtState::new(captures, options, &tree);
+ drop(tree);
+ self.view_transition = Some(state);
+ Ok(())
+ }
+
/// The root cascade for `theme`, reusing the last one while the theme
/// holds still.
fn root_cascade(&self, theme: &Theme, rem_size: gpui::Pixels) -> crate::inheritance::Inherited {
@@ -3176,6 +3267,8 @@ impl GpuixView {
highlight,
highlights: &mut self.highlights,
highlight_events: &mut highlight_events,
+ vt: self.view_transition.as_ref(),
+ frozen: false,
direct_rules: Vec::new(),
descendant_rules: Vec::new(),
};
@@ -3389,25 +3482,38 @@ impl gpui::Render for GpuixView {
// Sync focus handles before building elements.
self.sync_focus_handles(&tree, &callback, window, cx);
+ // One frame of the running view transition, taken out of `self` so
+ // the build can borrow the rest of the view. A transition past its
+ // end drops here, and the frame paints the live tree alone.
+ let now = self.clock.now();
+ let view_transition = self.view_transition.take().and_then(|mut transition| {
+ transition.tick(now).then_some(transition)
+ });
+ let kept_by_transition = |id: &u64| {
+ view_transition
+ .as_ref()
+ .is_some_and(|transition| transition.keeps(*id))
+ };
+
// Ensure custom element instances are destroyed when their IDs disappear.
+ // A frozen view-transition copy keeps its instances until it fades.
self.custom_registry
- .prune_missing(|id| tree.elements.contains_key(&id));
+ .prune_missing(|id| tree.elements.contains_key(&id) || kept_by_transition(&id));
// Clean up scroll handles for destroyed elements (IDs removed from tree).
// Scrollability-based cleanup (element still exists but style changed
// from scroll to non-scroll) is handled inside build_host_container().
self.scroll_handles
- .retain(|id, _| tree.elements.contains_key(id));
+ .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id));
self.virtual_lists
- .retain(|id, _| tree.elements.contains_key(id));
+ .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id));
self.motion_states
- .retain(|id, _| tree.elements.contains_key(id));
+ .retain(|id, _| tree.elements.contains_key(id) || kept_by_transition(id));
// Build the element tree. custom_registry, focus_handles, and scroll_handles
// are different fields of self, so Rust allows borrowing all simultaneously.
let theme = Theme::dark();
let root_cascade = self.root_cascade(&theme, window.rem_size());
- let now = self.clock.now();
let mut motion_active = false;
// Pruned by DECLARATION, not existence: an element that drops its
// `highlight` prop keeps living, and its cached group list holds a copy
@@ -3418,7 +3524,7 @@ impl gpui::Render for GpuixView {
.is_some_and(|element| element.custom_props.contains_key("highlight"))
});
let mut highlight_events = Vec::new();
- let result = match tree.root_id {
+ let (result, exit_copies) = match tree.root_id {
Some(root_id) => {
let mut ctx = BuildCtx {
tree: &tree,
@@ -3436,13 +3542,24 @@ impl gpui::Render for GpuixView {
highlight: None,
highlights: &mut self.highlights,
highlight_events: &mut highlight_events,
+ vt: view_transition.as_ref(),
+ frozen: false,
direct_rules: Vec::new(),
descendant_rules: Vec::new(),
};
- build_element(root_id, None, &mut ctx, window, cx)
+ let root = build_element(root_id, None, &mut ctx, window, cx);
+ // Names captured without a successor paint as frozen copies
+ // over the tree. Built after the root, so they paint last.
+ let copies = view_transition::exit_copies(&mut ctx, window, cx);
+ (root, copies)
}
- None => gpui::Empty.into_any_element(),
+ None => (gpui::Empty.into_any_element(), Vec::new()),
};
+ // A transition animates every frame until it comes to rest.
+ if view_transition.is_some() {
+ motion_active = true;
+ }
+ self.view_transition = view_transition;
// Flushed after the root build so a `setState` in the handler cannot
// re-enter this build.
emit_highlight_events(&callback, &highlight_events);
@@ -3461,6 +3578,7 @@ impl gpui::Render for GpuixView {
.child(selection_frame_reset(self.selection.clone()))
.child(crate::automation::bounds_frame_reset())
.child(result)
+ .children(exit_copies)
.into_any_element()
};
diff --git a/packages/native/src/renderer/frame.rs b/packages/native/src/renderer/frame.rs
index 47222874..a3f5d4db 100644
--- a/packages/native/src/renderer/frame.rs
+++ b/packages/native/src/renderer/frame.rs
@@ -49,6 +49,14 @@ pub(super) struct BuildCtx<'a> {
/// would re-enter the build and emit again. They are flushed once the root
/// build has returned.
pub highlight_events: &'a mut Vec<(u64, usize)>,
+ /// The running view transition, or `None`. Cleared inside the build of a
+ /// frozen copy, so a name inside the copy never starts a nested one.
+ pub vt: Option<&'a super::view_transition::VtState>,
+ /// Whether this build is the frozen copy of a view transition. A copy is
+ /// a still image, so it gets no scrollbar. The scrollbar defers its draw,
+ /// and a deferred draw from inside the copy's isolated layout would read
+ /// its layout ids against the window's tree and panic.
+ pub frozen: bool,
/// Rules the parent puts on its direct children, `& > *` and
/// `& > :not(:last-child)`. They reach one depth only, so every element
/// swaps in its own set, possibly empty, before it builds its children.
@@ -105,6 +113,28 @@ pub(super) fn build_element(
};
let style = element.style.as_deref();
+ // This frame of the view transition, when one runs and the element
+ // carries a name. The opacity and blur of the arriving side fold into the
+ // motion channel here, and the movement applies at paint in the wrapper
+ // below.
+ let vt_frame = ctx.vt.and_then(|vt| {
+ let name = style?.view_transition_name.as_deref()?;
+ if name.is_empty() || name == "none" {
+ return None;
+ }
+ vt.frame_for(name)
+ });
+ let motion = match vt_frame
+ .as_ref()
+ .map(|frame| (frame.new_opacity(), frame.new_blur()))
+ {
+ Some((opacity, blur)) if opacity.is_some() || blur.is_some() => Some(match motion {
+ Some(frame) => frame.with_view_transition(opacity, blur),
+ None => crate::motion::MotionFrame::view_transition_frame(opacity, blur),
+ }),
+ _ => motion,
+ };
+
// Inheritable style resolves before the element's own style, because a
// custom property declared here is in scope for the `var()` next to it.
let parent_cascade = ctx.cascade.clone();
@@ -210,6 +240,10 @@ pub(super) fn build_element(
};
let built = super::auto_height::wrap(id, built, motion.as_ref(), resolved.as_deref());
+ let built = match vt_frame {
+ Some(frame) => super::view_transition::wrap(element, built, frame, ctx, window, cx),
+ None => built,
+ };
ctx.cascade = parent_cascade;
ctx.highlight = parent_highlight;
@@ -526,9 +560,12 @@ pub(crate) fn build_host_container(
el = el.track_scroll(handle);
// The scrollbar. Classic bars reserve a gutter in the layout,
- // which taffy takes as one width for both axes.
+ // which taffy takes as one width for both axes. A frozen view
+ // transition copy gets none: see `BuildCtx::frozen`.
let mode = super::scrollbar::Mode::current(cx);
- if let Some(spec) = super::scrollbar::Spec::from_style(style, mode) {
+ if let Some(spec) =
+ super::scrollbar::Spec::from_style(style, mode).filter(|_| !ctx.frozen)
+ {
let state = ctx.scrollbars.entry(element.id).or_default().clone();
let reserved = spec.reserved(state.borrow().overflowed);
let gutter = reserved.x.max(reserved.y);
diff --git a/packages/native/src/renderer/view_transition.rs b/packages/native/src/renderer/view_transition.rs
new file mode 100644
index 00000000..13a4a28b
--- /dev/null
+++ b/packages/native/src/renderer/view_transition.rs
@@ -0,0 +1,848 @@
+//! View transitions: freeze the named elements, swap the tree, then animate
+//! each name from its old place to its new one.
+//!
+//! `viewTransitionCapture` clones the subtree and the painted bounds of every
+//! element that has a `viewTransitionName`. `viewTransitionStart` parses the
+//! options and starts the clock. While the transition runs, `build_element`
+//! wraps each named live element in a `VtGroup`. The group takes the layout of
+//! the live element, so the transition never disturbs the surrounding layout.
+//! It paints the frozen copy at its captured place, then paints the live
+//! element moved by this frame's offset. Opacity for the live element rides
+//! the same style channel that `motion` uses, and the frozen copy carries its
+//! opacity on a wrapper element.
+//!
+//! A name that disappears without a successor becomes an exit copy: the
+//! renderer paints its frozen copy over the whole tree at its captured
+//! place, and the group's `old` side drives it.
+//!
+//! Known limits, on purpose:
+//! - An exit copy paints over the tree, so a former ancestor's clip or
+//! scroll no longer applies to it. Give both screens one name when the
+//! exit must stay inside the element's own area.
+//! - When the named element survives the swap, its frozen copy takes fresh
+//! ids, so the copy paints without the old scroll offsets.
+//! - The frozen copy keeps its event listeners, but their elements are gone
+//! on the React side, so input over the copy does nothing.
+
+use std::collections::{HashMap, HashSet};
+
+use gpui::{
+ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId,
+ InspectorElementId, IntoElement, IsolatedLayout, LayoutId, Pixels, Point, Size, Window, point,
+ px, size,
+};
+use serde::Deserialize;
+use web_time::Instant;
+
+use super::frame::{build_element, BuildCtx};
+use crate::motion::{self, MotionEase};
+use crate::retained_tree::{RetainedElement, RetainedTree};
+
+// ── Options ──────────────────────────────────────────────────────────
+
+/// A translation distance: pixels, or a share of the element's size.
+#[derive(Clone, Copy, Debug, Deserialize)]
+#[serde(try_from = "LenWire")]
+pub(crate) enum VtLen {
+ Px(f64),
+ Percent(f64),
+}
+
+impl VtLen {
+ fn resolve(self, extent: f64) -> f64 {
+ match self {
+ Self::Px(value) => value,
+ Self::Percent(value) => value / 100.0 * extent,
+ }
+ }
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum LenWire {
+ Number(f64),
+ Text(String),
+}
+
+impl TryFrom for VtLen {
+ type Error = String;
+
+ fn try_from(wire: LenWire) -> Result {
+ let text = match wire {
+ LenWire::Number(value) if value.is_finite() => return Ok(Self::Px(value)),
+ LenWire::Number(value) => {
+ return Err(format!("view transition length must be finite, got {value}"))
+ }
+ LenWire::Text(text) => text,
+ };
+ let trimmed = text.trim();
+ let (number, percent) = match trimmed.strip_suffix('%') {
+ Some(number) => (number, true),
+ None => (trimmed.strip_suffix("px").unwrap_or(trimmed), false),
+ };
+ let value = number
+ .trim()
+ .parse::()
+ .ok()
+ .filter(|value| value.is_finite())
+ .ok_or_else(|| format!("bad view transition length: {text:?}"))?;
+ Ok(if percent {
+ Self::Percent(value)
+ } else {
+ Self::Px(value)
+ })
+ }
+}
+
+/// What one side of a pair does over the transition. Every field is a
+/// `[from, to]` pair. A missing field holds still.
+#[derive(Clone, Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase", default)]
+struct SideSpec {
+ translate_x: Option<[VtLen; 2]>,
+ translate_y: Option<[VtLen; 2]>,
+ opacity: Option<[f64; 2]>,
+ /// A `filter: blur()` sigma in pixels, as a `[from, to]` pair.
+ blur: Option<[f64; 2]>,
+ /// Paint this side over the other one. Only read on the old side.
+ on_top: Option,
+}
+
+#[derive(Clone, Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase", default)]
+struct GroupSpec {
+ duration: Option,
+ delay: Option,
+ ease: Option,
+ old: Option,
+ new: Option,
+}
+
+/// The whole options payload of one `viewTransitionStart` call.
+#[derive(Clone, Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase", default)]
+pub(crate) struct VtOptions {
+ /// Seconds, like the `motion` prop.
+ duration: Option,
+ delay: Option,
+ ease: Option,
+ groups: HashMap,
+}
+
+const DEFAULT_DURATION: f64 = 0.3;
+
+impl VtOptions {
+ pub(crate) fn parse(json: &str) -> Result {
+ let options: Self = serde_json::from_str(json).map_err(|error| error.to_string())?;
+ for (ease, duration, delay) in std::iter::once((&options.ease, options.duration, options.delay))
+ .chain(
+ options
+ .groups
+ .values()
+ .map(|group| (&group.ease, group.duration, group.delay)),
+ )
+ {
+ if let Some(ease) = ease {
+ motion::validate_ease(ease)?;
+ }
+ for (name, value) in [("duration", duration), ("delay", delay)] {
+ if let Some(value) = value {
+ if !value.is_finite() || value < 0.0 {
+ return Err(format!(
+ "view transition {name} must be a finite non-negative number"
+ ));
+ }
+ }
+ }
+ }
+ Ok(options)
+ }
+
+ fn timing(&self, name: &str) -> (f64, f64, MotionEase) {
+ let group = self.groups.get(name);
+ let duration = group
+ .and_then(|group| group.duration)
+ .or(self.duration)
+ .unwrap_or(DEFAULT_DURATION);
+ let delay = group.and_then(|group| group.delay).or(self.delay).unwrap_or(0.0);
+ let ease = group
+ .and_then(|group| group.ease.clone())
+ .or_else(|| self.ease.clone())
+ .unwrap_or(MotionEase::Name("easeInOut".to_string()));
+ (duration, delay, ease)
+ }
+
+ /// When the last group comes to rest, in seconds from the start.
+ fn longest_end(&self) -> f64 {
+ let default_end =
+ self.delay.unwrap_or(0.0) + self.duration.unwrap_or(DEFAULT_DURATION);
+ self.groups
+ .keys()
+ .map(|name| {
+ let (duration, delay, _) = self.timing(name);
+ delay + duration
+ })
+ .fold(default_end, f64::max)
+ }
+}
+
+// ── Capture ──────────────────────────────────────────────────────────
+
+/// One frozen named element: its cloned subtree and its painted place.
+pub(crate) struct VtCapture {
+ pub(crate) tree: RetainedTree,
+ pub(crate) root: u64,
+ pub(crate) origin: Point,
+ pub(crate) size: Size,
+}
+
+/// Clone every named element's subtree, with the bounds it painted at.
+/// An element that never painted has no place to animate from, so it is
+/// skipped and its name enters as a new element.
+pub(crate) fn capture(tree: &RetainedTree) -> HashMap {
+ let bounds = crate::automation::all_bounds();
+ let mut captures = HashMap::new();
+ for (&id, element) in &tree.elements {
+ let Some(name) = element
+ .style
+ .as_deref()
+ .and_then(|style| style.view_transition_name.as_deref())
+ else {
+ continue;
+ };
+ if name.is_empty() || name == "none" {
+ continue;
+ }
+ let Some(rect) = bounds.get(&id) else {
+ continue;
+ };
+ let mut frozen = RetainedTree::new();
+ clone_subtree(tree, id, None, &mut frozen);
+ frozen.root_id = Some(id);
+ captures.insert(
+ name.to_string(),
+ VtCapture {
+ tree: frozen,
+ root: id,
+ origin: point(px(rect.x as f32), px(rect.y as f32)),
+ size: size(px(rect.width as f32), px(rect.height as f32)),
+ },
+ );
+ }
+ captures
+}
+
+fn clone_subtree(source: &RetainedTree, id: u64, parent: Option, into: &mut RetainedTree) {
+ let Some(element) = source.elements.get(&id) else {
+ return;
+ };
+ let mut clone = RetainedElement::new(id, element.element_type.clone(), element.subtree_revision);
+ clone.style = element.style.clone();
+ clone.content = element.content.clone();
+ clone.events = element.events.clone();
+ clone.children = element.children.clone();
+ clone.parent = parent;
+ clone.custom_props = element.custom_props.clone();
+ // The copy is a still image. Without this, a fresh `MotionState` would
+ // replay the initial-to-animate run inside it.
+ clone.custom_props.remove("motion");
+ // Locators must find the live element, never the copy.
+ clone.test_id = None;
+ clone.search_revision = element.search_revision;
+ let children = clone.children.clone();
+ into.elements.insert(id, clone);
+ for child in children {
+ clone_subtree(source, child, Some(id), into);
+ }
+}
+
+/// Fresh ids for clones whose original survives the swap. Far above what the
+/// JS counter reaches, so the two ranges never meet.
+const REMAP_BASE: u64 = 1 << 62;
+
+/// Give a clone a fresh id when its original is still in the live tree.
+/// Building both under one id would hand them one GPUI element state.
+/// A clone of a destroyed element keeps its id, and with it its scroll
+/// offsets, which is the common pair case.
+fn remap_live_ids(captures: &mut HashMap, live: &RetainedTree, next: &mut u64) {
+ for capture in captures.values_mut() {
+ let colliding: Vec = capture
+ .tree
+ .elements
+ .keys()
+ .copied()
+ .filter(|id| live.elements.contains_key(id))
+ .collect();
+ for from in colliding {
+ let to = *next;
+ *next += 1;
+ remap(&mut capture.tree, from, to);
+ if capture.root == from {
+ capture.root = to;
+ }
+ }
+ }
+}
+
+fn remap(tree: &mut RetainedTree, from: u64, to: u64) {
+ let Some(mut element) = tree.elements.remove(&from) else {
+ return;
+ };
+ element.id = to;
+ let parent = element.parent;
+ let children = element.children.clone();
+ tree.elements.insert(to, element);
+ if let Some(parent) = parent.and_then(|id| tree.elements.get_mut(&id)) {
+ for child in &mut parent.children {
+ if *child == from {
+ *child = to;
+ }
+ }
+ }
+ for child in children {
+ if let Some(child) = tree.elements.get_mut(&child) {
+ child.parent = Some(to);
+ }
+ }
+ if tree.root_id == Some(from) {
+ tree.root_id = Some(to);
+ }
+}
+
+// ── State ────────────────────────────────────────────────────────────
+
+/// One running transition. The view holds at most one. A new start replaces
+/// the one before it.
+pub(crate) struct VtState {
+ captures: HashMap,
+ options: VtOptions,
+ started: Option,
+ frame_now: Option,
+ /// Every id inside a frozen tree. The view keeps the scroll handles and
+ /// custom element instances of these ids alive while the transition runs.
+ ids: HashSet,
+}
+
+impl VtState {
+ pub(crate) fn new(
+ mut captures: HashMap,
+ options: VtOptions,
+ live: &RetainedTree,
+ ) -> Self {
+ let mut next = REMAP_BASE;
+ remap_live_ids(&mut captures, live, &mut next);
+ let ids = captures
+ .values()
+ .flat_map(|capture| capture.tree.elements.keys().copied())
+ .collect();
+ Self {
+ captures,
+ options,
+ started: None,
+ frame_now: None,
+ ids,
+ }
+ }
+
+ /// Bring the clock up to this frame. Returns whether the transition still
+ /// runs. Called once per frame, before the tree builds.
+ pub(crate) fn tick(&mut self, now: Instant) -> bool {
+ let started = *self.started.get_or_insert(now);
+ self.frame_now = Some(now);
+ let elapsed = now.duration_since(started).as_secs_f64();
+ elapsed < self.options.longest_end()
+ }
+
+ /// Whether the view must keep per-id state alive for a frozen clone.
+ pub(crate) fn keeps(&self, id: u64) -> bool {
+ self.ids.contains(&id)
+ }
+
+ fn capture(&self, name: &str) -> Option<&VtCapture> {
+ self.captures.get(name)
+ }
+
+ /// This frame's animation values for one name, or `None` before the first
+ /// tick.
+ pub(crate) fn frame_for(&self, name: &str) -> Option {
+ let started = self.started?;
+ let now = self.frame_now?;
+ let (duration, delay, ease_spec) = self.options.timing(name);
+ let elapsed = now.duration_since(started).as_secs_f64();
+ let raw = if duration <= 0.0 {
+ 1.0
+ } else {
+ ((elapsed - delay) / duration).clamp(0.0, 1.0)
+ };
+ let t = motion::ease(raw, &ease_spec);
+
+ let group = self.options.groups.get(name);
+ // A group that names neither side crossfades, like the web default.
+ // A group that names a side animates only what that side says.
+ let explicit = group.is_some_and(|group| group.old.is_some() || group.new.is_some());
+ let old = group.and_then(|group| group.old.clone()).unwrap_or_else(|| SideSpec {
+ opacity: (!explicit).then_some([1.0, 0.0]),
+ ..SideSpec::default()
+ });
+ let new = group.and_then(|group| group.new.clone()).unwrap_or_else(|| SideSpec {
+ opacity: (!explicit).then_some([0.0, 1.0]),
+ ..SideSpec::default()
+ });
+ let old_on_top = old.on_top.unwrap_or(false);
+ Some(VtElementFrame {
+ t,
+ old,
+ new,
+ old_on_top,
+ })
+ }
+}
+
+/// The values one named element animates with on one frame.
+pub(crate) struct VtElementFrame {
+ t: f64,
+ old: SideSpec,
+ new: SideSpec,
+ old_on_top: bool,
+}
+
+impl VtElementFrame {
+ /// The live element's opacity this frame, or `None` when it holds still.
+ pub(crate) fn new_opacity(&self) -> Option {
+ self.new
+ .opacity
+ .map(|[from, to]| motion::mix(from, to, self.t))
+ }
+
+ /// The live element's blur sigma this frame, or `None` when it holds
+ /// still.
+ pub(crate) fn new_blur(&self) -> Option {
+ self.new
+ .blur
+ .map(|[from, to]| motion::mix(from, to, self.t).max(0.0))
+ }
+
+ fn old_blur(&self) -> Option {
+ self.old
+ .blur
+ .map(|[from, to]| motion::mix(from, to, self.t).max(0.0))
+ }
+
+ /// How far past the group's bounds this frame's blur reaches: three
+ /// sigmas of the widest blur among the two sides. The group's mask
+ /// grows by this, so the halo paints instead of clipping at the edge.
+ fn mask_inflation(&self) -> Pixels {
+ let sigma = self
+ .new_blur()
+ .unwrap_or(0.0)
+ .max(self.old_blur().unwrap_or(0.0));
+ px((3.0 * sigma).ceil() as f32)
+ }
+
+ fn offset(x: Option<[VtLen; 2]>, y: Option<[VtLen; 2]>, t: f64, extent: Size) -> Point {
+ let resolve = |lens: Option<[VtLen; 2]>, extent: f32| {
+ lens.map_or(0.0, |[from, to]| {
+ motion::mix(from.resolve(extent as f64), to.resolve(extent as f64), t)
+ })
+ };
+ point(
+ px(resolve(x, f32::from(extent.width)) as f32),
+ px(resolve(y, f32::from(extent.height)) as f32),
+ )
+ }
+
+ fn new_offset(&self, extent: Size) -> Point {
+ Self::offset(self.new.translate_x, self.new.translate_y, self.t, extent)
+ }
+
+ fn old_offset(&self, extent: Size) -> Point {
+ Self::offset(self.old.translate_x, self.old.translate_y, self.t, extent)
+ }
+
+ fn old_opacity(&self) -> f64 {
+ self.old
+ .opacity
+ .map_or(1.0, |[from, to]| motion::mix(from, to, self.t))
+ }
+}
+
+// ── The transition element ───────────────────────────────────────────
+
+/// Wrap one named live element for this frame of the transition.
+pub(super) fn wrap(
+ element: &RetainedElement,
+ built: AnyElement,
+ frame: VtElementFrame,
+ ctx: &mut BuildCtx,
+ window: &mut Window,
+ cx: &mut gpui::Context,
+) -> AnyElement {
+ use gpui::prelude::*;
+
+ let name = element
+ .style
+ .as_deref()
+ .and_then(|style| style.view_transition_name.as_deref())
+ .unwrap_or_default();
+ let vt = ctx.vt;
+ let old = vt.and_then(|vt| vt.capture(name)).map(|capture| OldCopy {
+ element: build_frozen(capture, &frame, ctx, window, cx),
+ layout: IsolatedLayout::new(),
+ origin: capture.origin + frame.old_offset(capture.size),
+ size: capture.size,
+ });
+ VtGroup {
+ child: built,
+ old,
+ frame,
+ }
+ .into_any_element()
+}
+
+/// Build the frozen copy of one capture, held at its captured size and faded
+/// and blurred for this frame.
+fn build_frozen(
+ capture: &VtCapture,
+ frame: &VtElementFrame,
+ ctx: &mut BuildCtx,
+ window: &mut Window,
+ cx: &mut gpui::Context,
+) -> AnyElement {
+ use gpui::prelude::*;
+
+ // The frozen tree builds through the same walk as the live one. The
+ // nested context clears `vt`, so a name inside the copy never starts
+ // a transition of its own.
+ let mut frozen_ctx = BuildCtx {
+ tree: &capture.tree,
+ event_callback: ctx.event_callback,
+ focus_handles: ctx.focus_handles,
+ scroll_handles: &mut *ctx.scroll_handles,
+ custom_registry: &mut *ctx.custom_registry,
+ virtual_lists: &mut *ctx.virtual_lists,
+ motion_states: &mut *ctx.motion_states,
+ scrollbars: &mut *ctx.scrollbars,
+ now: ctx.now,
+ motion_active: &mut *ctx.motion_active,
+ selection: ctx.selection.clone(),
+ cascade: ctx.cascade.clone(),
+ highlight: None,
+ highlights: &mut *ctx.highlights,
+ highlight_events: &mut *ctx.highlight_events,
+ vt: None,
+ frozen: true,
+ direct_rules: Vec::new(),
+ descendant_rules: Vec::new(),
+ };
+ // A frozen copy builds outside the tree walk, so it has no child position
+ // and the index states do not apply to it.
+ let content = build_element(capture.root, None, &mut frozen_ctx, window, cx);
+ // The shell fixes the copy at its captured size and carries this
+ // frame's opacity down the whole copy.
+ let mut shell = gpui::div()
+ .w(capture.size.width)
+ .h(capture.size.height)
+ .overflow_hidden();
+ shell.style().opacity = Some(frame.old_opacity() as f32);
+ let shell = shell.child(content);
+ // The blur rides a wrapper that does not clip. On the shell itself,
+ // its `overflow: hidden` would clip the halo at the captured edge.
+ match frame.old_blur() {
+ Some(blur) => gpui::div()
+ .w(capture.size.width)
+ .h(capture.size.height)
+ .blur(px(blur as f32))
+ .child(shell)
+ .into_any_element(),
+ None => shell.into_any_element(),
+ }
+}
+
+/// Build a frozen copy for every captured name that has no live element this
+/// frame. The renderer appends these to the root wrapper, so they paint over
+/// the tree at their captured place while the group's `old` side fades or
+/// moves them out.
+pub(super) fn exit_copies(
+ ctx: &mut BuildCtx,
+ window: &mut Window,
+ cx: &mut gpui::Context,
+) -> Vec {
+ use gpui::prelude::*;
+
+ let Some(vt) = ctx.vt else {
+ return Vec::new();
+ };
+ let live: HashSet<&str> = ctx
+ .tree
+ .elements
+ .values()
+ .filter_map(|element| element.style.as_deref()?.view_transition_name.as_deref())
+ .collect();
+ // Sorted, so two exit copies paint in the same order on every frame.
+ let mut names: Vec<&String> = vt
+ .captures
+ .keys()
+ .filter(|name| !live.contains(name.as_str()))
+ .collect();
+ names.sort();
+ let mut copies = Vec::new();
+ for name in names {
+ let Some(capture) = vt.capture(name) else {
+ continue;
+ };
+ let Some(frame) = vt.frame_for(name) else {
+ continue;
+ };
+ copies.push(
+ ExitCopy {
+ element: build_frozen(capture, &frame, ctx, window, cx),
+ layout: IsolatedLayout::new(),
+ origin: capture.origin + frame.old_offset(capture.size),
+ size: capture.size,
+ }
+ .into_any_element(),
+ );
+ }
+ copies
+}
+
+/// The frozen copy of one name, ready to paint at its captured place.
+struct OldCopy {
+ element: AnyElement,
+ /// The copy lays out here rather than in the window's tree, because its
+ /// captured size is fixed and must not join the live layout.
+ layout: IsolatedLayout,
+ origin: Point,
+ size: Size,
+}
+
+/// One named element while the transition runs.
+///
+/// The group hands the live child's layout through untouched, so the page
+/// around a transition lays out exactly as it will at rest. Movement happens
+/// at paint: the child prepaints under an element offset, and the frozen copy
+/// prepaints at its captured bounds. Both paint inside the group's bounds as
+/// a mask, so a slide stays inside the element's own area. When a side blurs,
+/// the mask grows by the blur's support, so the halo shows instead of
+/// clipping at the edge.
+struct VtGroup {
+ child: AnyElement,
+ old: Option,
+ frame: VtElementFrame,
+}
+
+impl Element for VtGroup {
+ type RequestLayoutState = ();
+ type PrepaintState = ();
+
+ fn id(&self) -> Option {
+ None
+ }
+
+ fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (LayoutId, ()) {
+ (self.child.request_layout(window, cx), ())
+ }
+
+ fn prepaint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ bounds: Bounds,
+ _request_layout: &mut (),
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let offset = self.frame.new_offset(bounds.size);
+ let mask = bounds.dilate(self.frame.mask_inflation());
+ window.with_content_mask(Some(ContentMask { bounds: mask }), |window| {
+ if let Some(old) = &mut self.old {
+ let element = &mut old.element;
+ let origin = old.origin;
+ let extent = old.size;
+ old.layout.enter(window, |window| {
+ element.layout_as_root(
+ size(
+ AvailableSpace::Definite(extent.width),
+ AvailableSpace::Definite(extent.height),
+ ),
+ window,
+ cx,
+ );
+ element.prepaint_at(origin, window, cx);
+ });
+ }
+ window.with_element_offset(offset, |window| {
+ self.child.prepaint(window, cx);
+ });
+ });
+ }
+
+ fn paint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ bounds: Bounds,
+ _request_layout: &mut (),
+ _prepaint: &mut (),
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let mask = bounds.dilate(self.frame.mask_inflation());
+ window.with_content_mask(Some(ContentMask { bounds: mask }), |window| {
+ let paint_old = |old: &mut Option, window: &mut Window, cx: &mut App| {
+ if let Some(old) = old {
+ let element = &mut old.element;
+ old.layout.enter(window, |window| element.paint(window, cx));
+ }
+ };
+ if self.frame.old_on_top {
+ self.child.paint(window, cx);
+ paint_old(&mut self.old, window, cx);
+ } else {
+ paint_old(&mut self.old, window, cx);
+ self.child.paint(window, cx);
+ }
+ });
+ }
+}
+
+impl IntoElement for VtGroup {
+ type Element = Self;
+
+ fn into_element(self) -> Self {
+ self
+ }
+}
+
+/// One exit-only frozen copy, painted over the tree at its captured place.
+///
+/// The element asks for no layout space of its own. The copy lays out in its
+/// own isolated tree at its captured size, so the live layout never sees it.
+struct ExitCopy {
+ element: AnyElement,
+ layout: IsolatedLayout,
+ origin: Point,
+ size: Size,
+}
+
+impl Element for ExitCopy {
+ type RequestLayoutState = ();
+ type PrepaintState = ();
+
+ fn id(&self) -> Option {
+ None
+ }
+
+ fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+ None
+ }
+
+ fn request_layout(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> (LayoutId, ()) {
+ (
+ window.request_layout(gpui::Style::default(), None::, cx),
+ (),
+ )
+ }
+
+ fn prepaint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ _bounds: Bounds,
+ _request_layout: &mut (),
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let element = &mut self.element;
+ let origin = self.origin;
+ let extent = self.size;
+ self.layout.enter(window, |window| {
+ element.layout_as_root(
+ size(
+ AvailableSpace::Definite(extent.width),
+ AvailableSpace::Definite(extent.height),
+ ),
+ window,
+ cx,
+ );
+ element.prepaint_at(origin, window, cx);
+ });
+ }
+
+ fn paint(
+ &mut self,
+ _id: Option<&GlobalElementId>,
+ _inspector_id: Option<&InspectorElementId>,
+ _bounds: Bounds,
+ _request_layout: &mut (),
+ _prepaint: &mut (),
+ window: &mut Window,
+ cx: &mut App,
+ ) {
+ let element = &mut self.element;
+ self.layout.enter(window, |window| element.paint(window, cx));
+ }
+}
+
+impl IntoElement for ExitCopy {
+ type Element = Self;
+
+ fn into_element(self) -> Self {
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::Duration;
+
+ /// A state with no captures, halted at `elapsed_ms` into the transition.
+ fn state_at(options: &str, elapsed_ms: u64) -> VtState {
+ let options = VtOptions::parse(options).unwrap();
+ let started = Instant::now();
+ VtState {
+ captures: HashMap::new(),
+ options,
+ started: Some(started),
+ frame_now: Some(started + Duration::from_millis(elapsed_ms)),
+ ids: HashSet::new(),
+ }
+ }
+
+ #[test]
+ fn blur_mixes_on_both_sides() {
+ let state = state_at(
+ r#"{"groups":{"screen":{"duration":0.3,"ease":"linear","old":{"blur":[0,6]},"new":{"blur":[6,0],"opacity":[0,1]}}}}"#,
+ 150,
+ );
+ let frame = state.frame_for("screen").unwrap();
+ assert_eq!(frame.new_blur(), Some(3.0));
+ assert_eq!(frame.old_blur(), Some(3.0));
+ assert_eq!(frame.new_opacity(), Some(0.5));
+ }
+
+ #[test]
+ fn a_side_without_blur_holds_still() {
+ let state = state_at(r#"{"groups":{"screen":{"old":{"opacity":[1,0]}}}}"#, 150);
+ let frame = state.frame_for("screen").unwrap();
+ assert_eq!(frame.new_blur(), None);
+ assert_eq!(frame.old_blur(), None);
+ }
+}
diff --git a/packages/native/src/style.rs b/packages/native/src/style.rs
index 223dfdc4..7d76d743 100644
--- a/packages/native/src/style.rs
+++ b/packages/native/src/style.rs
@@ -507,6 +507,10 @@ style_desc! {
scroll_padding_bottom: Option = "scrollPaddingBottom",
scroll_padding_left: Option = "scrollPaddingLeft",
+ // View transitions. The name pairs the element that leaves with the
+ // element that arrives across one `startViewTransition` call.
+ view_transition_name: Option = "viewTransitionName",
+
// Cursor
cursor: Option = "cursor",
/// `"auto"` blocks mouse hits behind this element. `"none"` never does.
diff --git a/packages/native/src/style/resolve.rs b/packages/native/src/style/resolve.rs
index 4e6fb0f3..ebb2c88b 100644
--- a/packages/native/src/style/resolve.rs
+++ b/packages/native/src/style/resolve.rs
@@ -313,6 +313,9 @@ pub(crate) fn apply_motion(
if let Some(opacity) = motion.opacity {
el = el.opacity(opacity as f32);
}
+ if let Some(blur) = motion.blur {
+ el = el.blur(gpui::px(blur as f32));
+ }
el
}
diff --git a/packages/native/src/test_renderer.rs b/packages/native/src/test_renderer.rs
index 6d176e76..763fb480 100644
--- a/packages/native/src/test_renderer.rs
+++ b/packages/native/src/test_renderer.rs
@@ -747,6 +747,36 @@ impl TestGpuixRenderer {
})
}
+ /// Clone every element that has a `viewTransitionName`, with its painted
+ /// bounds. Call flush() first, so the bounds are current.
+ #[napi]
+ pub fn view_transition_capture(&self) -> Result<()> {
+ with_test_state(|cx, window, view| {
+ let view = view.clone();
+ cx.update_window(window, |_, _window, app| {
+ view.update(app, |view, _cx| view.view_transition_capture());
+ })
+ .map_err(|e| Error::from_reason(e.to_string()))?;
+ Ok(())
+ })
+ }
+
+ /// Animate every captured name toward its new element. Call flush()
+ /// after, and move the automation clock to step through the frames.
+ #[napi]
+ pub fn view_transition_start(&self, options: Option) -> Result<()> {
+ let options = options.unwrap_or_else(|| "{}".to_string());
+ with_test_state(|cx, window, view| {
+ let view = view.clone();
+ let result = cx
+ .update_window(window, |_, _window, app| {
+ view.update(app, |view, _cx| view.view_transition_start(&options))
+ })
+ .map_err(|e| Error::from_reason(e.to_string()))?;
+ result.map_err(Error::from_reason)
+ })
+ }
+
/// Scroll a child into view by its index in the children list.
/// Call flush() after to apply and re-render. For a `` the
/// scroll is queued and applied on that flush, after the child splice.
diff --git a/packages/react/src/__tests__/view-transitions.test.tsx b/packages/react/src/__tests__/view-transitions.test.tsx
new file mode 100644
index 00000000..30b04aa0
--- /dev/null
+++ b/packages/react/src/__tests__/view-transitions.test.tsx
@@ -0,0 +1,196 @@
+/**
+ * View transitions. The automation clock is paused, so every frame of the
+ * animation is read at an exact time. Bounds come from the paint trackers,
+ * which record where an element really painted, moved or not.
+ */
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import React from "react"
+import { createTestRoot, hasNativeTestRenderer, type TestRoot } from "../testing"
+import { startViewTransition, type ViewTransitionOptions } from "../view-transitions"
+
+const describeNative = hasNativeTestRenderer ? describe : describe.skip
+
+/** The `key` makes each screen its own element, the way a navigation swaps
+ * one screen component for another. Without it, React updates one element
+ * in place, which is the separate case the last test covers. */
+function Screen({ label, color }: { label: string; color: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+/** The iOS push: the new screen slides in from the right over the old one,
+ * and the old one slides a third of the way out to the left. */
+const PUSH: ViewTransitionOptions = {
+ groups: {
+ screen: {
+ duration: 0.3,
+ ease: "linear",
+ old: { translateX: ["0%", "-30%"] },
+ new: { translateX: ["100%", "0%"] },
+ },
+ },
+}
+
+describeNative("view transitions", () => {
+ let root: TestRoot
+ beforeEach(() => {
+ root = createTestRoot()
+ })
+ afterEach(() => {
+ root.unmount()
+ })
+
+ const screenId = () => root.renderer.findByType("div")[0]!.id
+ const boundsOf = (id: number) => root.renderer.getElementBounds(id)
+
+ it("slides the pair like an iOS push", () => {
+ const { render, renderer } = root
+ renderer.clockPause()
+ render( )
+ const oldId = screenId()
+ const baseX = boundsOf(oldId)![0]
+
+ startViewTransition(renderer, () => render( ), PUSH)
+ const newId = screenId()
+ expect(newId).not.toBe(oldId)
+
+ // At the start, the new screen sits one width to the right, and the
+ // frozen copy of the old one still paints at its place.
+ expect(boundsOf(newId)![0]).toBeCloseTo(baseX + 300, 0)
+ expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0)
+
+ // Halfway, with a linear ease: the new screen covered half its way in,
+ // and the old copy moved 15% of its width out.
+ renderer.clockFastForward(150)
+ expect(boundsOf(newId)![0]).toBeCloseTo(baseX + 150, 0)
+ expect(boundsOf(oldId)![0]).toBeCloseTo(baseX - 45, 0)
+
+ // Past the end: the new screen rests at its layout place, and the copy
+ // paints no more.
+ renderer.clockFastForward(400)
+ expect(boundsOf(newId)![0]).toBeCloseTo(baseX, 0)
+ expect(boundsOf(oldId)).toBeNull()
+ })
+
+ it("crossfades by default without moving anything", () => {
+ const { render, renderer } = root
+ renderer.clockPause()
+ render( )
+ const oldId = screenId()
+ const baseX = boundsOf(oldId)![0]
+
+ startViewTransition(renderer, () => render( ))
+ const newId = screenId()
+
+ renderer.clockFastForward(150)
+ expect(boundsOf(newId)![0]).toBeCloseTo(baseX, 0)
+ expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0)
+
+ renderer.clockFastForward(400)
+ expect(boundsOf(oldId)).toBeNull()
+ })
+
+ it("animates a name that enters without a captured pair", () => {
+ const { render, renderer } = root
+ renderer.clockPause()
+ render(
)
+
+ startViewTransition(
+ renderer,
+ () => render( ),
+ { groups: { screen: { duration: 0.3, ease: "linear", new: { translateY: ["100%", "0%"] } } } }
+ )
+ const id = screenId()
+ const baseY = 200 * 1.0
+
+ // The screen is 200 high, so it starts one height down and slides up.
+ expect(boundsOf(id)![1]).toBeCloseTo(baseY, 0)
+ renderer.clockFastForward(150)
+ expect(boundsOf(id)![1]).toBeCloseTo(baseY / 2, 0)
+ renderer.clockFastForward(400)
+ expect(boundsOf(id)![1]).toBeCloseTo(0, 0)
+ })
+
+ it("paints an exit copy for a name that leaves without a successor", () => {
+ const { render, renderer } = root
+ renderer.clockPause()
+ render( )
+ const oldId = screenId()
+ const baseX = boundsOf(oldId)![0]
+
+ // The next tree has no element with the name. The frozen copy paints
+ // over the tree and the `old` side slides and blurs it out.
+ startViewTransition(renderer, () => render(
), {
+ groups: {
+ screen: {
+ duration: 0.3,
+ ease: "linear",
+ old: { translateX: ["0%", "100%"], opacity: [1, 0], blur: [0, 6] },
+ },
+ },
+ })
+
+ expect(boundsOf(oldId)![0]).toBeCloseTo(baseX, 0)
+ renderer.clockFastForward(150)
+ expect(boundsOf(oldId)![0]).toBeCloseTo(baseX + 150, 0)
+ renderer.clockFastForward(400)
+ expect(boundsOf(oldId)).toBeNull()
+ })
+
+ it("a fresh start replaces a running transition", () => {
+ const { render, renderer } = root
+ renderer.clockPause()
+ render( )
+ const baseX = boundsOf(screenId())![0]
+
+ startViewTransition(renderer, () => render( ), PUSH)
+ renderer.clockFastForward(150)
+
+ // Start again mid-flight. The second transition captures the moved pair
+ // and runs on its own clock from here.
+ startViewTransition(renderer, () => render( ), PUSH)
+ const thirdId = screenId()
+ expect(boundsOf(thirdId)![0]).toBeCloseTo(baseX + 300, 0)
+ renderer.clockFastForward(500)
+ expect(boundsOf(thirdId)![0]).toBeCloseTo(baseX, 0)
+ })
+
+ it("transitions an element React updates in place", () => {
+ const { render, renderer } = root
+ renderer.clockPause()
+ // No keys: React keeps the element and only swaps its style. The frozen
+ // copy takes a fresh id, so the live element and the copy never share
+ // GPUI element state.
+ render(
)
+ const id = screenId()
+
+ startViewTransition(renderer, () =>
+ render(
+
+ )
+ )
+ expect(screenId()).toBe(id)
+ renderer.clockFastForward(500)
+ expect(boundsOf(id)![3]).toBeCloseTo(100, 0)
+ })
+
+ it("runs the update alone on a renderer without the native methods", () => {
+ let ran = false
+ const bare = {} as Parameters[0]
+ startViewTransition(bare, () => {
+ ran = true
+ })
+ expect(ran).toBe(true)
+ })
+})
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index b9a008a5..9c4c7514 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -9,6 +9,13 @@ export {
startFrameLoop,
} from "./reconciler/renderer.js"
export { GpuixContext, useGpuix, useGpuixRequired } from "./hooks/use-gpuix.js"
+export { startViewTransition } from "./view-transitions.js"
+export type {
+ ViewTransitionGroupOptions,
+ ViewTransitionLength,
+ ViewTransitionOptions,
+ ViewTransitionSide,
+} from "./view-transitions.js"
export { useWindowInsets, useWindowSize } from "./hooks/use-window-size.js"
export { findRanges, useTextSearch } from "./hooks/use-text-search.js"
export type {
diff --git a/packages/react/src/testing.ts b/packages/react/src/testing.ts
index 51215786..fa36472b 100644
--- a/packages/react/src/testing.ts
+++ b/packages/react/src/testing.ts
@@ -68,6 +68,8 @@ interface NativeTestRendererApi extends NativeRenderer {
scrollToItem(elementId: number, index: number, offsetInItem?: number): void
scrollIntoView(elementId: number, block?: string, inline?: string): void
getScrollOffset(elementId: number): number[] | null
+ viewTransitionCapture(): void
+ viewTransitionStart(options?: string): void
getListScrollTop(elementId: number): number[] | null
setDebugFrameOverlay(mode: DebugFrameOverlayMode): string
getDebugFrameOverlay(): string
@@ -583,6 +585,22 @@ export class TestRenderer implements NativeRenderer {
return [result[0], result[1], result[2]]
}
+ // ── View transitions ────────────────────────────────────────────
+
+ /** Clone every element that has a `viewTransitionName`, with its painted
+ * bounds. The flush first makes those bounds current. */
+ viewTransitionCapture(): void {
+ this.native.flush()
+ this.native.viewTransitionCapture()
+ }
+
+ /** Animate every captured name toward its new element. Pause the clock
+ * first and move it to step through the frames. */
+ viewTransitionStart(options?: string): void {
+ this.native.viewTransitionStart(options)
+ this.native.flush()
+ }
+
// ── Selection API ───────────────────────────────────────────────
/** Drag-select from (x1,y1) to (x2,y2) and return the selected text.
diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts
index d662d0dd..0cee5ca4 100644
--- a/packages/react/src/types/host.ts
+++ b/packages/react/src/types/host.ts
@@ -26,6 +26,8 @@ export interface MotionStyle {
*/
height?: number | "auto"
opacity?: Numeric
+ /** A `filter: blur()` sigma in pixels, on the element and its children. */
+ blur?: Numeric
top?: Numeric
right?: Numeric
bottom?: Numeric
@@ -305,6 +307,9 @@ export interface StyleDesc {
scrollPaddingRight?: Numeric
scrollPaddingBottom?: Numeric
scrollPaddingLeft?: Numeric
+ /** The name that pairs this element across a `startViewTransition` call:
+ * the old element with this name animates into the new one. */
+ viewTransitionName?: string
/** `auto`, `contain` or `none`, one word for both axes or two with the x
* axis first. A scroll box keeps a wheel event it can scroll with. At its
* end, `auto` hands the event to the nearest scroll box around it and
@@ -810,6 +815,15 @@ export interface NativeRenderer {
* else. `itemIndex == item count` is gpui's at-end sentinel. */
getListScrollTop?(elementId: number): Array | null
+ // ── View transitions ───────────────────────────────────────────
+ /** Clone every element that has a `viewTransitionName`, with its painted
+ * bounds. Call before the update, then `viewTransitionStart` after it.
+ * `startViewTransition` does both. */
+ viewTransitionCapture?(): void
+ /** Animate every captured name toward its new element. `options` is the
+ * JSON of a `ViewTransitionOptions` value, or nothing for a crossfade. */
+ viewTransitionStart?(options?: string): void
+
// ── Selection API ──────────────────────────────────────────────
/** The current text selection joined in document order, or null. */
getSelectedText?(): string | null
diff --git a/packages/react/src/view-transitions.ts b/packages/react/src/view-transitions.ts
new file mode 100644
index 00000000..a098f342
--- /dev/null
+++ b/packages/react/src/view-transitions.ts
@@ -0,0 +1,68 @@
+// The View Transitions API: capture the named elements, apply the React
+// update synchronously, then animate each name from its old place to its
+// new one. The native renderer owns the animation, so React renders once.
+
+import { flushSync } from "./reconciler/reconciler.js"
+import type { MotionEase, NativeRenderer } from "./types/host.js"
+
+/** A translation distance: pixels as a number or "Npx", or a share of the
+ * element's size as "N%". */
+export type ViewTransitionLength = number | string
+
+/** What one side of a pair does over the transition. Every field is a
+ * `[from, to]` pair. A missing field holds still. */
+export interface ViewTransitionSide {
+ translateX?: [ViewTransitionLength, ViewTransitionLength]
+ translateY?: [ViewTransitionLength, ViewTransitionLength]
+ opacity?: [number, number]
+ /** A `filter: blur()` sigma in pixels. */
+ blur?: [number, number]
+ /** Paint this side over the other one. Only read on `old`. */
+ onTop?: boolean
+}
+
+export interface ViewTransitionGroupOptions {
+ /** Seconds, like the `motion` prop. The default is 0.3. */
+ duration?: number
+ /** Seconds before this group starts. */
+ delay?: number
+ ease?: MotionEase
+ /** The element that leaves. When a group gives neither `old` nor `new`,
+ * the pair crossfades. */
+ old?: ViewTransitionSide
+ /** The element that arrives. */
+ new?: ViewTransitionSide
+}
+
+export interface ViewTransitionOptions {
+ /** Seconds, for every group that does not set its own. */
+ duration?: number
+ delay?: number
+ ease?: MotionEase
+ /** Options per `viewTransitionName`. A name with no entry crossfades. */
+ groups?: Record
+}
+
+/**
+ * Run `update` and animate every element that carries a
+ * `viewTransitionName` from its place before the update to its place after
+ * it. Give the leaving screen and the arriving screen the same name to
+ * animate a navigation as a pair. A name that only leaves paints a frozen
+ * copy over the tree while its group's `old` side runs, without the clip
+ * of its former ancestors.
+ *
+ * On a renderer without the native methods, this runs `update` alone.
+ */
+export function startViewTransition(
+ renderer: NativeRenderer,
+ update: () => void,
+ options?: ViewTransitionOptions,
+): void {
+ if (!renderer.viewTransitionCapture || !renderer.viewTransitionStart) {
+ update()
+ return
+ }
+ renderer.viewTransitionCapture()
+ flushSync(update)
+ renderer.viewTransitionStart(JSON.stringify(options ?? {}))
+}