
Handle dynamic elements in Selenium with proven strategies for dynamic IDs, AJAX content, changing attributes, and AI alternatives that eliminate the problem.
Modern web applications generate element attributes dynamically. IDs change with each page load. Classes include randomly generated hashes. Content loads asynchronously. Lists grow and shrink based on user actions. These dynamic behaviors break traditional Selenium locators that expect static, predictable elements. This guide covers every type of dynamic element challenge and provides working solutions for each, from immediate workarounds to architectural approaches that handle dynamics automatically.
Dynamic elements have attributes or behaviors that change without explicit user action or between sessions:
The same form field generates a completely different ID on every page load, making any locator that targets it by ID unreliable from the start.
<!-- First page load -->
<input id="email_field_a7f3e2" type="email">
<!-- Second page load -->
<input id="email_field_k9x2m1" type="email">CSS-in-JS libraries like Styled Components and CSS Modules generate hashed class names at build time, meaning the class you recorded in your test will not exist in the next build.
<!-- Generated by CSS-in-JS or CSS Modules -->
<button class="Button_primary__3xK2p">Submit</button>Elements that depend on an API response are present in the DOM but empty until the data arrives, causing tests that interact with them too early to fail or return incorrect results.
<!-- Content loaded via AJAX -->
<div id="results">
<!-- Empty until API responds -->
</div>
Lists driven by real-time data have no fixed length or content, so any test that references a specific item by position or count will break as soon as the underlying data changes.
<!-- Items change based on filters, searches, or real-time updates -->
<ul id="notifications">
<!-- 0 to N items, constantly changing -->
</ul>
Dynamic attributes are not bugs. They serve specific engineering purposes and understanding them helps you choose the right handling strategy rather than fighting the framework.
Static locators work fine when attribute values never change. The moment an application starts generating attributes dynamically, every locator strategy that depends on those values becomes a maintenance liability.
Here is what that looks like in practice. Both of these locators will fail the next time the page loads with freshly generated values.
# Fails when ID changes
driver.find_element(By.ID, "email_field_a7f3e2")
# Fails when class hash changes
driver.find_element(By.CLASS_NAME, "Button_primary__3xK2p")
Every locator strategy that depends on dynamic values eventually breaks. The question is not whether it will fail but when.
None of these strategies eliminates the underlying problem of dynamic attributes. What they do is make your locators resilient enough to survive the specific type of change you are dealing with. The right choice depends on what part of the attribute stays stable.
When an ID or class always contains a predictable substring even if the rest changes, you can target the stable portion instead of the full value. XPath and CSS both support partial string matching with slightly different syntax.
When an ID or class contains a stable prefix followed by a generated hash, targeting the predictable portion of the string keeps your locator working even as the dynamic suffix changes.
# Matches any ID containing "email_field"
driver.find_element(By.XPATH, "//*[contains(@id, 'email_field')]")
# Matches any class containing "Button_primary"
driver.find_element(By.XPATH, "//*[contains(@class, 'Button_primary')]")If the dynamic portion always appears at the end of an attribute value, anchoring your selector to the beginning of the string is a more precise match than a general contains check.
# Matches IDs starting with "email_field_"
driver.find_element(By.XPATH, "//*[starts-with(@id, 'email_field_')]")
CSS attribute selectors offer the same partial matching capability as XPath but with a more concise syntax. Choose the operator based on where the stable portion of the attribute value sits: anywhere in the string, at the start, or at the end.
# Attribute contains value
driver.find_element(By.CSS_SELECTOR, "[id*='email_field']")
# Attribute starts with value
driver.find_element(By.CSS_SELECTOR, "[id^='email_field']")
# Attribute ends with value
driver.find_element(By.CSS_SELECTOR, "[id$='_field']")
When IDs and classes are fully dynamic, shift your locator to attributes that developers do not regenerate. These attributes are more likely to remain stable across page loads and application updates because they serve functional or accessibility purposes rather than styling.
Form fields almost always carry a name attribute that maps to the field's purpose and rarely changes between releases.
driver.find_element(By.NAME, "email")Data attributes are specifically intended for non-presentational metadata. The data-testid convention exists precisely to give automation a stable hook that is independent of styling and functionality.
# data-testid is commonly added for testing
driver.find_element(By.CSS_SELECTOR, "[data-testid='email-input']")
# Other data attributes
driver.find_element(By.CSS_SELECTOR, "[data-field='email']")Accessibility attributes describe what an element does rather than how it looks, making them among the most stable locators available. They change only when the element's purpose changes.
driver.find_element(By.CSS_SELECTOR, "[aria-label='Email address']")
driver.find_element(By.CSS_SELECTOR, "[role='textbox'][aria-describedby='email-help']")Input type and placeholder attributes are set by developers to communicate purpose to users and browsers. They tend to remain stable because changing them changes the user experience.
driver.find_element(By.CSS_SELECTOR, "input[type='email']")
driver.find_element(By.CSS_SELECTOR, "input[placeholder='Enter email']")
Visible text is often the most stable thing on a page. Button labels, link text, and headings change when the product changes its terminology, which happens far less frequently than implementation details like class names and IDs.
Selenium's link text strategies match anchor elements by their visible label, which maps directly to what a user would read and click.
driver.find_element(By.LINK_TEXT, "Sign In")
driver.find_element(By.PARTIAL_LINK_TEXT, "Sign")XPath text functions give you flexible control over how strictly you match visible text, from exact matches to contains checks with whitespace normalisation for text that wraps or has inconsistent spacing.
# Exact text match
driver.find_element(By.XPATH, "//button[text()='Submit']")
# Contains text
driver.find_element(By.XPATH, "//button[contains(text(), 'Submit')]")
# Normalize whitespace
driver.find_element(By.XPATH, "//button[normalize-space()='Submit Order']")
When an element has no stable attributes of its own, locate it in relation to a nearby element that does. This strategy works particularly well for form fields associated with labels, buttons inside specific containers, and elements within well-defined page regions.
XPath axis selectors let you navigate the DOM tree directionally from a stable anchor point, moving to siblings, descendants, or parents of known elements.
# Find input following a specific label
driver.find_element(By.XPATH, "//label[text()='Email']//following-sibling::input")
# Find button inside a specific form
driver.find_element(By.XPATH, "//form[@id='login']//button[@type='submit']")
# Find element by parent context
driver.find_element(By.XPATH, "//div[@class='checkout-section']//input[@type='text']")CSS combinators express structural relationships between elements more concisely than XPath axes. Use them when the relationship between your anchor and target is straightforward.
# Direct child
driver.find_element(By.CSS_SELECTOR, ".form-group > input")
# Descendant
driver.find_element(By.CSS_SELECTOR, "#login-form input[type='email']")
# Adjacent sibling
driver.find_element(By.CSS_SELECTOR, "label + input")
When a single attribute match is too broad and returns multiple elements, combining multiple conditions narrows the result to a unique match. This is the most precise manual locator strategy and the most robust against unintended matches.
Combining conditions reduces the chance that a partial match accidentally targets the wrong element when similar elements exist elsewhere on the page.
# Element with class containing 'btn' AND 'primary'
driver.find_element(By.XPATH, "//*[contains(@class, 'btn') and contains(@class, 'primary')]")
# Input with type email inside element with role form
driver.find_element(By.CSS_SELECTOR, "[role='form'] input[type='email']")
# Button containing 'Submit' text with type submit
driver.find_element(By.XPATH, "//button[@type='submit' and contains(text(), 'Submit')]")

AJAX content introduces a timing dimension that locator strategies alone cannot solve. The element may be perfectly identifiable once it exists, but if your test looks for it before the API responds, it simply will not be there.
The page load event fires when the initial HTML is parsed. AJAX requests fire after that. Any element that depends on an API response is absent from the DOM during the window between page load and API response completion.
Here is what that failure looks like. The page loads, the test immediately looks for the element, and the element does not exist yet because the API has not responded.
driver.get("https://example.com/dashboard")
# Content loads via AJAX after page load
driver.find_element(By.ID, "user-data") # NoSuchElementExceptionRather than assuming the element exists, tell Selenium to wait until it appears. Explicit waits poll the DOM repeatedly until the condition is met or the timeout expires, giving AJAX time to complete without a fixed delay.
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
# Wait up to 10 seconds for element to be present
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "user-data"))
)
An element being present in the DOM does not mean a user can see or interact with it. Some elements load hidden and become visible only after additional processing. Visibility waits check both conditions simultaneously.
# Wait for element to be visible (present AND displayed)
element = WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "user-data"))
)
Interactive elements like buttons and links may be present and visible but temporarily disabled while a form validates or a request processes. A clickability wait ensures the element is both visible and enabled before your test attempts to interact with it.
# Wait for element to be clickable (visible AND enabled)
button = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "submit-button"))
)
button.click()
Some elements appear in the DOM immediately as empty containers and fill with content when data arrives. Waiting for the element itself is not enough in these cases. You need to wait for the content inside it.
# Wait for element to contain expected text
WebDriverWait(driver, 10).until(
EC.text_to_be_present_in_element((By.ID, "status"), "Complete")
)
# Wait for any text to appear
def element_has_text(locator):
def check(driver):
element = driver.find_element(*locator)
return element.text.strip() != ""
return check
WebDriverWait(driver, 10).until(element_has_text((By.ID, "results")))Applications that show a spinner or loading overlay during AJAX calls give you an indirect signal you can wait on. When the spinner disappears, the data it was waiting for has arrived and the content is ready to interact with.
# Wait for spinner to disappear
WebDriverWait(driver, 10).until(
EC.invisibility_of_element_located((By.CLASS_NAME, "loading-spinner"))
)
# Then interact with loaded content
driver.find_element(By.ID, "results").click()Standard expected conditions cover common scenarios but not application-specific ones. When your application uses a known framework like jQuery or Angular, or has a detectable network activity pattern, you can write a wait condition that checks exactly the signal that matters for your application.
These custom conditions give you more reliable synchronisation than generic waits because they check the application's own readiness signals rather than inferring readiness from element state.
def ajax_complete(driver):
"""Wait for all AJAX requests to complete"""
return driver.execute_script("return jQuery.active == 0")
def angular_ready(driver):
"""Wait for Angular to finish processing"""
return driver.execute_script(
"return window.getAllAngularTestabilities().every(t => t.isStable())"
)
def network_idle(driver):
"""Wait for network activity to settle"""
return driver.execute_script("""
return performance.getEntriesByType('resource')
.filter(r => r.responseEnd === 0).length === 0
""")
# Usage
WebDriverWait(driver, 10).until(ajax_complete)
Lists and tables present a distinct challenge from dynamic attributes: the structure itself changes. Rows appear and disappear, item counts shift, and positions move based on filters, sort orders, and real-time updates.
When list length is unpredictable, waiting for a specific item count before interacting prevents your test from running against an incomplete list. This is particularly important for search results and filtered lists where the count depends on query results.
# Wait for list to have items
def list_has_items(locator, min_count=1):
def check(driver):
items = driver.find_elements(*locator)
return len(items) >= min_count
return check
WebDriverWait(driver, 10).until(
list_has_items((By.CSS_SELECTOR, ".search-result"), 5)
)
Referencing list items by their content rather than their position makes your test independent of the current sort order and page state. As long as the item with that content exists in the list, the locator works regardless of where it appears.
# Find row containing specific text
driver.find_element(By.XPATH, "//tr[contains(., 'Order #12345')]")
# Find list item by data
driver.find_element(By.XPATH, "//li[@data-product-id='SKU123']")
# Find card by heading text
driver.find_element(By.XPATH, "//div[contains(@class, 'card')]//h3[text()='Premium Plan']/..")Position-based selection is the most fragile approach to dynamic lists and should be avoided where content-based alternatives exist. When index-based selection is genuinely necessary, always verify the list length first to avoid silent failures from out-of-bounds access.
# Get all items first
items = driver.find_elements(By.CSS_SELECTOR, ".list-item")
# Verify expected count before indexing
if len(items) >= 3:
items[2].click()
else:
raise Exception(f"Expected at least 3 items, found {len(items)}")When you must use an index, narrow the set first by filtering to elements with specific attributes so the index refers to a meaningful position within a defined subset rather than an arbitrary position in the full list.
# Find the third item with specific class
items = driver.find_elements(By.CSS_SELECTOR, ".list-item.active")
# Now index refers to "third active item" not "third item"Tables are the most complex dynamic structure because they combine variable row counts with the need to cross-reference row content against column headers. Hard-coding column indices breaks when columns are reordered. This function resolves the column index from the header text at runtime, making it resilient to column reordering.
# Find cell in row containing "John Smith", column "Email"
def get_cell_by_row_content_and_header(driver, row_content, header_text):
# Find column index
headers = driver.find_elements(By.CSS_SELECTOR, "table th")
col_index = None
for i, header in enumerate(headers):
if header.text == header_text:
col_index = i + 1 # XPath is 1-indexed
break
if col_index is None:
raise Exception(f"Header '{header_text}' not found")
# Find row and cell
return driver.find_element(
By.XPATH,
f"//tr[contains(., '{row_content}')]/td[{col_index}]"
)
email_cell = get_cell_by_row_content_and_header(driver, "John Smith", "Email")
State changes introduce a temporal dimension where the same element is valid at some moments and invalid at others. Tests that do not account for state often produce false failures because they interact with elements at the wrong point in their lifecycle.
Buttons that are disabled until a form is complete, or until a previous action finishes, require a wait that checks the enabled state rather than just the element's presence.
# Wait for button to be enabled
WebDriverWait(driver, 10).until(
lambda d: d.find_element(By.ID, "submit").is_enabled()
)
Elements frequently toggle between visible and hidden states in response to user actions and application events. Both directions of this toggle need explicit handling: waiting for appearance before interacting and waiting for disappearance before asserting the element is gone.
# Wait for element to appear
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "success-message"))
)
# Wait for element to disappear
WebDriverWait(driver, 10).until(
EC.invisibility_of_element_located((By.ID, "error-message"))
)
Class changes are a common pattern for reflecting UI state transitions such as a tab becoming active or a step becoming complete. Rather than polling the element repeatedly in your test code, a custom wait condition encapsulates this check cleanly.
def element_has_class(locator, class_name):
def check(driver):
element = driver.find_element(*locator)
return class_name in element.get_attribute("class")
return check
# Wait for tab to become active
WebDriverWait(driver, 10).until(
element_has_class((By.ID, "settings-tab"), "active")
)
Each dynamic element strategy adds code that engineers must write, review, and maintain. The problem compounds over time.
This complexity accumulates across the test suite. Test code becomes progressively harder to read, maintain, and debug as every new dynamic element adds another layer of handling logic.
AI-native test platform like Virtuoso QA identify elements the way humans do: by what they look like and what they do, not by the implementation details underneath them. When you describe an element in natural language, the platform handles the complexity of locating it across any combination of dynamic attributes.
Here is the same interaction written in traditional Selenium and in natural language. Both achieve the same result. One requires explicit handling of the dynamic ID. The other does not know the ID exists.
A test engineer must know that the email field has a dynamic ID, design a partial XPath match around the stable prefix, wrap it in an explicit wait, and maintain all of this if the prefix changes.
# Handle dynamic ID
element = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//*[contains(@id, 'email_field')]"))
)
element.send_keys("user@example.com")
The platform identifies the email field through label associations, placeholder text, input type, field position, and visual appearance simultaneously. The dynamic ID is irrelevant to the identification process.
Enter "user@example.com" in the email fieldThe platform identifies "email field" through multiple signals: label associations, placeholder text, input type, field position, visual appearance. Dynamic IDs are irrelevant.
Virtuoso QA's AI Augmented Object Identification builds comprehensive element models:
Semantic Signals:
Visual Signals:
Structural Signals:
When any single attribute changes, elements remain identifiable through other signals. Dynamic classes, IDs, and attributes do not cause failures.
Beyond initial identification, self healing adapts when elements change:
Virtuoso QA achieves approximately 95% self healing accuracy, automatically adapting to changes that would require manual locator updates in traditional frameworks.

Try Virtuoso QA in Action
See how Virtuoso QA transforms plain English into fully executable tests within seconds.