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_down needs a matching key_up, or the modifier stays held for later commands in the session.
  • Use Keys.CONTROL on Linux and Windows but Keys.COMMAND on 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()
MethodWhat it doesNote
drag_and_drop(a, b)Moves a to the centre of bFails with HTML5 drag events
click_and_hold()Presses and holdsPair with release()
move_by_offset(x, y)Relative moveOffsets accumulate across calls
move_to_element(el)Absolute move to centreSafest targeting method
pause(s)Waits inside the chainFor 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_element over 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.

Alerts, frames and windows JavaScript execution and CDP

Last refreshed 2026-09-18.