diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx
index d37ca81042..88300ca491 100644
--- a/client/src/components/Chat/ChatView.tsx
+++ b/client/src/components/Chat/ChatView.tsx
@@ -126,9 +126,14 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
)}
>
{content}
+ {/* Named + opaque so a view transition (the ask_user_question
+ popover ⇄ chat-card morph) paints the whole composer band
+ over the travelling card instead of letting it show
+ through below the composer. The background matches the
+ page, so normal rendering is unchanged. */}
diff --git a/client/src/style.css b/client/src/style.css
index 91b4a47561..917a802209 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -3027,3 +3027,17 @@ html {
.sharepoint-picker-bg {
background-color: #f5f5f5;
}
+
+/* ask_user_question popover <-> chat-card morph (view transition) */
+::view-transition-group(ask-question),
+::view-transition-old(ask-question),
+::view-transition-new(ask-question) {
+ animation-duration: 280ms;
+ animation-timing-function: cubic-bezier(0.32, 0.72, 0.22, 1);
+}
+
+/* The composer keeps its own snapshot group stacked above the morphing
+ question card, so the card travels behind the ChatForm, not over it. */
+::view-transition-group(chat-form) {
+ z-index: 2;
+}
diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts
index 0078d1666a..58e4674984 100644
--- a/client/src/utils/index.ts
+++ b/client/src/utils/index.ts
@@ -11,6 +11,7 @@ export * from './share';
export * from './files';
export * from './latex';
export * from './tilde';
+export * from './morph';
export * from './forms';
export * from './roles';
export * from './errors';
diff --git a/client/src/utils/morph.ts b/client/src/utils/morph.ts
new file mode 100644
index 0000000000..8ffa2b4f08
--- /dev/null
+++ b/client/src/utils/morph.ts
@@ -0,0 +1,21 @@
+import { flushSync } from 'react-dom';
+
+/**
+ * Runs a state update inside a same-document view transition when supported,
+ * so elements sharing a `view-transition-name` morph (position, size, shape)
+ * between the two states instead of swapping. Falls back to applying the
+ * update directly. Call from user-event handlers only: the update is flushed
+ * synchronously so the browser can snapshot the old and new layouts.
+ */
+export function morphTransition(update: () => void): void {
+ if (
+ typeof document.startViewTransition !== 'function' ||
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ ) {
+ update();
+ return;
+ }
+ document.startViewTransition(() => {
+ flushSync(update);
+ });
+}