Selenium gives you five ways to scroll a page, and they aren’t interchangeable. I ran all five against a real infinite-scroll page (quotes.toscrape.com/scroll, 10 scroll steps each) and the results split into two groups:
| Method | Items loaded | Triggers / 10 steps | Time |
|---|---|---|---|
window.scrollTo(scrollHeight) | +90 | 9/10 | 26 s |
scrollIntoView on last element | +90 | 9/10 | 28 s |
Keys.END | +90 | 9/10 | 27 s |
window.scrollBy(0, 800) | +40 | 4/10 | 43 s |
ActionChains scroll_by_amount(800) | +40 | 4/10 | 44 s |
The fixed-pixel methods (scrollBy and ActionChains) loaded half as many items. An 800 px step doesn’t reliably reach the loading threshold, because the page fires a new batch only once the viewport edge sits close enough to the document bottom.
Overflow containers and popups with lazy loading need a scrollTop loop, which is covered in Scrolling an Element with Overflow.
Every example below uses this template. Drop your chosen method in place of the # scrolling code goes here comment.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
url = "https://demo.opencart.com/"
chrome_options = Options()
chrome_options.add_argument("--headless=new")
driver = webdriver.Chrome(options=chrome_options)
driver.get(url)
# scrolling code goes here
driver.quit()The template runs headless and quits cleanly, so you can test each method below on its own.
Using JavaScript Executor
JavaScript execution bypasses focus and visibility requirements that affect Action Chains and keyboard events.
Scroll to a specific element
scrollIntoView takes any element Selenium can find, wherever it sits on the page.
element = driver.find_element(By.ID, "carousel-banner-1")
driver.execute_script("arguments[0].scrollIntoView(true);", element)scrollIntoView works on any element regardless of its position. To center the element in the viewport instead of aligning it to the top, pass a block option:
driver.execute_script(
"arguments[0].scrollIntoView({ behavior: 'smooth', block: 'center' });",
element
)The block: 'center' option keeps tall elements from scrolling past the relevant part.
Scroll to a specific position
Bottom, top, and a fixed offset each use the same scrollTo call with different y values.
# scroll to the bottom
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
# scroll to the top
driver.execute_script("window.scrollTo(0, 0);")
# scroll to an exact pixel offset
driver.execute_script("window.scrollTo(0, 1500);")scrollTo takes absolute coordinates, so it doesn’t care where the viewport happens to be.
Scroll by coordinates
Positive values scroll right and down, negative values scroll left and up.
# scroll down 500 px from current position
driver.execute_script("window.scrollBy(0, 500);")
# scroll up 500 px
driver.execute_script("window.scrollBy(0, -500);")
# scroll right
driver.execute_script("window.scrollBy(500, 0);")scrollBy offsets from wherever you are now. That’s fine for fixed steps, but as the benchmark shows, a fixed pixel value misses the loading threshold about half the time on infinite-scroll pages. If you’re after lazy loading, reach for scrollTo(scrollHeight) instead.
Using Action Class
Reach for Action Chains when you’re combining a scroll with clicks, hover states or a drag. The gesture emulation lets you chain them into one .perform() call.
actions = ActionChains(driver)
# scroll to a specific element
element = driver.find_element(By.ID, "carousel-banner-1")
actions.move_to_element(element).perform()
# chain scroll with a click
actions.move_to_element(element).click().perform()
# scroll by a fixed amount (Selenium 4.2+)
actions.scroll_by_amount(0, 800).perform()scroll_by_amount and scrollBy share the same fixed-step limitation. A fixed pixel value misses the loading threshold roughly half the time, giving 4 triggers out of 10 in the benchmark instead of 9. move_to_element on the last visible item adjusts to actual content height and clears the threshold every time.
Keyboard keys inside Action Chains
Add PAGE_DOWN and PAGE_UP when you need keyboard scroll as part of a larger action chain.
actions.send_keys(Keys.PAGE_DOWN).perform()
actions.send_keys(Keys.PAGE_UP).perform()Keyboard keys inside Action Chains earn their place when you’re mixing keyboard input into a single chained sequence.
Scrolling with Keyboard Events
Keyboard scrolling sends keys directly to the body element:
body = driver.find_element(By.TAG_NAME, "body")
body.send_keys(Keys.END) # jump to bottom
body.send_keys(Keys.HOME) # jump to top
body.send_keys(Keys.PAGE_DOWN) # scroll one viewport down
body.send_keys(Keys.PAGE_UP) # scroll one viewport upIn the benchmark, Keys.END matched scrollTo(scrollHeight) exactly, with 9/10 triggers and 90 items loaded. It requires no JavaScript, and on infinite-scroll pages where you always want to land at the bottom, it needs no setup beyond finding the body element.
Focus is the catch here. If the page has a focused input or a modal, body.send_keys types into that element instead of scrolling, and execute_script("window.scrollTo(...)") doesn’t have that problem.
Scrolling an Element with Overflow
When a container has overflow: auto or overflow: scroll (popup follower lists, sidebar feeds, chat windows), window.scrollTo and keyboard events only move the main page. You need to target the element directly.
Jump to the bottom of an overflow container
Setting scrollTop to scrollHeight jumps immediately to the current bottom of the container.
scrollable = driver.find_element(By.ID, "popup-list")
driver.execute_script("arguments[0].scrollTop = arguments[0].scrollHeight;", scrollable)That loads exactly one batch. In a popup with lazy loading, the content below doesn’t exist yet when you jump to the current scrollHeight, so nothing new arrives. Measured on a simulated popup (a 400 px overflow list appending 10-row batches as the scroll nears its bottom, 100 rows total), this exact snippet ended at 20 rows: the jump fired one batch and then had nowhere further to go.
Loop with WebDriverWait for lazy-loaded popups
For overflow elements that load content as you scroll:
from selenium.webdriver.support.ui import WebDriverWait
scrollable = driver.find_element(By.ID, "popup-list")
item_selector = (By.CSS_SELECTOR, "#popup-list .item")
for _ in range(20):
before = len(driver.find_elements(*item_selector))
driver.execute_script(
"arguments[0].scrollTop += arguments[0].offsetHeight;",
scrollable
)
try:
WebDriverWait(driver, 3).until(
lambda d: len(d.find_elements(*item_selector)) > before
)
except Exception:
break # no new content loaded, stop scrollingUnlike a single scrollHeight jump, offsetHeight per step scrolls exactly one visible viewport of the container. New items render at the bottom, scrollHeight grows, and the next iteration scrolls into the newly loaded area. WebDriverWait advances as soon as new items appear instead of waiting a fixed interval. On the same simulated popup the loop collected all 100 rows either way, but the version with a fixed 1-second pause took 20.5 seconds and the WebDriverWait version took 9.6, because the wait releases each step the moment the batch lands.
Tips and Tricks
Modals, infinite-scroll feeds and iframes each get in the way of the methods above, and each does it differently.
Infinite scrolling on the main page
For an infinite-scroll page (social feeds, product catalogues, search results):
from selenium.webdriver.support.ui import WebDriverWait
import time
item_selector = (By.CSS_SELECTOR, "div.product")
for _ in range(10):
before = len(driver.find_elements(*item_selector))
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
try:
WebDriverWait(driver, 4).until(
lambda d: len(d.find_elements(*item_selector)) > before
)
except Exception:
break # reached the endDon’t use time.sleep(2) as your wait. A fixed pause adds 20 seconds to a 10-step loop even when the content lands in 0.3 seconds, while WebDriverWait exits the moment the condition is met.
Pop-ups and overlays
Before scrolling, dismiss any modal that intercepts pointer events:
try:
close_button = driver.find_element(By.ID, "modal-close")
close_button.click()
except Exception:
pass # no modal present
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")The try/except keeps the script alive if the modal was already closed, or never showed up at all.
Frames and nested elements
To scroll content inside an iframe, switch context first:
iframe = driver.find_element(By.ID, "iframe_id")
driver.switch_to.frame(iframe)
nested = driver.find_element(By.ID, "nested_element_id")
driver.execute_script("arguments[0].scrollIntoView(true);", nested)
driver.switch_to.default_content() # return to main documentEverything inside the frame behaves as a separate document, and after the context switch, scrollIntoView, a scrollTop loop and scrollTo all work exactly as they do outside it.
When to use which method
Both benchmarks condense into one picker matrix.
| Method | Triggers lazy loading | Works inside overflow | Works inside iframe | Notes |
|---|---|---|---|---|
scrollTo(scrollHeight) | Yes | No | Yes (after switch) | Best for main-page infinite scroll |
scrollIntoView | Yes | No | Yes | Targets a specific element |
Keys.END | Yes | No | Rarely | Requires body focus |
scrollBy(fixed px) | Sometimes | No | Yes | Misses threshold ~half the time |
ActionChains scroll_by_amount | Sometimes | No | Yes | Same as scrollBy |
scrollTop loop | Yes | Yes | Yes | Only option for overflow popups |
For main-page infinite scroll, scrollTo(scrollHeight) is your default. For an overflow container with lazy loading, the scrollTop += offsetHeight loop is the only thing that reliably works. The benchmark on quotes.toscrape.com/scroll confirmed this (10 steps, 10 quotes per batch): both fixed-pixel methods stalled at 40 items while the jump-to-bottom methods reached 100. The popup simulation gave the mirror image, where the single jump stopped at 20 rows of 100 and the offsetHeight loop collected them all.


