scroll_by
Scroll any container or page by precise pixel amounts to test infinite scroll, sticky headers, or carousel navigation. Auto-detects scroll direction when only one axis is available.
Instructions
Scroll a container (or page) by a specific number of pixels. Auto-detects scroll direction when only one is available. Essential for: testing sticky headers/footers, triggering infinite scroll, carousel navigation, precise scroll position testing. Use 'html' or 'body' for page scrolling. Positive pixels = down/right, negative = up/left. Outputs: ✓ success summary with axis position and percent of max scroll; ⚠️ boundary notice when movement is limited; ⚠️ ambiguous-direction guidance when both axes scroll; ⚠️ not-scrollable report with ancestor suggestions; 💡 follow-up tips matching the detected scenario.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| selector | Yes | CSS selector of scrollable container (use 'html' or 'body' for page scroll, e.g., 'testid:chat-container', '.scrollable-list', 'html') | |
| pixels | Yes | Number of pixels to scroll. Positive = down/right, negative = up/left. Example: 500, -200 | |
| direction | No | Scroll direction: 'vertical' (default), 'horizontal', or 'auto' (detects available direction). Use 'auto' for smart detection. |
Implementation Reference
- The execute() method of ScrollByTool class (extends BrowserToolBase). Handles page scrolling and element scrolling via Playwright's page.evaluate, with auto-direction detection, boundary checking, and rich response messages.
async execute(args: any, context: ToolContext): Promise<ToolResponse> { this.recordInteraction(); return this.safeExecute(context, async (page) => { const selector = this.normalizeSelector(args.selector); const pixels = args.pixels; const direction = args.direction || 'vertical'; // Default to vertical for backward compatibility // Check if scrolling the page (html/body) or a specific element const isPageScroll = selector === 'html' || selector === 'body'; if (isPageScroll) { // Scroll the page const scrollResult = await page.evaluate(({ scrollAmount, scrollDirection }) => { const maxVertical = document.documentElement.scrollHeight - window.innerHeight; const maxHorizontal = document.documentElement.scrollWidth - window.innerWidth; // Auto-detect direction if needed let actualDirection = scrollDirection; if (scrollDirection === 'auto') { const verticalScrollable = maxVertical > 0; const horizontalScrollable = maxHorizontal > 0; if (verticalScrollable && !horizontalScrollable) { actualDirection = 'vertical'; } else if (horizontalScrollable && !verticalScrollable) { actualDirection = 'horizontal'; } else if (verticalScrollable && horizontalScrollable) { // Both directions scrollable - need explicit direction return { error: 'ambiguous', maxVertical, maxHorizontal }; } else { // Neither direction scrollable return { error: 'not-scrollable', maxVertical: 0, maxHorizontal: 0 }; } } // Perform the scroll const isVertical = actualDirection === 'vertical'; const previousScroll = isVertical ? window.scrollY : window.scrollX; if (isVertical) { window.scrollBy(0, scrollAmount); } else { window.scrollBy(scrollAmount, 0); } const newScroll = isVertical ? window.scrollY : window.scrollX; const actualScrolled = newScroll - previousScroll; return { previous: previousScroll, new: newScroll, actualScrolled, maxScroll: isVertical ? maxVertical : maxHorizontal, direction: actualDirection, maxVertical, maxHorizontal }; }, { scrollAmount: pixels, scrollDirection: direction }); // Handle error cases if ('error' in scrollResult) { if (scrollResult.error === 'ambiguous') { return createSuccessResponse([ `⚠️ Page is scrollable in both directions`, `Vertical: ${scrollResult.maxVertical}px max scroll`, `Horizontal: ${scrollResult.maxHorizontal}px max scroll`, ``, `💡 Specify direction explicitly:`, ` scroll_by({ selector: "html", pixels: ${pixels}, direction: "vertical" })`, ` scroll_by({ selector: "html", pixels: ${pixels}, direction: "horizontal" })` ]); } else if (scrollResult.error === 'not-scrollable') { return createSuccessResponse([ `⚠️ Page is not scrollable in any direction`, `Content fits within viewport (no overflow)`, `Position: unchanged` ]); } } const isVertical = scrollResult.direction === 'vertical'; const directionWord = isVertical ? (pixels > 0 ? 'down' : 'up') : (pixels > 0 ? 'right' : 'left'); const axis = isVertical ? 'y' : 'x'; const messages = [ `✓ Scrolled page ${directionWord} ${Math.abs(pixels)}px (${scrollResult.direction})`, `Position: ${axis}=${scrollResult.new}px (was ${scrollResult.previous}px)` ]; // Add info if we hit the scroll boundary const hitBoundary = Math.abs(scrollResult.actualScrolled) < Math.abs(pixels); if (hitBoundary) { const boundary = pixels > 0 ? (isVertical ? 'bottom' : 'right') : (isVertical ? 'top' : 'left'); messages.push(`⚠️ Reached ${boundary} of page (max scroll: ${scrollResult.maxScroll}px)`); } // Contextual suggestions if (isVertical && pixels > 0 && !hitBoundary) { // Scrolling down on page - suggest sticky header testing messages.push(''); messages.push('💡 Common next step - Test sticky header/footer:'); messages.push(' measure_element({ selector: "header" }) - Check if position stays fixed'); } else if (hitBoundary && pixels > 0) { // Hit boundary - suggest checking for infinite scroll or lazy-loaded content messages.push(''); messages.push('💡 At page boundary - Check for dynamic content:'); messages.push(' element_visibility({ selector: "..." }) - Verify lazy-loaded elements appeared'); messages.push(' inspect_dom() - See if new content was added'); } return createSuccessResponse(messages); } else { // Scroll a specific element const locator = await this.createScopedLocator(page, args.selector); // Check if element exists const count = await locator.count(); if (count === 0) { return createSuccessResponse([ `✗ Element not found: ${args.selector}`, ``, `💡 Try:`, ` • Use 'html' or 'body' to scroll the page`, ` • Use inspect_dom() to find scrollable containers`, ` • Use get_test_ids() to see available test IDs` ]); } // Use standard element selection with error on multiple matches const { element } = await this.selectPreferredLocator(locator, { errorOnMultiple: true, originalSelector: args.selector, }); // Scroll the element and collect scrollable ancestor info const scrollResult = await element.evaluate( (el, { scrollAmount, scrollDirection }) => { const maxVertical = el.scrollHeight - el.clientHeight; const maxHorizontal = el.scrollWidth - el.clientWidth; const getClassName = (element: Element) => { const value = (element as any).className as unknown; if (typeof value === 'string') { return value; } if ( value && typeof (value as { baseVal?: unknown }).baseVal === 'string' ) { return (value as { baseVal: string }).baseVal; } return ''; }; // Auto-detect direction if needed let actualDirection = scrollDirection; if (scrollDirection === 'auto') { const verticalScrollable = maxVertical > 0; const horizontalScrollable = maxHorizontal > 0; if (verticalScrollable && !horizontalScrollable) { actualDirection = 'vertical'; } else if (horizontalScrollable && !verticalScrollable) { actualDirection = 'horizontal'; } else if (verticalScrollable && horizontalScrollable) { // Both directions scrollable - need explicit direction return { error: 'ambiguous', maxVertical, maxHorizontal, tagName: el.tagName ? el.tagName.toLowerCase() : 'element', testId: el.getAttribute('data-testid'), id: (el as any).id ?? null, className: getClassName(el) }; } else { // Neither direction scrollable - collect ancestors const scrollableAncestors: Array<{ tagName: string; testId: string | null; id: string | null; className: string; maxScrollVertical: number; maxScrollHorizontal: number; }> = []; let parent = el.parentElement; while (parent && scrollableAncestors.length < 3) { const maxParentVertical = parent.scrollHeight - parent.clientHeight; const maxParentHorizontal = parent.scrollWidth - parent.clientWidth; if (maxParentVertical > 0 || maxParentHorizontal > 0) { scrollableAncestors.push({ tagName: parent.tagName ? parent.tagName.toLowerCase() : 'element', testId: parent.getAttribute('data-testid'), id: (parent as any).id ?? null, className: getClassName(parent), maxScrollVertical: maxParentVertical, maxScrollHorizontal: maxParentHorizontal }); } parent = parent.parentElement; } return { error: 'not-scrollable', maxVertical: 0, maxHorizontal: 0, tagName: el.tagName ? el.tagName.toLowerCase() : 'element', testId: el.getAttribute('data-testid'), id: (el as any).id ?? null, className: getClassName(el), scrollableAncestors }; } } // Perform the scroll const isVertical = actualDirection === 'vertical'; const previousScroll = isVertical ? el.scrollTop : el.scrollLeft; if (isVertical) { el.scrollTop += scrollAmount; } else { el.scrollLeft += scrollAmount; } const newScroll = isVertical ? el.scrollTop : el.scrollLeft; const actualScrolled = newScroll - previousScroll; // Find scrollable ancestors (up to 3) const scrollableAncestors: Array<{ tagName: string; testId: string | null; id: string | null; className: string; maxScrollVertical: number; maxScrollHorizontal: number; }> = []; let parent = el.parentElement; while (parent && scrollableAncestors.length < 3) { const maxParentVertical = parent.scrollHeight - parent.clientHeight; const maxParentHorizontal = parent.scrollWidth - parent.clientWidth; if (maxParentVertical > 0 || maxParentHorizontal > 0) { scrollableAncestors.push({ tagName: parent.tagName ? parent.tagName.toLowerCase() : 'element', testId: parent.getAttribute('data-testid'), id: (parent as any).id ?? null, className: getClassName(parent), maxScrollVertical: maxParentVertical, maxScrollHorizontal: maxParentHorizontal }); } parent = parent.parentElement; } return { previous: previousScroll, new: newScroll, actualScrolled, maxScroll: isVertical ? maxVertical : maxHorizontal, direction: actualDirection, maxVertical, maxHorizontal, tagName: el.tagName ? el.tagName.toLowerCase() : 'element', testId: el.getAttribute('data-testid'), id: (el as any).id ?? null, className: getClassName(el), scrollableAncestors }; }, { scrollAmount: pixels, scrollDirection: direction } ); // Build element description let elementDesc = `<${scrollResult.tagName}`; if (scrollResult.testId) elementDesc += ` data-testid="${scrollResult.testId}"`; else if (scrollResult.id) elementDesc += ` id="${scrollResult.id}"`; else if (scrollResult.className) { const classes = scrollResult.className.split(' ').slice(0, 2).join(' '); if (classes) elementDesc += ` class="${classes}"`; } elementDesc += '>'; // Handle error cases if ('error' in scrollResult) { if (scrollResult.error === 'ambiguous') { return createSuccessResponse([ `⚠️ ${elementDesc} is scrollable in both directions`, `Vertical: ${scrollResult.maxVertical}px max scroll`, `Horizontal: ${scrollResult.maxHorizontal}px max scroll`, ``, `💡 Specify direction explicitly:`, ` scroll_by({ selector: "${args.selector}", pixels: ${pixels}, direction: "vertical" })`, ` scroll_by({ selector: "${args.selector}", pixels: ${pixels}, direction: "horizontal" })` ]); } else if (scrollResult.error === 'not-scrollable') { const messages = [ `⚠️ Container is not scrollable in any direction`, `${elementDesc} has max scroll: 0px vertical, 0px horizontal`, `Position: unchanged` ]; // Suggest scrollable ancestors if found if (scrollResult.scrollableAncestors.length > 0) { messages.push(''); messages.push('💡 Try these scrollable ancestors:'); scrollResult.scrollableAncestors.forEach((ancestor, i) => { // Build selector suggestion (prefer testid > id > class) let suggestion = ''; if (ancestor.testId) { suggestion = `testid:${ancestor.testId}`; } else if (ancestor.id) { suggestion = `#${ancestor.id}`; } else if (ancestor.className) { const firstClass = ancestor.className.split(' ')[0]; suggestion = `.${firstClass}`; } else { suggestion = ancestor.tagName; } const directions = []; if (ancestor.maxScrollVertical > 0) directions.push(`↕️ ${ancestor.maxScrollVertical}px vertical`); if (ancestor.maxScrollHorizontal > 0) directions.push(`↔️ ${ancestor.maxScrollHorizontal}px horizontal`); messages.push(` ${i + 1}. ${suggestion} (${directions.join(', ')})`); }); } else { messages.push(''); messages.push('💡 Suggestions:'); messages.push(` • Use 'html' or 'body' to scroll the page`); messages.push(` • Use inspect_dom() to find scrollable containers`); } return createSuccessResponse(messages); } } // Calculate percentage of max scroll const percentage = scrollResult.maxScroll > 0 ? Math.round((scrollResult.new / scrollResult.maxScroll) * 100) : 0; const isVertical = scrollResult.direction === 'vertical'; const directionWord = isVertical ? (pixels > 0 ? 'down' : 'up') : (pixels > 0 ? 'right' : 'left'); const axis = isVertical ? 'y' : 'x'; const messages = [ `✓ Scrolled ${elementDesc} ${directionWord} ${Math.abs(pixels)}px (${scrollResult.direction})`, `Position: ${axis}=${scrollResult.new}px (was ${scrollResult.previous}px) [${percentage}% of max: ${scrollResult.maxScroll}px]` ]; // Add info if we hit the scroll boundary const hitBoundary = Math.abs(scrollResult.actualScrolled) < Math.abs(pixels); if (hitBoundary) { const boundary = pixels > 0 ? (isVertical ? 'bottom' : 'right') : (isVertical ? 'top' : 'left'); messages.push(`⚠️ Reached ${boundary} of container`); // Suggest checking for lazy-loaded content at container boundary if (pixels > 0) { messages.push(''); messages.push('💡 At container boundary - Check for lazy-loaded content:'); messages.push(` inspect_dom({ selector: "${args.selector}" }) - See if new children appeared`); } } return createSuccessResponse(messages); } }); } - getMetadata() defining the tool schema: name 'scroll_by', description, inputSchema with required 'selector' (string), 'pixels' (number), and optional 'direction' enum (vertical/horizontal/auto). Uses ANNOTATIONS.interaction.
static getMetadata(sessionConfig?: SessionConfig): ToolMetadata { return { name: "scroll_by", description: "Scroll a container (or page) by a specific number of pixels. Auto-detects scroll direction when only one is available. Essential for: testing sticky headers/footers, triggering infinite scroll, carousel navigation, precise scroll position testing. Use 'html' or 'body' for page scrolling. Positive pixels = down/right, negative = up/left. Outputs: ✓ success summary with axis position and percent of max scroll; ⚠️ boundary notice when movement is limited; ⚠️ ambiguous-direction guidance when both axes scroll; ⚠️ not-scrollable report with ancestor suggestions; 💡 follow-up tips matching the detected scenario.", annotations: ANNOTATIONS.interaction, inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector of scrollable container (use 'html' or 'body' for page scroll, e.g., 'testid:chat-container', '.scrollable-list', 'html')" }, pixels: { type: "number", description: "Number of pixels to scroll. Positive = down/right, negative = up/left. Example: 500, -200" }, direction: { type: "string", description: "Scroll direction: 'vertical' (default), 'horizontal', or 'auto' (detects available direction). Use 'auto' for smart detection.", enum: ["vertical", "horizontal", "auto"] } }, required: ["selector", "pixels"], }, }; } - src/tools/browser/register.ts:53-104 (registration)ScrollByTool is registered in the BROWSER_TOOL_CLASSES array at line 58, which gets registered via registry.ts line 65.
export const BROWSER_TOOL_CLASSES: ToolClass[] = [ // Navigation (5) NavigateTool, GoHistoryTool, ScrollToElementTool, ScrollByTool, // Lifecycle (2) CloseTool, SetColorSchemeTool, // Interaction (7) ClickTool, FillTool, SelectTool, HoverTool, UploadFileTool, DragTool, PressKeyTool, // Content (3) ScreenshotTool, GetTextTool, GetHtmlTool, // Inspection (10) InspectDomTool, GetTestIdsTool, QuerySelectorTool, FindByTextTool, CheckVisibilityTool, CompareElementAlignmentTool, InspectAncestorsTool, ElementExistsTool, MeasureElementTool, GetComputedStylesTool, // Evaluation (1) EvaluateTool, // Console (2) GetConsoleLogsTool, ClearConsoleLogsTool, // Network (2) ListNetworkRequestsTool, GetRequestDetailsTool, // Waiting (2) WaitForElementTool, WaitForNetworkIdleTool, ]; - Re-exports ScrollByTool from scroll_by.ts for clean module imports.
export { ScrollByTool } from './scroll_by.js'; - Suggestion helper in evaluate tool that recommends scroll_by when user scripts contain scroll-related JS patterns.
if (scriptLower.match(/scrollto|scrollby|scrollintoview|scrolltop|scrollleft|window\.scroll|pageyoffset|scrolly/)) { suggestions.push({ key: 'scroll', line: '📜 scroll_to_element / scroll_by — vs scrollTo/scrollIntoView/pageYOffset' }); }