Interacting with Elements
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
widthorheightis0(via the bounding rect zero-dimension guard). - Its computed CSS properties hide it (per
checkVisibility()):display: noneon the element or any ancestor.visibility: hiddenorvisibility: collapse.content-visibility: hidden, orcontent-visibility: autowhen not rendered.opacity: 0(when asserting visibility, see the Opacity note below).
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: scrollancestor - Rotated past 90 degrees via
transformwithbackface-visibility: hidden - Covered by another element while positioned
fixedorsticky(legacy only checks coverage for fixed- or sticky-positioned elements viadocument.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:
- cypress.config.js
- cypress.config.ts
const { defineConfig } = require('cypress')
module.exports = defineConfig({
visibilityStrategy: 'legacy',
})
import { defineConfig } from 'cypress'
export default 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', () => {
// ...
})
})
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:
// 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:
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))
})
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- andsticky-positioned elements. - It requires every matched element to pass, while
be.visibleis 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:
// 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:
// 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:
// 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:
// 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:
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:
.click().dblclick().rightclick().type().clear().check().uncheck().select().trigger().selectFile()
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
- Scroll the element into view.
- Ensure the element is not hidden.
- Ensure the element is not disabled.
- Ensure the element is not detached.
- Ensure the element is not readonly.
- Ensure the element is not animating.
- Ensure the element is not covered.
- Scroll the page if still covered by an element with fixed position.
- Fire the event at the desired coordinates.
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.
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.
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​
| Value | scrollIntoView() options | Result |
|---|---|---|
'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. |
false | Not applicable | Disables 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'.
- cypress.config.js
- cypress.config.ts
const { defineConfig } = require('cypress')
module.exports = defineConfig({
scrollBehavior: { block: 'center', inline: 'nearest' },
})
import { defineConfig } from 'cypress'
export default 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.

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

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 })
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.
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.