Skip to content

feat(ui): open images from their place in the message - #56896

Merged
kitlangton merged 3 commits into
betafrom
image-open-motion
Oct 19, 2026
Merged

kitlangton merged 3 commits into
betafrom
image-open-motion

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Stacks on #56935 (which stacks on #56917). The base stays beta, so the diff below includes both; review only the top three commits (a9c4f7ee70, 359712495a, 62fc7a3077), or compare sidebar-motion...image-open-motion.

Why

Clicking an image in a session makes the preview appear at full size in one frame, with no link to the thumbnail you clicked, and closing makes it vanish. Claude's desktop app instead grows the image out of its spot in the message while the page dims, and shrinks it back on close, which keeps you oriented and feels much better.

What Changes

The image now flies between its place on the page and the preview, in both directions.

Moment Behavior
Open The image grows from exactly where it sits (position, size, crop and corner radius) to the fitted preview. The page dims in step, and the preview panel and its zoom controls fade in as the image lands.
While open The inline image stays hidden, so there are never two copies on screen.
Close (Esc, the close button, or a backdrop click) The image shrinks back into its spot, re-measured at that moment. If you are zoomed in, it flies from the zoomed, cropped view on screen rather than snapping to fit first. The panel clears early in the flight.
Close during the open The flight turns around where it is and keeps its velocity, so the image slows, reverses and heads home rather than restarting a curve.
Thumbnail gone or scrolled off screen Plain fade instead of a flight.
Reduced motion 150 ms fade, no travel.

Clicking the open image doesn't close it: Claude doesn't either (its click toggles zoom). Double-click zoom, pinch, ⌘ + scroll, pan and the zoom controls from #56685 are unchanged.

Timing, measured from Claude

I measured the image rect frame by frame in a 60 fps screen recording of Claude's desktop preview, then fitted curves to it (progress 0 to 1, RMS error over every frame):

Claude measured Cubic-bezier fit Spring from rest Spring with launch velocity (used)
Open 285 → 651 px, no crop or overshoot; 25% after 1 frame, 61% after 3, 91% after 7, settled ≈250 ms (.2,.8,.2,1) 285 ms: 0.0094 0.155 s: 0.0318 0.2 s, bounce 0, launched at 14 × distance/s: 0.0093
Close 39% after 1 frame, 81% after 3, settled ≈170 ms same curve 180 ms: 0.0105 0.10 s: 0.0401 0.2 s, bounce 0, launched at 32.5 × distance/s: 0.0075
Backdrop dims in parallel, gentler start ease ≈300 / ≈190 ms: 0.045 / 0.059 0.34 s / 0.2 s: 0.037 / 0.049 (used)

A spring that starts from rest can't match Claude, because Claude's image starts fast. The same spring launched with an initial velocity fits as well as the best bezier on open and better on close, and one spring (0.2 s, bounce 0) covers both directions; only the launch differs. Claude starts moving ≈150 ms after the click; this branch starts as soon as the full image has decoded, 15 to 45 ms after the click in the recordings. The image stays sharp mid-flight because the flight uses the full-resolution image.

Shared-Element Flight

A stand-in <img> with the same src is laid out once at the preview image's fitted rect and moved with transform and clip-path only. A flight frame, in viewport pixels, is where the whole image would be drawn (always its own aspect ratio), which part of it shows, and the visible corner radius. Translate plus uniform scale moves the image and an inset clip crops it, so an object-fit: cover thumbnail (message and composer attachments) flies without squashing.

Each of those nine numbers rides its own Spring on the shared frameLoop, and one job per preview writes the transform, clip and opacities straight to the DOM; no CSS transitions or WAAPI drive the flight. Because every edge moves on screen, corners and crops stay even mid-flight. Opening or closing retargets the springs and adds the launch velocity to whatever velocity they already have, which is what makes the mid-open reversal continuous. Once every edge is within a quarter pixel and the backdrop has settled, the real image takes over on the same pixels; on close, the thumbnail reappears under the stand-in when it lands.

Callers pass the element the preview opens from as origin: markdown and tool output images, message attachments and composer attachments all do. Two supporting changes make the close visible:

  • The dialog stack disposes a closing dialog after a fixed 100 ms. useDialogLayer() gains setExitDuration(ms), which the preview sets from its tuned close timing (360 ms by default).
  • Kobalte unmounts closed content immediately unless a CSS animation runs, so the content holds a no-op exit animation for the same time. The preview draws its own scrim, in step with the flight, and makes the stack's overlay transparent; the overlay still handles backdrop clicks.

Motion Library and Motion Workspace

This PR no longer carries its own copy of the spring library. It uses @opencode/ui/motion from #56917 (with its closed-form spring step) through the same public API: Spring, frameLoop and prefersReducedMotion. The zoom ease still uses motion's animate().

The flight's timing is tunable in #56935's Motion workspace, in an Image open section registered through @opencode/ui/motion-tuning:

Control Default ("Claude match")
Open spring (duration, bounce) 0.2 s, 0
Open launch velocity 14 × distance/s
Close spring (duration, bounce) 0.2 s, 0
Close launch velocity 32.5 × distance/s
Backdrop in / Backdrop out springs 0.34 s / 0.2 s, bounce 0
Reduced-motion fade 0.15 s

Buttons: Open (the newest image in view in the session, or a sample thumbnail pinned bottom-left when there is none), Close, Open, close mid-flight, and presets Claude match and Calm (0.32 s / 0.26 s springs, launches 6 and 16, backdrop 0.45 s / 0.28 s, fade 0.2 s). Edits persist on the device and apply from the next open or close. The global speed and slow motion stretch the springs and divide the launch velocities by the same factor, so the curve keeps its shape. The dialog's exit hold follows the tuned values (1.8 × the longest close duration, 360 ms by default) instead of a fixed 320 ms, so slow-motion closes land before the dialog unmounts.

Shared registry changes, additive only: motionTuning.number() (with an optional unit and a time of "duration" or "rate" that applies the global speed), and the panel shows a number knob's unit. The buttons register from createImagePreview() in session-ui, so no app screen files change.

Demo

Left: beta. Right: this branch. Headless Chromium on the new UI/ImagePreview/FromMessage story (a fictional poster drawn on a canvas), with real Playwright input: click the inline image, ⌘ + scroll zoom, Esc; click a cropped 58×46 thumbnail, click the backdrop; Esc 120 ms into an open; a plain open and close. Each action is synced on its click or key press.

split-1x.mp4

The same moments at 0.25×. The right side is recorded with the page clock slowed to 0.25, so every frame is real; beta has no animation, so its 1× recording is stretched.

split-0.25x.mp4

Scope

Desktop and web image preview, plus its Image open section in the Motion workspace. The open waits for the full image to load and decode before anything appears; it uses the same URL as the thumbnail, so it's normally cached. Diff on top of #56935: 13 files, +838 / −24.

Verification

cd packages/ui
bun test src/components/image-flight.test.ts src/components/image-zoom.test.ts src/motion
bun typecheck
cd ../app
bun test --conditions=browser --isolate --preload ./happydom.ts ./test-browser/motion-dom.test.ts
bun typecheck
cd ../session-ui && bun typecheck
cd ../.. && bun run lint:changed origin/beta
  • image-flight.test.ts covers object-fit cover and contain geometry, the transform and clip onto uncropped and cropped thumbnails, a zoomed view clipped to the viewport, the spring flight following Claude's measured open (25%, 61% and 91% after 1, 3 and 7 frames, no overshoot), and a mid-flight turn-around that starts exactly where the image is and carries its outward velocity. The zoom tests and the library's own tests (pure and 17 DOM) pass on the optimized library.
  • Typecheck passes in ui, session-ui and app, and lint:changed reports 0 problems.
  • Per-frame traces from the recordings (flight rect, clip, scrim and panel opacity, inline image visibility) show no frame with two images, no jump at the hand-off between the stand-in and the real image in either direction (under 0.3% brightness change at the open hand-off), a continuous turn-around when Esc lands mid-open, and a fade with no travel under reduced motion.
  • Re-traced after the restack onto the optimized library (headless Chromium, FromMessage story): no frame shows the stand-in together with the real image, open and close hand-offs land on exactly the real image's and thumbnail's rects, the mid-open Esc turns around continuously, and with slow motion on the flight takes about 4× as long and still lands before the dialog unmounts.
  • Not run inside the Electron desktop app.

@kitlangton
kitlangton merged commit 834dfef into beta Oct 19, 2026
17 checks passed
@kitlangton
kitlangton deleted the image-open-motion branch October 19, 2026 05:37
@amrkmn amrkmn mentioned this pull request Oct 27, 2026
1 task
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant