Advanced user actions with ActionChains
Hover, drag, hold, and send keyboard chords, and understand why every chain must end in perform().
Chains and perform
ActionChains builds a sequence of low-level input actions and executes them together. Nothing happens until perform() is called - a chain that is never performed is a silent no-op, which is a common reason for tests that pass without testing anything.
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
menu = driver.find_element(By.ID, "account-menu")
ActionChains(driver).move_to_element(menu).perform()
# hover then click a submenu that only exists while hovered
driver.find_element(By.LINK_TEXT, "Sign out").click()
# keyboard chord: select all, then copy
body = driver.find_element(By.TAG_NAME, "body")
(
ActionChains(driver)
.move_to_element(body)
.click()
.key_down(Keys.CONTROL)
.send_keys("a")
.key_up(Keys.CONTROL)
.perform()
)perform()is required and returns None; chaining without it is the most frequent mistake with this API.pause(seconds)inserts a delay inside the chain - useful for a CSS transition that must finish before the next action lands.- Every
key_downneeds a matchingkey_up, or the modifier stays held for later commands in the session. - Use
Keys.CONTROLon Linux and Windows butKeys.COMMANDon macOS; detect the platform rather than hardcoding one.
💡
ActionChains sends real input events, so it exercises the same code path as a mouse and keyboard. That is exactly why it is worth using for hover menus, drag and drop and keyboard shortcuts - and why it is unnecessary for a plain click, which
element.click() handles better.Drag, drop and hold
source = driver.find_element(By.ID, "card-1")
target = driver.find_element(By.ID, "column-done")
# the straightforward form
ActionChains(driver).drag_and_drop(source, target).perform()
# HTML5 drag-and-drop often needs a slower, manual sequence
(
ActionChains(driver)
.move_to_element(source)
.click_and_hold()
.pause(0.5)
.move_to_element(target)
.pause(0.5)
.release()
.perform()
)
# move the mouse a fixed offset from its current position
ActionChains(driver).move_by_offset(80, 0).perform()| Method | What it does | Note |
|---|---|---|
drag_and_drop(a, b) | Moves a to the centre of b | Fails with HTML5 drag events |
click_and_hold() | Presses and holds | Pair with release() |
move_by_offset(x, y) | Relative move | Offsets accumulate across calls |
move_to_element(el) | Absolute move to centre | Safest targeting method |
pause(s) | Waits inside the chain | For transitions and throttled handlers |
If drag and drop does nothing, the page is probably listening for HTML5 dragstart and drop events, which a synthetic mouse sequence does not raise. Dispatch them with execute_script in that case, or use the application's own move API.
Scrolling within a chain
from selenium.webdriver.common.action_chains import ActionChains, ScrollOrigin
footer = driver.find_element(By.CSS_SELECTOR, "footer")
ActionChains(driver).scroll_to_element(footer).perform()
# scroll a specific container by an amount
scroller = driver.find_element(By.CSS_SELECTOR, ".results")
ActionChains(driver).scroll_from_origin(
ScrollOrigin.from_element(scroller), 0, 400
).perform()
# scroll to the document origin
ActionChains(driver).scroll_by_amount(0, -5000).perform()- Scrolling then clicking in a single chain is more reliable than scrolling, locating again and clicking, because nothing can move in between.
- Always prefer
scroll_to_elementover a fixed pixel offset - page heights change and hardcoded offsets silently stop working. - An element inside a scrollable container may be found but not visible; scrolling the container is a different operation from scrolling the page.
- Neither scroll method changes
document.readyState, so it will not satisfy a wait that is looking for the page to finish loading.
# the action many people want: scroll, then interact, atomically
(
ActionChains(driver)
.scroll_to_element(checkout_button)
.move_to_element(checkout_button)
.click()
.perform()
)FAQ
Why does my hover menu test work locally but not in headless mode?
Usually a transition that has not finished, or a menu that only appears after a short delay. Add a
pause() inside the chain, or wait for the menu element to become visible before clicking it - do not increase a global timeout to cover a 200ms animation.Do I still need ActionChains if element.click() works?
No, for plain clicks. Use ActionChains when the interaction depends on pointer position or key state: hover, drag, hold, chords, and interactions where you must move the mouse before pressing.
Related
Alerts, frames and windows JavaScript execution and CDP
Last refreshed 2026-09-18.