Skip to main content
Cypress App

Interacting with Elements

note

UI Coverage turns your runs into a visual map of the interactive elements your tests exercise and the ones they miss, with no code changes or instrumentation. Schedule a demo.

Visibility​

Default Behavior​

As of Cypress 16, the default visibility algorithm delegates to the browser's native Element.checkVisibility() API. This is faster than the previous DOM-walking algorithm and aligns Cypress's notion of "visible" with the browser's own definition, making results more predictable across layouts. See Visibility Strategy below to configure or opt back into the previous algorithm.

An element is considered hidden if:​

  • Its width or height is 0 (via the bounding rect zero-dimension guard).
  • Its computed CSS properties hide it (per checkVisibility()):
    • display: none on the element or any ancestor.
    • visibility: hidden or visibility: collapse.
    • content-visibility: hidden, or content-visibility: auto when not rendered.
    • opacity: 0 (when asserting visibility, see the Opacity note below).
info
Opacity

Elements where the CSS property (or ancestors) is opacity: 0 are considered hidden when asserting on the element's visibility directly.

However elements where the CSS property (or ancestors) is opacity: 0 are considered actionable and any commands used to interact with the hidden element will perform the action.

Visibility Strategy​

Configure which algorithm Cypress uses with the visibilityStrategy option, which accepts either 'modern' (the default, described in Default Behavior above) or 'legacy'.

The legacy algorithm additionally considered elements hidden when they were:

  • Clipped by an ancestor's overflow: hidden
  • Scrolled out of view of an overflow: auto/overflow: scroll ancestor
  • Rotated past 90 degrees via transform with backface-visibility: hidden
  • Covered by another element while positioned fixed or sticky (legacy only checks coverage for fixed- or sticky-positioned elements via document.elementFromPoint)

The modern algorithm intentionally does not detect these cases. If your tests rely on this legacy behavior, you can opt back into the previous algorithm:

const { defineConfig } = require('cypress')

module.exports = defineConfig({
visibilityStrategy: 'legacy',
})

visibilityStrategy can also be set per-suite or per-test:

describe('My Test Suite', { visibilityStrategy: 'legacy' }, () => {
it('Requires legacy visibility to pass', () => {
// ...
})
})
caution

visibilityStrategy is deprecated. Both the 'legacy' value and the option itself will be removed in a future major version of Cypress. Use it only as a temporary migration path while updating tests that depend on legacy visibility semantics.

Updating tests that depended on the legacy algorithm​

When you encounter a test that fails under the modern algorithm, prefer updating the assertion to verify the same user-visible behavior in an algorithm-agnostic way rather than opting into 'legacy'.

Two kinds of assertion are affected, and each needs a different fix:

  • should('be.visible') used as a wait before an action. This is the most common usage. See Waiting for an element before you interact with it below, where the fix is usually to remove the assertion.
  • should('not.be.visible') used to assert that something is hidden. These fail in the cases the legacy algorithm detected and the modern algorithm does not. The remaining patterns cover each of those cases.

Every pattern below uses .should() rather than .then(). Cypress retries .should() until it passes or times out, so these patterns keep the retry behavior of the assertion they replace.

Waiting for an element before you interact with it​

cy.get(selector).should('be.visible').click() is a common way to wait until an element is ready. Under the legacy algorithm, the assertion kept retrying until the element was no longer clipped or covered.

Under the modern algorithm, the assertion passes as soon as the browser considers the element rendered, so the wait ends earlier than it did before.

Action commands did not change. Before every action, Cypress still scrolls the element into view and checks that it is not hidden, disabled, detached, readonly, animating, or covered by another element. Cypress runs the coverage check under both strategies, so it still refuses to click a covered element under 'modern'.

When an action follows the assertion, and the assertion was there to wait out clipping or coverage, you can remove it. The action command already checks both:

cypress/e2e/spec.cy.js
// Before
cy.get('#submit').should('be.visible').click()

// After
cy.get('#submit').click()

Keep the assertion if you rely on it to wait out a fade-in. Actionability ignores opacity, so an element at opacity: 0 is actionable, as described in the Opacity note above. should('be.visible') is the only one of the two that waits for the element to become opaque.

Because the assertion passes sooner, the action that follows it starts sooner. If your application replaces the element in that window, the action fails with we initially found matching element(s), but while waiting for them to become actionable, they disappeared from the page. The error describes a detached element and never mentions visibility, so the cause is easy to miss.

Removing the redundant should('be.visible') does not fix this on its own. Wait on a signal from your application that the element has settled, such as a loading indicator disappearing, a request completing with cy.intercept(), or a class or attribute your application sets. Then perform the action.

When you need to assert that a user can see and use an element, combine Cypress.dom.isVisible() with a coverage check. Define both as custom queries so you can reuse them across your suite:

cypress/support/commands.js
const isCovered = (el) => {
const win = el.ownerDocument.defaultView
const rect = el.getBoundingClientRect()
const elAtPoint = el.ownerDocument.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2
)

if (el.contains(elAtPoint)) {
return false
}

// `pointer-events: none` makes the hit test fall through to an ancestor,
// which the legacy algorithm did not count as coverage
const ignoresPointerEvents =
win.getComputedStyle(el).pointerEvents === 'none' ||
(el.parentElement &&
win.getComputedStyle(el.parentElement).pointerEvents === 'none')

return !(ignoresPointerEvents && elAtPoint && elAtPoint.contains(el))
}

Cypress.Commands.addQuery('isCovered', function () {
return ($el) => Array.from($el).every(isCovered)
})

Cypress.Commands.addQuery('isVisibleToUser', function () {
return ($el) =>
Array.from($el).every((el) => Cypress.dom.isVisible(el) && !isCovered(el))
})
cypress/e2e/spec.cy.js
cy.get('#confirmation-banner').isVisibleToUser().should('be.true')

Both are queries rather than commands. A query reads state without changing it, and Cypress reruns it on each retry.

isCovered() requires every matched element to be covered, which is what not.be.visible asks for.

isVisibleToUser() is stricter than be.visible in three ways, so treat it as its own assertion rather than a drop-in replacement:

  • It builds on Cypress.dom.isVisible(), which follows the configured strategy. Under 'modern' you get modern visibility plus coverage, so a clipped or scrolled-out element still reports as visible. The patterns below cover those two cases.
  • It hit tests every element. The legacy algorithm only hit tested fixed- and sticky-positioned elements.
  • It requires every matched element to pass, while be.visible is satisfied when any one of them is visible. chai-jQuery implements that assertion as $el.is(':visible'), and jQuery's .is() matches on any element in the set.

The hit test has one more consequence worth knowing. elementFromPoint() returns null for a point outside the viewport, which counts as covered, so anything below the fold reports as not visible to the user. The legacy algorithm never met that case, because a fixed or sticky element is in the viewport by definition. Cypress also scrolls an element into view before acting on it, so an element below the fold is still usable. Reach for isVisibleToUser() only where you expect the element to be on screen already.

fixed or sticky elements covered by something else​

The legacy algorithm treated a fixed- or sticky-positioned element as hidden when another element covered its center point. The element doing the covering can be positioned any way at all. The modern algorithm does not hit test, so it reports the covered element as visible.

Run the same hit test with the isCovered() query defined above:

cypress/e2e/spec.cy.js
// Before
cy.get('#toolbar-action').should('not.be.visible')

// After
cy.get('#toolbar-action').isCovered().should('be.true')

That query calls document.elementFromPoint(), which is relative to the viewport and returns null for a point outside it. The query treats that as covered. If the element can also scroll out of view, assert on its geometry instead, as in the next pattern.

Scroll-clipped elements​

The modern algorithm reports elements scrolled out of an overflow: auto ancestor as visible (they are technically rendered, just outside the scroll container's visible area). Assert on the geometry directly:

cypress/e2e/spec.cy.js
// Before
cy.get('#scroll-container button').should('not.be.visible')

// After
cy.get('#scroll-container button').should(($el) => {
const container = $el[0].closest('#scroll-container')

expect($el[0].getBoundingClientRect().top).to.be.greaterThan(
container.getBoundingClientRect().bottom
)
})
Children inside a collapsed container that hides via overflow: hidden + max-height: 0​

Many UI libraries hide collapsible content by setting max-height: 0 and overflow: hidden on a wrapper. The wrapper itself reports as hidden under the modern algorithm (its bounding rect collapses to zero), but children with their own non-zero dimensions inside that wrapper are not detected as hidden, because checkVisibility() doesn't account for ancestor clipping. Most libraries also set aria-hidden="true" on the closed wrapper, so assert on that instead:

cypress/e2e/spec.cy.js
// Before
cy.get('.collapsible-content .nested-item').should('not.be.visible')

// After
cy.get('.collapsible-content .nested-item')
.closest('[aria-hidden]')
.should('have.attr', 'aria-hidden', 'true')
Elements rotated away with backface-visibility: hidden​

An element rotated past 90 degrees with backface-visibility: hidden turns its back face to the viewer, so the browser does not paint it. It keeps a non-zero bounding rect, and checkVisibility() reports it as visible.

Card flip and carousel components usually track which face is showing. Assert on that state, so your test does not depend on how the flip is implemented:

cypress/e2e/spec.cy.js
// Before
cy.get('.card-back').should('not.be.visible')

// After
cy.get('.card').should('have.attr', 'data-facing', 'front')

Prefer that over a geometric check. In a typical flip, the rotation that hides a face lives on an ancestor, and the face itself carries a static transform: rotateY(180deg) that never changes. Reading the computed transform off the element alone therefore returns the same value in both states, so the assertion passes whichever face is showing. Reproducing the legacy check means accumulating the transform across every preserve-3d ancestor, which is more DOM logic than the assertion is worth.

If your component exposes no state at all, add an attribute that reflects which face is showing, then assert on it.

Text truncated with text-overflow: ellipsis​

The browser renders truncated text with an ellipsis, but still considers the underlying text node visible. Neither algorithm reports the container as hidden, so use this pattern whenever you need to assert that text is clipped.

Assert that the truncating ancestor's scrollWidth exceeds its clientWidth:

cypress/e2e/spec.cy.js
cy.get('.truncate-container').should(($el) => {
expect($el[0].scrollWidth).to.be.greaterThan($el[0].clientWidth)
})

Actionability​

Some commands in Cypress are for interacting with the DOM such as:

We call these "action commands." These actions simulate a user interacting with your application. Under the hood, Cypress fires the events a browser would fire thus causing your application's event bindings to fire.

Prior to issuing any of the commands, we check the current state of the DOM and take some actions to ensure the DOM element is "ready" to receive the action.

Cypress will watch the DOM - re-running the queries that yielded the current subject - until an element passes all of these checks for the duration of the defaultCommandTimeout (described in depth in the Implicit Assertions core concept guide).

Checks and Actions Performed

Whenever Cypress cannot interact with an element, it could fail at any of the above steps. You will usually get an error explaining why the element was not found to be actionable.

Disability​

Cypress checks whether the disabled property is true on a form control element, such as button or input. Setting a disabled attribute on other elements will have no effect on a user's ability to interact with them, and won't impact Cypress actionability checks.

Detached​

Cypress checks whether an element you are making assertions on is still within the document of the application under test.

When many applications rerender the DOM, they actually remove the DOM element and insert a new DOM element in its place with the newly change attributes. This is why it's important not to chain action commands together - cypress can re-run queries to locate the fresh element, but it will never re-run commands.

Readonly​

Cypress checks whether an element's readonly property is set during .type().

Animations​

Cypress will automatically determine if an element is animating and wait until it stops.

To calculate whether an element is animating we take a sample of the last positions it was at and calculate the element's slope. You might remember this from 8th grade algebra. 😉

To calculate whether an element is animating we check the current and previous positions of the element itself. If the distance exceeds the animationDistanceThreshold, then we consider the element to be animating.

When coming up with this value, we did a few experiments to find a speed that "feels" too fast for a user to interact with. You can always increase or decrease this threshold.

You can also turn off our checks for animations with the configuration option waitForAnimations.

Covering​

We also ensure that the element we're attempting to interact with isn't covered by a parent element.

For instance, an element could pass all of the previous checks, but a giant dialog could be covering the entire screen making interacting with the element impossible for any real user.

info

When checking to see if the element is covered we always check its center coordinates.

If a child of the element is covering it - that's okay. In fact we'll automatically issue the events we fire to that child.

Imagine you have a button:

<button>
<i class="fa fa-check">
<span>Submit</span>
</button>

Oftentimes either the <i> or <span> element is covering the exact coordinate we're attempting to interact with. In those cases, the event fires on the child. We even note this for you in the Command Log.

Scrolling​

Before interacting with an element, we will always scroll it into view (including any of its parent containers). Even if the element was visible without scrolling, we perform the scrolling algorithm in order to reproduce the same behavior every time the command is run.

info

This scrolling logic only applies to commands that are actionable above. We do not scroll elements into view when using DOM commands such as cy.get() or .find().

By default, the scrolling algorithm works by scrolling the top of the element we issued the command on to the top of its scrollable container. The horizontal position is left to the browser, which scrolls only as far as it needs to bring the element into view, so a container that already shows the element horizontally is not scrolled sideways.

After scrolling the element, if we determine that it is still being covered up, we will continue to scroll and "nudge" the page until it becomes visible. This most frequently happens when you have position: fixed or position: sticky navigation elements which are fixed to the top of the page.

Our algorithm should always be able to scroll until the element is not covered.

To change the position in the viewport to where we scroll an element, you can use the scrollBehavior configuration option. This can be useful if the element is covered up when aligned to the top of the viewport, or if you prefer the element to be centered during scrolling of action commands.

scrollBehavior accepts a single alignment, an object that sets each axis independently, or false to skip scrolling. Cypress translates the value into the options it hands to the browser's scrollIntoView() method.

Aligning with a single value​

ValuescrollIntoView() optionsResult
'top'{ block: 'start' }Aligns the element to the top of its scrollable container and leaves the horizontal position to the browser. This is the default.
'bottom'{ block: 'end' }Aligns the element to the bottom of its scrollable container and leaves the horizontal position to the browser.
'start'{ block: 'start', inline: 'start' }Aligns the element to the start of both axes, which is the top, leftmost point of its scrollable container in a left-to-right document.
'end'{ block: 'end', inline: 'end' }Aligns the element to the end of both axes, which is the bottom, rightmost point of its scrollable container in a left-to-right document.
'center'{ block: 'center', inline: 'center' }Centers the element within its scrollable container on both axes.
'nearest'{ block: 'nearest', inline: 'nearest' }Scrolls the minimum amount needed on both axes to bring the element into view. If the element is above the visible area it aligns to the top; if it is below, it aligns to the bottom; if it is already fully visible, it does not scroll.
falseNot applicableDisables scrolling entirely before executing the command.

'top' and 'bottom' describe the vertical axis only, the same way scrollIntoView(true) and scrollIntoView(false) do, so they never scroll a horizontally scrollable container that is already showing the element. Use 'start' or 'end' when you want both axes aligned.

Aligning each axis independently​

Pass an object to control the two axes separately. block is the vertical axis and inline is the horizontal axis, and each takes the same values as the native scrollIntoView() method: 'start', 'end', 'center', or 'nearest'.

const { defineConfig } = require('cypress')

module.exports = defineConfig({
scrollBehavior: { block: 'center', inline: 'nearest' },
})

'top' and 'bottom' are Cypress shorthands for a whole alignment rather than positions an axis can take, so they are not valid inside the object. Use 'start' in place of 'top' and 'end' in place of 'bottom'.

An axis you leave out is omitted from the scrollIntoView() call, so the browser's own default applies: 'start' for block and 'nearest' for inline.

Overriding the configured value​

A scrollBehavior passed to a command replaces the configured value rather than merging with it one axis at a time. With scrollBehavior: { block: 'center' } in your Cypress configuration, this click scrolls with { inline: 'start' } alone, and block uses the browser default instead of staying 'center':

cy.get('button').click({ scrollBehavior: { inline: 'start' } })

Test configuration overrides replace the global value the same way, so include every axis you care about in the value you pass.

Coordinates​

After we verify the element is actionable, Cypress will then fire all of the appropriate events and corresponding default actions. Usually these events' coordinates are fired at the center of the element, but most commands enable you to change the position it's fired to.

cy.get('button').click({ position: 'topLeft' })

The coordinates we fired the event at will generally be available when clicking the command in the Command Log.

Event coordinates

Additionally we'll display a red "hitbox" - which is a dot indicating the coordinates of the event.

Cypress App highlighting the click command with the red hitbox highlighted over the element

Debugging​

It can be difficult to debug problems when elements are not considered actionable by Cypress.

Although you should see a nice error message, nothing beats visually inspecting and poking at the DOM yourself to understand the reason why.

When you use the Command Log to hover over a command, you'll notice that we will always scroll the element the command was applied to into view. Please note that this is NOT using the same algorithms that we described above.

In fact we only ever scroll elements into view when actionable commands are running using the above algorithms. We do not scroll elements into view on regular DOM queries like cy.get() or .find().

The reason we scroll an element into view when hovering over a snapshot is to help you to see which element(s) were found by that corresponding command. It's a purely visual feature and does not necessarily reflect what your page looked like when the command ran.

In other words, you cannot get a correct visual representation of what Cypress "saw" when looking at a previous snapshot.

The only way for you to "see" and debug why Cypress thought an element was not visible is to use a debugger statement.

We recommend placing debugger or using the .debug() command directly BEFORE the action.

Make sure your Developer Tools are open and you can get pretty close to "seeing" the calculations Cypress is performing.

You can also bind to Events that Cypress fires as it's working with your element. Using a debugger with these events will give you a much lower level view into how Cypress works.

// break on a debugger before the action command
cy.get('button').debug().click()

Forcing​

While the above checks are super helpful at finding situations that would prevent your users from interacting with elements - sometimes they can get in the way!

Sometimes it's not worth trying to "act like a user" to get a robot to do the exact steps a user would to interact with an element.

Imagine you have a nested navigation structure where the user must hover over and move the mouse in a very specific pattern to reach the desired link.

Is this worth trying to replicate when you're testing?

Maybe not! For these scenarios, we give you an escape hatch to bypass all of the checks above and force events to happen!

You can pass { force: true } to most action commands.

// force the click and all subsequent events
// to fire even if this element isn't considered 'actionable'
cy.get('button').click({ force: true })
info
What's the difference?

When you force an event to happen we will:

  • Continue to perform all default actions
  • Forcibly fire the event at the element

We will NOT perform these:

  • Scroll the element into view
  • Ensure it is visible
  • Ensure it is not disabled
  • Ensure it is not detached
  • Ensure it is not readonly
  • Ensure it is not animating
  • Ensure it is not covered
  • Fire the event at a descendent

In summary, { force: true } skips the checks, and it will always fire the event at the desired element.

caution

force .select() disabled options

Passing { force: true } to .select() will not override the actionability checks for selecting a disabled <option> or an option within a disabled <optgroup>. See this issue for more detail.