Skip to content

Motion system to make Zed feel fluid and intentional - #48295

Closed
srbsingh3 wants to merge 27 commits into
zed-industries:mainfrom
srbsingh3:animations
Closed

Motion system to make Zed feel fluid and intentional#48295
srbsingh3 wants to merge 27 commits into
zed-industries:mainfrom
srbsingh3:animations

Conversation

@srbsingh3

@srbsingh3 srbsingh3 commented Feb 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Zed feels snappy but mechanical - panels and modals appear instantly, losing spatial context.

Demo

All five surfaces with reduce_motion toggle:

demo.mp4

Changes

Surface Animation
Panels (docks & utility) Slide in from edge
Modals Fade + subtle slide
Popover menus Fade + subtle slide
Picker list Smooth selection tracking

Plus a reduce_motion setting that respects macOS accessibility preferences by default.

Motion principles

  • 150ms open, 100ms close - entrances can breathe; exits stay snappy
  • ease_out_cubic for panels/modals - responsive start, gentle settle
  • ease_in_out for picker - selection travels between items, needs acceleration and deceleration
  • Consistent timing across all surfaces

Accessibility

New setting: reduce_motion: "system" | "on" | "off"

Defaults to system, which reads the macOS preference. When enabled, animations skip gracefully.

Implementation notes

  • Built on GPUI's Animation and easing primitives
  • Timing centralized as constants for easy tuning
  • Animations cancel cleanly on rapid toggle - no stacking or glitches
  • 29 unit tests covering state machines and edge cases

Note: In practice, I'd ship this as smaller, incremental PRs (one surface at a time). This is bundled as a single prototype to show the complete motion system working together.

Add smooth entrance animation to popover menus with opacity fade-in
(0.4 to 1.0) and quadratic height unfold effect. Animation uses
ease-out-quint easing over 150ms (AnimationDuration::Fast) for
polished UI feel.

Uses centralized AnimationDuration constant and named constants for
animation parameters to maintain consistency with existing codebase
patterns (matching animation.rs fade-in behavior).
Replace the previous height-based unfold animation with a simpler
opacity fade and vertical slide animation to better match the smooth,
polished feel of modern UI patterns like Linear's dropdown menus.

Previous approach used progressive height revealing (max-height with
quadratic easing) which created a jarring clipping effect. New approach
uses full opacity fade (0→1) combined with subtle 6px upward slide for
natural, fluid menu entrance.

Changes:
- Remove height-unfold logic (max-height, overflow-hidden)
- Remove partial opacity start (0.4 → 1.0)
- Add clean fade-in (0 → 1) with -6px vertical slide
- Use .into() for AnimationDuration consistency
Dock panels (agent, notification, terminal, debug) now smoothly slide
into view when toggled. Right dock panels slide in from the right,
bottom dock panels slide in from the bottom, and left dock panels
slide in from the left. Animation includes 16px slide with fade-in
effect over 150ms using ease_out_quint easing.

Implementation uses an open_generation counter to ensure animations
replay on each toggle, preventing stale animation state.
Changed dock panel animations from opacity fade + position offset to
physical width/height transitions for a true slide-in/slide-out
effect. Panels now expand from 0 to full size when opening and
collapse from full to 0 when closing.

Added close animation state tracking (is_closing, close_generation,
_close_task) to keep panels visible during the 150ms close animation.
Opening a panel while closing cancels the close animation task.

This provides the expected drawer behavior: right/left docks slide
horizontally, bottom dock slides vertically, all matching their edge
position.
Changed easing from ease_out_quint to ease_out_cubic to eliminate
visual fade artifacts caused by the aggressive deceleration curve.
Quint reaches 83% progress at 30% duration, making the tail appear
to fade in. Cubic distributes motion more evenly for a clean slide.

Asymmetric timing: 150ms open, 100ms close. Faster exit feels
snappier and more responsive when dismissing panels.
Use direction-specific animation ID prefixes ("dock-open" vs "dock-close")
to prevent GPUI from treating close animations as already-completed open
animations. Previously both open_generation and close_generation would
reach the same value after one cycle, causing identical animation IDs
and skipped close transitions.
Replace dual-counter approach (open_generation + close_generation with
direction-specific prefixes) with a single monotonically increasing
animation_generation counter. This achieves the same uniqueness
guarantee more simply: every state transition increments one counter,
naturally producing unique animation IDs without branching logic or
prefix disambiguation.

The previous fix prevented animation skipping by using different
prefixes for open vs close. This refactor solves it at the root by
ensuring the generation value itself is always unique per transition.
Implements smooth fade and slide animations when modals open and close.
Modals now animate in over 150ms with an ease-out-cubic curve (opacity
0->1, slide from -6px) and animate out over 100ms with the reverse.

The animation state machine prevents flickering during rapid
open/close/reopen sequences by tracking animation generations and
managing a closing modal state. Focus is restored immediately on
dismiss so users can type right away, while the visual animation
completes asynchronously.

This change affects all 51 ModalView implementors since it modifies
the shared ModalLayer render path. Key modals to verify:
- Command Palette (Cmd+Shift+P)
- File Finder (Cmd+P)
- Outline (Cmd+Shift+O)
- Theme Selector (Cmd+K Cmd+T)
- Tab Switcher (Ctrl+Tab)
- Go to Line (Ctrl+G)
- Branch Picker, Git Commit Modal, Recent Projects
- Onboarding modals (agent, debugger, edit prediction)

Modals that override render_bare() (e.g. DisconnectedOverlay) bypass
the animation and render directly, so they should be unaffected.
Adds a tri-state `reduce_motion` setting that controls UI animations:
- "system" (default): follows macOS accessibility preference
- "on": always skip animations
- "off": always animate

Implementation details:
- Integrates NSWorkspace.accessibilityDisplayShouldReduceMotion on macOS
- Exposes Platform.should_reduce_motion() through App context
- Gates animations in popovers, dock panels, and modal dialogs
- Adds setting to Appearance page in Settings UI
- Always registers animations but skips visual effects when reduced,
  preventing false re-animation on setting changes
Adds a convenience function `should_reduce_motion(cx)` that replaces
the verbose `ReduceMotionSetting::get_global(cx).should_reduce_motion(cx)`
pattern at all call sites.

Additional cleanups:
- Removed redundant if/else branch in dock close logic
- Renamed abbreviated variable `close_gen` to `close_generation`
- Inlined single-use variable in modal layer
- Fixed alphabetical ordering of pub use exports
Extracts duration constants and eliminates redundant logic:

- Adds MODAL_OPEN_DURATION and MODAL_CLOSE_DURATION constants to
  centralize the timing values used in both the close timer and
  animation duration, preventing drift between the two
- Simplifies DismissDecision::Dismiss match to eliminate tautology
  where `!should_dismiss` was checked twice
- Merges two active_modal borrows in render() into one, deriving
  is_closing from which branch was taken rather than computing it
  separately before the early return
Utility panes (used by Agent V2 for thread detail views) now animate
when appearing and disappearing, matching the existing dock panel
animation pattern.

Animations:
- Slide in from left/right (150ms, ease_out_cubic) on open
- Slide out to width 0 (100ms, ease_out_cubic) on close
- Respect macOS accessibility reduce-motion setting

Implementation follows the dock animation pattern exactly:
- Animation generation counter prevents state conflicts
- Async close task waits for animation before removing pane
- New utility_pane_frame() helper simplifies all 10 render call sites
- Resize handle hidden during close animation for polish
Centralized Left/Right slot dispatch into UtilityPaneState::slot() and
slot_mut() accessor methods, eliminating 7 duplicated match blocks
throughout the file.

Additional improvements:
- Flattened clear_utility_pane() with early-return for reduce_motion
- Simplified resize handle creation by extracting shared styling
- Renamed single-letter variable to full word (e → event)
- Combined generation check in async close task into single expression

No behavior changes - same animation parameters and logic.
The selection-overlay-animation.patch was accidentally included in
commit fdf706f. This patch file was a development artifact and
should not have been committed to the repository.
Implements a smooth sliding animation for the selection indicator in
uniform_list pickers (e.g., command palette). When navigating with
arrow keys, the selection now smoothly slides between items instead of
jumping abruptly.

Implementation details:
- Created SelectionIndicator decoration using UniformListDecoration
  trait to render an animated overlay behind list items
- Added tracking for previous selection index to enable smooth
  transitions between arbitrary positions
- Clamped animation distance to 3 items max to prevent jarring long-
  distance animations when jumping via search results changes
- Used 80ms cubic ease-out timing for natural, responsive feel
- Suppressed per-item selection backgrounds in uniform lists to avoid
  dual-highlight visual conflict
- Added reduce-motion support for accessibility
- Fixed paint order in uniform_list so decorations render behind items

The animation uses a wrapper div positioning approach to avoid layout
issues with absolute positioning in decoration contexts.
Improved the selection indicator animation to feel smoother and more
polished:

- Changed easing from cubic ease-out to ease-in-out for visible
  motion throughout the transition instead of frontloaded movement
- Increased duration from 80ms to 150ms to give the easing curve
  room to express its character
- Fixed overlay width to match ListItem hover background by using
  horizontal insets (Base04 spacing) instead of full-width
- Skip animation when previous selection is outside visible range to
  prevent jarring motion during scroll jumps

The ease-in-out curve starts slowly, accelerates through the middle,
and decelerates at the end, creating a natural gliding motion similar
to selection animations in polished applications like VS Code and
macOS system UI.
Fixed jarring motion when navigating past visible edges of the picker
list in both directions. Previously, animation would play while
scrolling at the top and bottom edges, causing a jittery up-down or
down-up visual effect.

Now tracks the visible item range and excludes partially visible edge
items from the "safe to animate" zone. When the new selection is
outside the fully-visible range, animation is skipped and the
indicator stays stationary while list content scrolls underneath.

This creates smooth scrolling behavior at both boundaries: the
selection indicator remains fixed at the edge position while the list
content slides beneath it, matching natural list navigation UX.
Extract helper methods and eliminate duplication in picker selection
indicator rendering:

- Add SelectionIndicator::animated_origin() to encapsulate animation
  eligibility checks and remove unwrap() call that could panic
- Deduplicate indicator element styling by extracting shared base div
- Use idiomatic match instead of if/else for Option handling
- Extract is_fully_visible() method to clarify visibility logic and
  eliminate variable shadowing
- Rename use_overlay to has_selection_overlay for clearer intent
- Add explanatory comments for non-obvious behavior

These changes improve code clarity and follow project guidelines
(no unwrap, full words for variable names, explaining "why" in
comments) while preserving all existing functionality.
Deduplicate cubic easing function across dock, modal, and utility pane
animations by adding ease_out_cubic to gpui's easing module.

Skip animation infrastructure entirely when reduce_motion is enabled,
avoiding unnecessary element wrapping and frame scheduling. Previously
animations ran but returned unchanged elements.

Fix unwrap() in reduce_motion_setting to use unwrap_or_default(),
preventing potential panic if the setting is missing.
Add exhaustive unit tests for all animation state machines and pure
logic functions introduced in the animations branch. Tests focus on
behavior correctness, not visual rendering.

Test coverage:
- reduce_motion_setting: 6 tests for On/Off/System behavior, defaults,
  settings integration, and global function
- picker: 14 tests for SelectionIndicator::animated_origin (reduce
  motion, visibility, clamping, boundaries) and is_fully_visible logic
  (safe range, scroll boundaries, list edges, empty cases)
- dock: 10 tests for open/close state machine (set_open, reduce_motion
  skip, animation completion/cancellation, double operations, panel
  activation, generation tracking)
- modal_layer: 11 tests for toggle/hide/show operations, animation
  lifecycle, reduce_motion skip, type safety, and state queries
- utility_pane: 3 tests for slot mapping and dock position conversion

All 44 tests pass. Uses GPUI test framework with proper async executor
clock advancement for timer-based animations.
Refactor test code added in the previous commit to improve clarity
and maintainability while preserving all functionality:

- Remove organizational and descriptive comments per CLAUDE.md
  (comments should explain "why", not "what")
- Extract repeated test setup patterns into helper functions
  (init_test, set_reduce_motion, add_panel_to_dock)
- Replace duplicate TestModal structs with define_test_modal! macro
  to eliminate ~40 lines of boilerplate
- Rename abbreviated variables to full words (ind→indicator,
  gen→generation, cx→_cx when unused)
- Inline single-use intermediate variables into assertions
- Use std::ptr::eq() for pointer identity checks instead of raw casts
- Add doc comment explaining non-obvious test helper design

All 44 tests still pass.
Reduce test count from 44 to 22 by merging redundant test cases
while maintaining complete coverage. Improves maintainability without
sacrificing test quality.

Consolidations:
- picker: Merge 3 "returns None" cases into 1, merge 4 "computes
  position" cases into 1, merge visibility boundary tests (7→2)
- settings: Merge On/Off/System/Default variants into single test
- dock: Merge open+close lifecycle, merge animation completion with
  visible_entry check, merge double-open and double-close noop tests
- modal_layer: Merge toggle open+close, fold hide_empty into
  hide_animation, drop redundant type-query tests
- utility_pane: Merge left+right slot tests into single test

Additional improvements:
- Extract dock test boilerplate into add_dock_with_panel helper
- Add new_modal_layer helper to reduce repetition
- Remove comments that restate code (keep only "why" comments)

All 22 tests pass with same coverage as original 44 tests.
Fixes three critical issues in animation state management:

1. Silent error handling: Changed modal_layer.rs spawn_in pattern from
   .ok() (which discarded errors) to upgrade() pattern that properly
   handles entity lifecycle

2. Integer overflow safety: Replaced += 1 with wrapping_add(1) for
   animation_generation counters across modal_layer, dock, and
   utility_pane to prevent panic on usize overflow

3. Magic number elimination: Extracted animation duration constants
   (OPEN_DURATION: 150ms, CLOSE_DURATION: 100ms) and MODAL_SLIDE_OFFSET
   (-6.0px) for maintainability

Also removed summary doc comments per CLAUDE.md guidelines (comments
should explain "why", not "what") and added explanatory comments for
non-obvious animation_generation increment pattern that prevents stale
close tasks from interfering with new animation cycles.
Adds comprehensive test coverage for previously untested code:

- ease_out_cubic easing function: boundary value tests (0.0, 1.0),
  monotonicity verification across 100 steps, and midpoint behavior
  validation to ensure proper ease-out deceleration curve

- UtilityPaneState slot accessors: pointer identity tests verifying
  slot() and slot_mut() return references to correct internal fields,
  default state validation, and slot independence verification

Note: Animation lifecycle tests (is_closing, animation_generation,
_close_task transitions) require full Workspace fixture and are better
suited for integration tests following dock.rs patterns.
Removes summary doc comments that described "what" the code does
(which is clear from reading the code itself) from picker.rs.

Adds explanatory comment in uniform_list.rs clarifying why
decorations paint before items: backgrounds like selection highlights
must render behind item content for proper visual layering.

Per CLAUDE.md: comments should only explain "why", not "what".
Fixed documentation for reduce_motion setting to show correct values
"on"/"off" instead of incorrect "true"/"false".

The ReduceMotion enum uses "on", "off", and "system" variants, not
boolean values. Documentation now matches actual implementation.
When a user re-expands a utility pane during the 100ms close animation
window, the pending close task would fire and clear the slot because
toggle_utility_pane didn't cancel the close animation or bump the
generation counter.

Now when expanding, we reset is_closing, increment animation_generation
to invalidate the stale close task, and clear _close_task.
@zed-community-bot zed-community-bot Bot added the first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions label Feb 3, 2026
@srbsingh3

srbsingh3 commented Feb 3, 2026

Copy link
Copy Markdown
Contributor Author

Hey @danilo-leal I'm applying for the Product Designer role and wanted to show rather than tell. I applied about a month ago as well, but I've been building a lot since then and felt it was worth re-applying. Would love your thoughts on the motion approach if you get a chance.

@danilo-leal

Copy link
Copy Markdown
Member

Heya @srbsingh3, thanks for taking the time to work on this! This looks good and well done; appreciate the tests and the respect for the reduced motion configuration; very important for accessibility. However, even though I find this super sweet, and might even want to explore having some of these animation capabilities in Zed's tool belt for specific moments/areas, I don't think we should merge this as is. Zed's main feature is performance and speed, and I think overtime, these animations end up significantly hurting the perception of speed—it's a power-use product that should feel absurdly snappy, and having main workspace elements frequently animate compounds to a feeling of slight slowness, even if the animations are fast and have good curves. I'll close this one for now, but I'd be open to smaller PRs, like you mentioned in the description, potentially adding one primitive at a time, so we can evaluate their use and implementation and see whether they make sense bringing in! Thanks again & good luck on the process!

@danilo-leal danilo-leal closed this Feb 4, 2026
Dima-369 added a commit to Dima-369/zed that referenced this pull request Feb 5, 2026
Copied from zed-industries#48295, just
trimmed down
Dima-369 added a commit to Dima-369/zed that referenced this pull request Jul 17, 2026
* Remove smooth open/close animation from all docks

Reverts the custom dock opening/closing animations that were added from
PR zed-industries#48295 and the threads sidebar animation:
- bottom/left/right panel docks (workspace render_dock + Dock::set_open)
- threads sidebar dock (MultiWorkspace)

Docks now open and close instantly again. Also drops the now-unused
ease_out_cubic easing helper and its tests from gpui, and removes the
corresponding 'Motion system' section from the README.

The left/right agent utility panes this originally touched no longer
exist upstream (removed in zed-industries#49038), so only dock.rs/multi_workspace.rs/
workspace.rs/animation.rs needed changes.

* cargo fmt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

first contribution the author's first pull request to Zed. NOTE: the label application is automated via github actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants