How to Verify an Element Is Within Viewport with Cypress: Testing On-Page Anchor Functionality

On-page anchors (e.g., <a href="#section">Jump to Section</a>) are a cornerstone of user-friendly web navigation, allowing visitors to quickly jump to specific content on a page. However, ensuring these anchors work as intended—and that the target content is actually visible in the viewport—is critical for a smooth user experience. A common pitfall in testing anchors is only verifying the URL hash updates (e.g., #section appears in the URL) without confirming the target element is visible to the user.

In this guide, we’ll dive into how to test on-page anchor functionality with Cypress, focusing on verifying that the target element is within the viewport after clicking the anchor. We’ll cover:

  • Why viewport visibility matters for anchor testing.
  • Cypress’s built-in tools for viewport and visibility checks.
  • Creating a custom Cypress command to confirm elements are in the viewport.
  • Step-by-step test examples, common pitfalls, and best practices.

Table of Contents#

  1. Understanding On-Page Anchors
  2. Why Viewport Verification Matters for Anchors
  3. Cypress Basics: Viewports and Visibility
  4. Limitations of Cypress’s Built-in be.visible Assertion
  5. Creating a Custom isInViewport Command
  6. Step-by-Step Example: Testing Anchors with Viewport Checks
  7. Common Pitfalls and Solutions
  8. Best Practices for Anchor Testing in Cypress
  9. References

Understanding On-Page Anchors#

On-page anchors link to specific sections of the same page using the id attribute of a target element. For example:

<!-- Anchor link -->
<a href="#features" class="anchor-link">Jump to Features</a>
 
<!-- Target section -->
<section id="features" class="section">
  <h2>Features</h2>
  <!-- Content -->
</section>

When clicked, the browser scrolls to the element with id="features". The URL updates to include #features (the "hash").

Why Viewport Verification Matters#

Testing anchors isn’t just about checking the URL hash. Bugs can prevent the target element from appearing in the viewport, even if the hash updates:

  • Fixed headers: A sticky header might overlap the target section, hiding it from view.
  • Dynamic content: Slow-loading images or API-driven content might shift the page layout after scrolling.
  • Responsive design: The target section might be off-screen on mobile due to smaller viewport sizes.

To ensure anchors work as intended, we need to confirm two things:

  1. The URL hash updates to the target section’s id.
  2. The target section is visibly within the viewport after scrolling.

Cypress Basics: Viewports and Visibility#

Before diving into custom commands, let’s recap how Cypress handles viewports and element visibility.

Setting Viewports in Cypress#

Cypress lets you define viewport dimensions (width/height) to simulate different devices using cy.viewport(). For example:

// Set viewport to iPhone 12 (390x844)
cy.viewport('iphone-12');
 
// Custom dimensions (1200x800)
cy.viewport(1200, 800);

This is critical for testing responsive behavior—an anchor that works on desktop might fail on mobile.

Cypress’s be.visible Assertion#

Cypress provides a built-in be.visible assertion to check if an element is visible. However, it does not check if the element is in the viewport. It only verifies:

  • The element’s display property is not none.
  • The element’s visibility property is not hidden or collapse.
  • The element’s opacity is not 0.
  • The element has non-zero dimensions (width/height > 0).

For example, an element hidden below the fold (out of the viewport) will still pass be.visible because it’s technically "visible" in the DOM—just not in the viewport.

Limitations of Cypress’s Built-in be.visible Assertion#

To demonstrate, consider this test:

// This test will PASS even if #features is below the fold!
cy.get('.anchor-link').click();
cy.url().should('include', '#features');
cy.get('#features').should('be.visible'); // ❌ Doesn't check viewport

The be.visible assertion passes because #features exists in the DOM and isn’t hidden by CSS. But if #features is scrolled off-screen, the user won’t see it.

Creating a Custom isInViewport Command#

To verify an element is within the viewport, we need a custom assertion. We’ll use the getBoundingClientRect() method, which returns an element’s position relative to the viewport.

How getBoundingClientRect() Works#

element.getBoundingClientRect() returns an object with properties like top, bottom, left, and right, representing the element’s edges relative to the viewport:

  • top: Distance from the top of the viewport to the element’s top edge.
  • bottom: Distance from the top of the viewport to the element’s bottom edge.
  • left: Distance from the left of the viewport to the element’s left edge.
  • right: Distance from the left of the viewport to the element’s right edge.

An element is fully in the viewport if:

  • top >= 0 (top edge is not above the viewport).
  • bottom <= viewportHeight (bottom edge is not below the viewport).
  • left >= 0 (left edge is not left of the viewport).
  • right <= viewportWidth (right edge is not right of the viewport).

Step 1: Define the Custom Command#

Add this to cypress/support/commands.js (or a dedicated commands file) to register a reusable isInViewport command:

// cypress/support/commands.js
Cypress.Commands.add(
  'isInViewport',
  { prevSubject: 'element' }, // Accepts a DOM element as input
  (subject) => {
    // Get the element's position relative to the viewport
    const rect = subject[0].getBoundingClientRect();
 
    // Get viewport dimensions (from Cypress config or window)
    const viewportWidth = Cypress.config('viewportWidth') || window.innerWidth;
    const viewportHeight = Cypress.config('viewportHeight') || window.innerHeight;
 
    // Assert the element is fully within the viewport
    expect(rect.top).to.be.gte(0);
    expect(rect.bottom).to.be.lte(viewportHeight);
    expect(rect.left).to.be.gte(0);
    expect(rect.right).to.be.lte(viewportWidth);
 
    return subject; // Allow chaining
  }
);

How It Works#

  • prevSubject: 'element' tells Cypress the command expects a DOM element (e.g., from cy.get()).
  • subject[0] accesses the raw DOM element from Cypress’s wrapped subject.
  • Cypress.config('viewportWidth') retrieves the viewport size set via cy.viewport(). If not set, it falls back to window.innerWidth.
  • The assertions check that all edges of the element are within the viewport boundaries.

Step-by-Step Example: Testing Anchors with Viewport Checks#

Let’s test a sample page with anchors. We’ll use a simple HTML file and write a Cypress test to verify anchor behavior.

Step 1: Create a Sample Page#

Save this as public/anchor-test.html:

<!DOCTYPE html>
<html>
<head>
  <title>Anchor Test Page</title>
  <style>
    .section {
      height: 100vh; /* Each section takes full viewport height */
      padding: 20px;
    }
    #hero { background: #f0f0f0; }
    #features { background: #e0e0e0; }
    #pricing { background: #d0d0d0; }
    .anchor-links {
      position: fixed;
      top: 20px;
      left: 20px;
    }
    .anchor-link { margin-right: 10px; }
  </style>
</head>
<body>
  <div class="anchor-links">
    <a href="#hero" class="anchor-link">Hero</a>
    <a href="#features" class="anchor-link">Features</a>
    <a href="#pricing" class="anchor-link">Pricing</a>
  </div>
 
  <section id="hero" class="section">
    <h1>Hero Section</h1>
  </section>
  <section id="features" class="section">
    <h1>Features Section</h1>
  </section>
  <section id="pricing" class="section">
    <h1>Pricing Section</h1>
  </section>
</body>
</html>

Each section is 100vh (full viewport height), so clicking an anchor should scroll to that section.

Step 2: Write the Cypress Test#

Create a test file cypress/e2e/anchor-viewport.cy.js:

describe('On-Page Anchor Functionality', () => {
  beforeEach(() => {
    // Visit the sample page and set viewport to desktop
    cy.visit('/anchor-test.html');
    cy.viewport(1200, 800); // Desktop viewport
  });
 
  it('clicks "Features" anchor and verifies #features is in viewport', () => {
    // 1. Click the "Features" anchor
    cy.get('.anchor-link').contains('Features').click();
 
    // 2. Verify URL hash updates
    cy.url().should('include', '#features');
 
    // 3. Verify #features section is in viewport using custom command
    cy.get('#features').isInViewport(); // ✅ Uses our custom command
  });
 
  it('works on mobile viewport (iPhone 12)', () => {
    // Override viewport for mobile testing
    cy.viewport('iphone-12');
 
    // Click "Pricing" anchor
    cy.get('.anchor-link').contains('Pricing').click();
 
    // Verify hash and viewport
    cy.url().should('include', '#pricing');
    cy.get('#pricing').isInViewport();
  });
});

How It Works#

  • beforeEach visits the test page and sets a default desktop viewport.
  • The first test clicks the "Features" anchor, checks the URL hash, and verifies #features is in the viewport with isInViewport().
  • The second test simulates a mobile device (iPhone 12) to ensure responsiveness.

Common Pitfalls and Solutions#

Pitfall 1: Fixed Headers Overlapping Targets#

A fixed header can hide the top of the target section. For example, a 80px-tall header might cause #features to scroll so its top is -20px (partially hidden). The isInViewport command would fail because rect.top < 0.

Solution: Adjust for Header Height#

Modify the isInViewport command to account for fixed headers by subtracting the header height from the viewport top boundary:

// Updated command with header height adjustment
Cypress.Commands.add(
  'isInViewport',
  { prevSubject: 'element' },
  (subject, headerHeight = 0) => { // Accept headerHeight as an argument
    const rect = subject[0].getBoundingClientRect();
    const viewportWidth = Cypress.config('viewportWidth') || window.innerWidth;
    const viewportHeight = Cypress.config('viewportHeight') || window.innerHeight;
 
    // Adjust top boundary by header height
    const adjustedTop = rect.top + headerHeight;
 
    expect(adjustedTop).to.be.gte(0); // Top edge (adjusted) >= 0
    expect(rect.bottom).to.be.lte(viewportHeight);
    expect(rect.left).to.be.gte(0);
    expect(rect.right).to.be.lte(viewportWidth);
 
    return subject;
  }
);

Use it like this in tests:

// Assume header is 80px tall
cy.get('#features').isInViewport(80); // Adjust for 80px header

Pitfall 2: Dynamic Content Shifting Layout#

If the target section contains images or API-driven content that loads after scrolling, the page layout might shift, moving the element out of the viewport.

Solution: Wait for Content to Load#

Use cy.intercept() to wait for API calls, or cy.get().should('be.visible') to ensure dynamic content is loaded before checking the viewport:

// Wait for images to load before checking viewport
cy.get('#features img').should('be.visible');
cy.get('#features').isInViewport();

Pitfall 3: Partial Visibility#

Sometimes you may want to allow partial visibility (e.g., 50% of the element is in view). The default isInViewport command enforces full visibility.

Solution: Relax Assertions for Partial Visibility#

Modify the command to check if any part of the element is in the viewport:

// Command for partial visibility (at least 1px in viewport)
Cypress.Commands.add(
  'isPartiallyInViewport',
  { prevSubject: 'element' },
  (subject) => {
    const rect = subject[0].getBoundingClientRect();
    const viewportWidth = Cypress.config('viewportWidth') || window.innerWidth;
    const viewportHeight = Cypress.config('viewportHeight') || window.innerHeight;
 
    // Check if any part of the element overlaps with the viewport
    const isOverlapping = !(
      rect.bottom < 0 || // Element is above viewport
      rect.top > viewportHeight || // Element is below viewport
      rect.right < 0 || // Element is left of viewport
      rect.left > viewportWidth // Element is right of viewport
    );
 
    expect(isOverlapping).to.be.true;
    return subject;
  }
);

Best Practices for Anchor Testing#

  1. Combine Hash and Viewport Checks: Always verify both the URL hash and viewport visibility.
  2. Test Responsive Viewports: Use cy.viewport() to test mobile, tablet, and desktop.
  3. Handle Dynamic Content: Wait for images, fonts, or API responses before checking visibility.
  4. Document Custom Commands: Add JSDoc comments to isInViewport for clarity:
/**
 * Verifies an element is fully within the viewport.
 * @param {HTMLElement} subject - The DOM element to check.
 * @param {number} [headerHeight=0] - Height of fixed header to adjust for.
 * @example cy.get('#features').isInViewport(80); // Adjust for 80px header
 */
Cypress.Commands.add('isInViewport', { prevSubject: 'element' }, (subject, headerHeight = 0) => {
  // ...
});

References#

By following this guide, you can ensure on-page anchors reliably scroll to visible sections, improving user experience and catching edge cases in your application.