Element steps are the atoms for fine-grained UI verification: interact with any element addressed by a raw CSS selector (click, hover, focus, scroll, dispatch events) and assert on things higher-level steps cannot express - attribute values, document order, viewport position, and visibility. When the friendly phrasings like When I press "Save" or the selector-registry shorthand cannot reach what you need, these steps can.
All of them build on Playwright locators, so interactions inherit Playwright's actionability auto-waiting: a click waits for the element to be attached, visible, and stable before firing. Where a selector matches multiple elements, the first match is used. Interaction steps raise friendly errors on failure - each one tells you what it could not do and hints at the likely cause (element missing, hidden, covered, or not focusable).
The assertions split into three families: attribute existence (built from selector[attr="value"] compound selectors, with exact and "containing" variants plus negatives), positional checks (element order, text order, top-of-viewport, horizontal centering), and visibility/viewport checks (displayed at all vs. fully inside the current viewport rectangle, with an optional top offset for sticky headers).
Quick reference
| Step | What it does |
|---|---|
Then the element "#footer" should appear after the element "#main" | Assert element A is positioned vertically below element B. |
Then the text "Sign in" should appear after the text "Welcome" | Assert text A appears after text B in the body's text order. |
Then the element "a" with the attribute "href" and the value "/about" should exist | Assert at least one element matches selector[attr="value"]. |
Then the element "a" with the attribute "href" and the value containing "/about" should exist | Assert at least one element has an attribute value containing a substring. |
Then the element "a" with the attribute "href" and the value "/admin" should not exist | Assert NO element matches selector[attr="value"]. |
Then the element "a" with the attribute "href" and the value containing "/old" should not exist | Assert NO element has an attribute value containing a substring. |
Then the element "#header" should be at the top of the viewport | Assert an element sits within the top 100 px of the viewport. |
Then the element ".hero" should be centered in the viewport | Assert an element is horizontally centered (±10% tolerance). |
When I click on the element "#sign-in" | Click an element addressed by CSS selector. |
When I trigger the JS event "click" on the element "#cta" | Dispatch a synthetic JavaScript event on an element. |
When I scroll to the element "#footer" | Scroll an element into view if needed. |
When I hover over the element ".tooltip-trigger" | Hover the mouse pointer over an element. |
When I focus on the element "#email" | Move keyboard focus to an element. |
Then the element "#dashboard" should be displayed | Wait for an element to become visible. |
Then the element "#loading-spinner" should not be displayed | Assert an element is hidden or absent. |
Then the element "#hero" should be displayed within a viewport | Assert an element is fully inside the current viewport. |
Then the element "#hero" should be displayed within a viewport with a top offset of 60 pixels | Same, but tolerate a sticky header of the given height. |
Then the element "#footer" should not be displayed within a viewport | Assert an element is NOT fully inside the viewport. |
Then the element "#footer" should not be displayed within a viewport with a top offset of 60 pixels | Negative viewport check with a top offset. |
Steps in detail
Then the element "<A>" should appear after the element "<B>"
Compares the two elements' bounding boxes and asserts A's Y coordinate is greater than B's - i.e. A is rendered further down the page. Both elements must be visible (they need bounding boxes). This checks rendered position, not DOM source order, so it catches CSS/layout regressions that reorder content visually.
Then the element ".lead[data-role='outro']" should appear after the element ".lead[data-role='intro']"
Then the text "<A>" should appear after the text "<B>"
Reads the body's innerText and asserts the first occurrence of A comes after the first occurrence of B. Both texts must be present; each missing text gets its own failure message. Useful for verifying reading order without caring which elements hold the text.
Then the text "Second paragraph" should appear after the text "First paragraph"
Then the element "<sel>" with the attribute "<attr>" and the value [containing] "<value>" should [not] exist
Four variants built from a compound CSS selector: exact match uses sel[attr="value"], "containing" uses the substring form sel[attr*="value"], and each has a should not exist negative asserting zero matches. These are existence checks against the DOM - the element does not need to be visible. Handy for links (href), inputs (name, type), test IDs, image sources, and ARIA attributes.
Then the element "h1" with the attribute "data-test" and the value "hero" should exist
And the element "h1" with the attribute "data-test" and the value containing "her" should exist
And the element "h1" with the attribute "data-test" and the value "missing" should not exist
And the element "img" with the attribute "src" and the value containing "tracking" should not exist
Then the element "<sel>" should be at the top of the viewport
Asserts the element's bounding-box Y coordinate is between 0 and 100 px - i.e. it sits within the top 100 px of the viewport. The element must be visible. Typical targets: page headers, sticky navs, and anchors after a scroll-to-top.
When I scroll to the top
Then the element "#header" should be at the top of the viewport
Then the element "<sel>" should be centered in the viewport
Asserts the element's horizontal center is within ±10% of the viewport width from the true center. On mismatch the error reports the computed center and viewport width so you can see how far off it is. Good for hero blocks, modals, and loaders.
Then the element ".modal-dialog" should be centered in the viewport
When I click on the element "<sel>"
Clicks the first element matching the CSS selector, with Playwright's full actionability auto-wait (attached, visible, stable, receives events). On failure you get a friendly error hinting that the element must exist, be visible, and be clickable. Use this when button/link text phrasings cannot target the element - e.g. icon buttons or nth-child selections.
When I click on the element "button[aria-label='Close']"
And I click on the element "li.nav-item:first-child a"
When I trigger the JS event "<event>" on the element "<sel>"
Dispatches a synthetic JavaScript event (dispatchEvent) instead of simulating real input. Useful for components that listen for non-bubbling events or events Playwright cannot synthesize through normal interactions. Stick to standard event names like click, change, input, focus, or blur. Note a dispatched event bypasses actionability checks - it fires even on covered elements.
When I trigger the JS event "change" on the element "#country"
And I trigger the JS event "input" on the element "#search"
When I scroll to the element "<sel>" / hover over / focus on
scroll to uses Playwright's scrollIntoViewIfNeeded() - it does nothing if the element is already visible. hover over moves the real mouse pointer onto the element, triggering CSS :hover and JS mouseover handlers (tooltips, dropdown menus). focus on moves keyboard focus; the target must be focusable - an input, button, link, or an element with tabindex. All three accept the we pronoun and fail with targeted hints.
When I scroll to the element "tbody tr:nth-child(20)"
And I hover over the element ".tooltip-trigger"
And I focus on the element "input[type=search]"
Then the element "<sel>" should [not] be displayed
The positive form is web-first: it waits for the element to reach the visible state (display not none, non-zero size, etc.) rather than checking once, so it doubles as a wait for post-action UI like toasts. The negative form passes immediately if the element is absent from the DOM, and otherwise asserts it is not currently visible.
When I press "Save"
Then the element ".toast-success" should be displayed
And the element "#loading-spinner" should not be displayed
Then the element "<sel>" should [not] be displayed within a viewport [with a top offset of <N> pixels]
Stricter than "displayed": the element's entire bounding rectangle must sit inside the current viewport (top, left, bottom, and right all within bounds). The top-offset variants raise the effective top edge by N pixels - pass your sticky header's height so content that scrolls underneath it counts as obscured. The negatives assert the element is at least partially outside the (offset) viewport.
When I scroll to the element "#footer"
Then the element "#footer" should be displayed within a viewport
And the element "#hero" should not be displayed within a viewport with a top offset of 60 pixels
Complete example
Adapted from tests/features/element.feature, running against the examples/element.html fixture:
Feature: Element step definitions
Scenario: Element existence and attribute matching
Given I am on "/element.html"
Then the element "h1" with the attribute "data-test" and the value "hero" should exist
And the element "h1" with the attribute "data-test" and the value containing "her" should exist
And the element "h1" with the attribute "data-test" and the value "missing" should not exist
And the element "h1" should be displayed
And the element "#hidden" should not be displayed
And the element ".lead[data-role='outro']" should appear after the element ".lead[data-role='intro']"
And the text "Second paragraph" should appear after the text "First paragraph"
Scenario: Click and hover via element steps
Given I am on "/element.html"
When I click on the element "#btn"
Then I should see "clicked"
Tips
- Selectors here are raw CSS. If you use the same selector in many scenarios, register it in the selector registry and use the readable
"<name>"form with the web-first assertion steps instead. - All interactions target the first match - tighten the selector (e.g. with
:first-child, attribute filters, or adata-testid) if the wrong element is being hit. - Prefer
click on the element(real input, auto-waited) overtrigger the JS event "click"(synthetic, no actionability checks) unless the component specifically needs a dispatched event. - Viewport assertions depend on the current window size - combine with the responsive steps to verify layout at specific breakpoints.