fix(renderer): jump instead of smooth-scrolling under reduced motion (#26796)

* fix(renderer): jump instead of smooth-scrolling under reduced motion

A scripted scroll's explicit behavior ignores the reduce-motion setting.

* test(renderer): cover motionSafeScrollBehavior under reduced motion

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Kelvin Amoaba
2026-10-11 16:11:10 -07:00
committed by GitHub
co-authored by Brennan Benson
parent b02240639d
commit 641b9773cc
12 changed files with 74 additions and 22 deletions
+1 -1
View File
@@ -264,7 +264,7 @@ Use this rubric when reviewing any Orca IDE screen, screenshot, or prototype. A
- **Layout density:** avoid jamming controls together. Preserve breathing room around the primary workflow, reduce competing buttons, and keep toolbar groups visually distinct. Dense screens are acceptable only when grouping, alignment, and hierarchy make scanning faster.
- **Cards and containers:** cards must be visually distinct from their parent surface through the existing `card`/`border` treatment. Avoid nesting cards inside cards. If a section is not a repeated item, modal, or framed tool, consider an unframed layout or full-width band instead.
- **Side-by-side layouts:** default to row-by-row layouts for complex workflows because they are easier to align and scan. Use side-by-side layouts only when space is constrained or comparison is the point, then polish column widths, baselines, and wrapping states carefully.
- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay.
- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay. Scripted scrolls ignore CSS, so pass them through `motionSafeScrollBehavior()`.
- **SSH and latency:** assume actions may run remotely. Disable submit controls immediately, delay visible loading feedback when appropriate, and keep focus stable while remote data arrives.
### 1. Match in-flight feedback to perceived duration
@@ -1,4 +1,5 @@
import { MarkdownHeadingSlugger } from './markdown-heading-slug'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
// Why: duplicate headings need the same stateful suffixes as the preview
// renderer (foo, foo-1, foo-2) or anchor links can jump to the wrong heading.
@@ -16,7 +17,7 @@ export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string)
const slugger = new MarkdownHeadingSlugger()
for (const heading of headings) {
if (slugger.slug(heading.textContent ?? '') === decoded) {
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
heading.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'start' })
return
}
}
@@ -6,6 +6,7 @@ import type { MarkdownPreviewBlockRange } from './markdown-preview-types'
import { requestMarkdownPreviewEditorRevealFrame } from './markdown-preview-editor-reveal'
import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation'
import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
function isMarkdownAnnotationNavigationClick(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@@ -190,7 +191,7 @@ export function useMarkdownPreviewReviewActions({
return
}
findRenderedMarkdownReviewNoteCard(comment.id)?.scrollIntoView({
behavior: 'smooth',
behavior: motionSafeScrollBehavior(),
block: 'center',
inline: 'nearest'
})
@@ -226,7 +227,7 @@ export function useMarkdownPreviewReviewActions({
break
}
}
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
target?.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'center' })
},
[rootRef, scrollToSourceLine, setActiveReviewCommentId]
)
@@ -13,6 +13,7 @@ import {
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
type UseRichMarkdownReviewRailControllerOptions = {
canAnnotateRichMarkdown: boolean
@@ -182,7 +183,7 @@ export function useRichMarkdownReviewRailController({
setActiveReviewCommentId(comment.id)
container.scrollTo({
top: Math.max(0, (sourceTop + sourceBottom) / 2 - container.clientHeight / 2),
behavior: 'smooth'
behavior: motionSafeScrollBehavior()
})
pulseRichMarkdownSourceRange({ from: bounds.from, to: bounds.to })
},
@@ -257,5 +258,5 @@ function centerReviewNoteCard(
return
}
const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2)
container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' })
container.scrollTo({ top: Math.max(0, targetTop), behavior: motionSafeScrollBehavior() })
}
@@ -31,6 +31,7 @@ import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides'
import { translate } from '@/i18n/i18n'
import { PRCommentGroupView } from './comment-group'
import { useNow } from '@/hooks/use-now'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
export type PRCommentsListDisplayMode = 'triage' | 'timeline'
export const PR_COMMENT_LIST_DISPLAY_MODES: PRCommentsListDisplayMode[] = ['triage', 'timeline']
@@ -95,7 +96,7 @@ export function useCommentsListState({
function scrollElementBottomIntoView(element: HTMLElement): void {
const scrollParent = findVerticalScrollParent(element)
if (!scrollParent) {
element.scrollIntoView({ block: 'end', behavior: 'smooth' })
element.scrollIntoView({ block: 'end', behavior: motionSafeScrollBehavior() })
return
}
@@ -106,7 +107,7 @@ export function useCommentsListState({
if (bottomOverflow > 0) {
scrollParent.scrollTo({
top: scrollParent.scrollTop + bottomOverflow,
behavior: 'smooth'
behavior: motionSafeScrollBehavior()
})
return
}
@@ -115,7 +116,7 @@ export function useCommentsListState({
if (topOverflow < 0) {
scrollParent.scrollTo({
top: Math.max(0, scrollParent.scrollTop + topOverflow),
behavior: 'smooth'
behavior: motionSafeScrollBehavior()
})
}
}
@@ -33,6 +33,7 @@ import {
import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers'
import { translate } from '@/i18n/i18n'
import { resolveAvailableBrowserSessionHostId } from './browser-session-host-selection'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
export { getBrowserPaneCombinedSearchEntries }
type BrowserPaneProps = {
@@ -204,7 +205,7 @@ export function BrowserPane({
if (!el) {
return
}
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
el.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'start' })
})
})
}
@@ -11,6 +11,7 @@ import {
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { TerminalThemeOption } from '@/lib/terminal-theme'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
type ThemePickerProps = {
label: string
@@ -45,7 +46,10 @@ export function ThemePicker({
if (!importedHighlightSignal) {
return
}
importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
importedGroupRef.current?.scrollIntoView({
behavior: motionSafeScrollBehavior(),
block: 'nearest'
})
setHighlightImported(true)
const timer = setTimeout(() => setHighlightImported(false), 2000)
return () => clearTimeout(timer)
@@ -1,4 +1,5 @@
import { GROUP_HEADER_ROW_HEIGHT } from './worktree-list/viewport/virtual-rows'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
const WORKTREE_REVEAL_TOP_CLEARANCE = 6
export const WORKTREE_SIDEBAR_REVEAL_TOP_INSET =
@@ -54,16 +55,8 @@ export function revealElementInScrollContainer(
if (nextScrollTop === null) {
return true
}
// Why: honor the user's reduced-motion preference by jumping instantly instead of
// animating a smooth scroll (also makes the reveal deterministic in headless
// environments that never tick the smooth-scroll animation).
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
const resolvedBehavior: ScrollBehavior =
behavior === 'smooth' && prefersReducedMotion ? 'auto' : behavior
const targetTop = Math.max(0, nextScrollTop)
onScrollIssued?.(targetTop)
container.scrollTo({ top: targetTop, behavior: resolvedBehavior })
container.scrollTo({ top: targetTop, behavior: motionSafeScrollBehavior(behavior) })
return true
}
@@ -1,5 +1,6 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { computeTabStripThumbLayout } from './tab-strip-scroll-metrics'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
export type TabStripScrollIndicatorProps = {
hasOverflow: boolean
@@ -171,7 +172,7 @@ export function TabStripScrollIndicator({
scrollContainer.scrollTo({
left: targetScrollLeft,
behavior: 'smooth'
behavior: motionSafeScrollBehavior()
})
}
@@ -20,6 +20,7 @@ import {
revealTabStripSlot,
type ActiveTabDockSide
} from './tab-strip-slot-geometry'
import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion'
const TAB_STRIP_SCROLL_FRACTION = 0.75
const TAB_STRIP_MIN_SCROLL_STEP_PX = 120
@@ -35,7 +36,7 @@ export function scrollTabStripByStep(
)
el.scrollBy({
left: direction === 'start' ? -scrollStep : scrollStep,
behavior
behavior: motionSafeScrollBehavior(behavior)
})
}
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { motionSafeScrollBehavior } from './usePrefersReducedMotion'
function stubReducedMotion(reduce: boolean): ReturnType<typeof vi.fn> {
const matchMedia = vi.fn((query: string) => ({
matches: reduce && query === '(prefers-reduced-motion: reduce)'
}))
vi.stubGlobal('window', { matchMedia })
return matchMedia
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('motionSafeScrollBehavior', () => {
it('jumps instead of smooth-scrolling when reduced motion is on', () => {
const matchMedia = stubReducedMotion(true)
expect(motionSafeScrollBehavior()).toBe('auto')
expect(motionSafeScrollBehavior('smooth')).toBe('auto')
expect(motionSafeScrollBehavior('instant')).toBe('instant')
expect(motionSafeScrollBehavior('auto')).toBe('auto')
expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
})
it('keeps the requested behavior when reduced motion is off', () => {
stubReducedMotion(false)
expect(motionSafeScrollBehavior()).toBe('smooth')
expect(motionSafeScrollBehavior('smooth')).toBe('smooth')
expect(motionSafeScrollBehavior('instant')).toBe('instant')
})
it('keeps the requested behavior without matchMedia', () => {
expect(typeof window).toBe('undefined')
expect(motionSafeScrollBehavior()).toBe('smooth')
vi.stubGlobal('window', {})
expect(motionSafeScrollBehavior('smooth')).toBe('smooth')
expect(motionSafeScrollBehavior('auto')).toBe('auto')
})
})
@@ -9,6 +9,11 @@ function readPrefersReducedMotion(): boolean {
return window.matchMedia(REDUCED_MOTION_QUERY).matches
}
// Why: a scripted scroll's explicit `behavior` is not affected by CSS or the OS setting.
export function motionSafeScrollBehavior(behavior: ScrollBehavior = 'smooth'): ScrollBehavior {
return behavior === 'smooth' && readPrefersReducedMotion() ? 'auto' : behavior
}
export function usePrefersReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(readPrefersReducedMotion)