Detect Broken UI Elements

Find broken layouts, missing images, overlapping elements, and off-screen buttons on any web page. One API call returns bounding rectangles for every element. No baselines, no false positives.

Vitalii Holben Updated

The Page Analysis API returns bounding rectangles for every element on a rendered page. Four numbers per element: x, y, width, height. That coordinate data is enough to catch the UI bugs that functional tests miss entirely. A button pushed off-screen by a rogue CSS change. An image container rendering at 0×0 because the CDN failed. Two elements stacked on top of each other after a merge conflict. A form that slid below the fold when someone added a banner above it. These aren't crashes. The server returns 200. The DOM has all the right nodes. But the page is broken for every visitor who lands on it.

One POST to /v1/analyze gives you the full coordinate map. Write a few comparisons against that data. Is any critical element at zero width? Is the CTA above y=800? Do any bounding boxes overlap by more than 50%? That's a broken-UI detector that runs in under four seconds, needs no baseline images, never throws a false positive because a font rendered one pixel differently.

curl -X POST https://api.screenshotrun.com/v1/analyze \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yoursite.com",
    "include": ["interactive", "images", "headings", "forms"]
  }'

What "broken" looks like in coordinate data

A broken UI element is rarely missing from the DOM. It's there, in the DOM, with the right class name. Except it rendered in the wrong place, at the wrong size, hidden behind something else. Visual regression tools catch this by comparing screenshots pixel by pixel, which also catches every irrelevant change (ad rotation, timestamp update, avatar swap) and buries the real bug in a pile of false positives. Coordinate data lets you ask precise questions instead.

Zero-dimension elements. An image tag exists in the DOM, but its rendered size is 0×0. The src returned a 404. A CSS rule collapsed the container. JavaScript failed to set the dimensions. The analyze response shows "rect": [340, 520, 0, 0]. Anything with width or height at zero that shouldn't be zero is broken.

Off-screen elements. A button sits at x=-200 or x=1400 on a 1280px viewport. CSS overflow, a failed transform, a negative margin that pushed it sideways. The element passes every DOM check. It exists, it has the right class, it's not display: none. Nobody can click it though.

Overlapping elements. A cookie banner covers the checkout button. A promotional strip pushes the hero section down and the nav links overlap the content area. Two absolutely positioned elements compete for the same space. Bounding box intersection math catches all of these: if element A's rectangle overlaps more than 30% of element B's rectangle, something went wrong.

Below-the-fold drift. Someone adds a component above the pricing section. The "Start Free Trial" button that used to sit at y=620 is now at y=940, below the fold on most screens. Functionally nothing changed. Visually, conversions drop because the CTA requires scrolling.

import requests

def detect_broken_elements(url, viewport_width=1280, viewport_height=800):
    resp = requests.post(
        "https://api.screenshotrun.com/v1/analyze",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"url": url, "include": ["interactive", "images", "headings", "forms"]},
        timeout=60,
    )
    data = resp.json()["result"]
    issues = []

    all_elements = []
    for category in data.values():
        if isinstance(category, list):
            all_elements.extend(category)

    for el in all_elements:
        x, y, w, h = el.get("rect", [0, 0, 0, 0])
        label = el.get("text", el.get("tag", "unknown"))[:60]

        # Zero-dimension check
        if w == 0 or h == 0:
            issues.append(f"ZERO SIZE: '{label}' at ({x},{y}) renders {w}x{h}")

        # Off-screen check
        if x + w < 0 or x > viewport_width:
            issues.append(f"OFF-SCREEN: '{label}' at x={x}, outside viewport")

        # Below-fold check for interactive elements
        if el.get("tag") in ("button", "a") and y > viewport_height:
            issues.append(f"BELOW FOLD: '{label}' at y={y}")

    return issues

Overlap detection with bounding box math

Two rectangles overlap when their x-ranges intersect AND their y-ranges intersect. The overlap area divided by the smaller element's area gives you an occlusion percentage. Above 50%, the smaller element is effectively hidden.

This is how you catch the most common layout failure in production. Sticky headers that extend too far. Modal backdrops that don't dismiss. Cookie consent bars that sit on top of a form. Promotional banners covering buttons the visitor needs to click. The covered element still exists in the DOM. Click handlers are still attached. Selenium's isDisplayed() returns true. A real user still can't reach it.

def find_overlapping_elements(elements, threshold=0.3):
    """Find element pairs where one covers more than threshold of the other."""
    overlaps = []

    for i, a in enumerate(elements):
        ax, ay, aw, ah = a.get("rect", [0, 0, 0, 0])
        if aw == 0 or ah == 0:
            continue

        for b in elements[i + 1:]:
            bx, by, bw, bh = b.get("rect", [0, 0, 0, 0])
            if bw == 0 or bh == 0:
                continue

            # Calculate intersection
            ix = max(ax, bx)
            iy = max(ay, by)
            iw = min(ax + aw, bx + bw) - ix
            ih = min(ay + ah, by + bh) - iy

            if iw <= 0 or ih <= 0:
                continue

            overlap_area = iw * ih
            smaller_area = min(aw * ah, bw * bh)
            ratio = overlap_area / smaller_area

            if ratio > threshold:
                overlaps.append({
                    "element_a": a.get("text", a.get("tag", ""))[:40],
                    "element_b": b.get("text", b.get("tag", ""))[:40],
                    "overlap_pct": round(ratio * 100),
                })

    return overlaps

Run this against a page after deploy and you'll know within seconds whether a new banner is blocking the signup form. Percy would flag the layout change too, alongside forty other pixel differences from dynamic content. The overlap detector flags exactly one thing: element A is covering element B by 78%.

Broken images without crawling src URLs

The standard approach to finding broken images is crawling every src URL and checking for HTTP 404. That misses half the problem. Images break for reasons that have nothing to do with the URL returning 404. A Content Security Policy blocks the domain. JavaScript was supposed to swap data-src into src but the lazy-load library failed to initialize. A responsive <picture> element has no matching <source> for the current viewport. The image loaded fine but CSS set max-height: 0 on its container.

The analyze endpoint renders the page in real Chromium, same as a visitor's browser. If the image doesn't render, its bounding box reflects that. A 300×200 product photo that failed to load shows up as a 300×0 container or a 0×0 placeholder. The rect data tells you what the visitor actually sees, not what the HTML source promises.

def find_broken_images(url):
    resp = requests.post(
        "https://api.screenshotrun.com/v1/analyze",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"url": url, "include": ["images"]},
        timeout=60,
    )
    images = resp.json()["result"]["images"]
    broken = []

    for img in images:
        x, y, w, h = img.get("rect", [0, 0, 0, 0])

        if w < 10 or h < 10:
            broken.append({
                "src": img.get("src", "unknown"),
                "alt": img.get("alt", ""),
                "rendered_size": f"{w}x{h}",
                "reason": "collapsed or failed to load",
            })

    return broken

For e-commerce sites with hundreds of product pages, run this in a nightly batch. A single CDN misconfiguration can break images across thousands of pages. You'll find out from the coordinate data before customers start emailing support with phone screenshots.

CI/CD pipeline integration

The fastest path: a post-deploy step that analyzes your staging URL and fails the pipeline if any critical element is broken. Playwright scripts aren't needed. Baseline images aren't needed. Not even a browser binary in your CI environment.

# GitHub Actions: detect broken UI elements after deploy
- name: Check UI Elements
  run: |
    RESULT=$(curl -s -X POST https://api.screenshotrun.com/v1/analyze \
      -H "Authorization: Bearer ${{ secrets.SR_API_KEY }}" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "${{ env.STAGING_URL }}",
        "include": ["interactive", "images", "headings"]
      }')

    # Check for zero-size elements
    ZERO_SIZE=$(echo $RESULT | jq '[
      .result.interactive[], .result.images[]
      | select(.rect[2] == 0 or .rect[3] == 0)
    ] | length')

    if [ "$ZERO_SIZE" -gt 0 ]; then
      echo "FAIL: $ZERO_SIZE elements have zero dimensions"
      echo $RESULT | jq '[.result.interactive[], .result.images[] | select(.rect[2] == 0 or .rect[3] == 0)] | .[:5]'
      exit 1
    fi

    # Check CTA is above the fold
    CTA_Y=$(echo $RESULT | jq '[.result.interactive[] | select(.tag == "a" and (.text | test("free|start|signup";"i"))) | .rect[1]] | min')
    if [ "$CTA_Y" -gt 800 ]; then
      echo "FAIL: Primary CTA at y=$CTA_Y (below 800px fold)"
      exit 1
    fi

    echo "UI element check passed"

This adds about four seconds to your deploy pipeline. Compare that to Percy's SDK integration (install package, wrap test runner, configure baseline approvals, wait for cloud rendering) or running Playwright screenshot comparisons (install browser binaries, maintain reference images, tune diff thresholds per element). Those approaches work, but they're solving a bigger problem than most deploys create. Most deploys either work fine or break something obvious. The coordinate check catches the obvious breaks.

Monitor responsive breakpoints

The layout that works at 1280px can collapse at 768px. A flex container that wraps cleanly on desktop might stack elements in the wrong order on tablet. The sidebar pushes below the main content, overlaps the footer. Suddenly the contact form is unreachable. Send the same URL through the analysis at multiple viewports and compare the structural results.

def check_responsive_breakpoints(url, viewports=None):
    if viewports is None:
        viewports = [
            {"width": 1280, "device": "desktop"},
            {"width": 768, "device": "tablet"},
            {"width": 390, "device": "mobile"},
        ]

    issues = []
    for vp in viewports:
        resp = requests.post(
            "https://api.screenshotrun.com/v1/analyze",
            headers={"Authorization": f"Bearer {API_KEY}"},
            json={
                "url": url,
                "include": ["interactive", "images", "forms"],
                "width": vp["width"],
                "device": vp.get("device", "desktop"),
            },
            timeout=60,
        )
        data = resp.json()["result"]

        # Check interactive elements stay within viewport
        for el in data.get("interactive", []):
            x, y, w, h = el.get("rect", [0, 0, 0, 0])
            if x + w > vp["width"] + 10:
                issues.append(
                    f"{vp['device']}: '{el.get('text', '')[:30]}' "
                    f"overflows viewport at x={x}+{w} > {vp['width']}"
                )

        # Check forms are still visible
        if not data.get("forms"):
            issues.append(f"{vp['device']}: no forms detected (hidden at this breakpoint?)")

    return issues

One API call per viewport. The response tells you which elements overflow, which disappeared, which shifted below the fold. Screenshot comparison isn't needed for this, although you can add "with_screenshot": true to get visual proof alongside the coordinate data for the bug report.

E-commerce product page monitoring at scale

A missing "Add to Cart" button costs direct revenue every minute it's gone. A broken product image drops conversion rates. A collapsed description section means customers can't read what they're buying. These failures happen constantly on large catalogs. CMS updates push bad templates to a subset of pages. CDN cache invalidation breaks images in specific regions. Third-party review widgets fail, push the layout sideways. Nobody notices until the conversion rate dips.

Define the structural contract for a product page once. Then run it against every product URL in your catalog on a schedule.

PRODUCT_PAGE_CONTRACT = {
    "images": {
        "min_count": 1,
        "min_width": 100,
        "min_height": 100,
    },
    "interactive": {
        "required_patterns": ["cart", "buy", "add to"],
        "max_y": 900,  # primary CTA must be above this
    },
    "headings": {
        "min_count": 1,
        "h1_required": True,
    },
}

def verify_product_page(url, contract):
    resp = requests.post(
        "https://api.screenshotrun.com/v1/analyze",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"url": url, "include": list(contract.keys())},
        timeout=60,
    )
    data = resp.json()["result"]
    violations = []

    # Image checks
    imgs = data.get("images", [])
    visible_imgs = [i for i in imgs if i["rect"][2] >= contract["images"]["min_width"]
                    and i["rect"][3] >= contract["images"]["min_height"]]
    if len(visible_imgs) < contract["images"]["min_count"]:
        violations.append(f"Only {len(visible_imgs)} product images above minimum size")

    # CTA check
    cta_found = False
    for el in data.get("interactive", []):
        text = el.get("text", "").lower()
        if any(p in text for p in contract["interactive"]["required_patterns"]):
            if el["rect"][1] <= contract["interactive"]["max_y"]:
                cta_found = True
                break
    if not cta_found:
        violations.append("No Add to Cart button found above the fold")

    # Heading check
    headings = data.get("headings", [])
    if contract["headings"]["h1_required"]:
        h1s = [h for h in headings if h["level"] == 1]
        if not h1s:
            violations.append("Missing H1 (product title)")

    return violations

Point this at 5,000 product URLs overnight. At three seconds per page with a couple of concurrent workers, you'll finish in about two hours. The 200 free monthly requests cover a nightly sample of your top pages. Paid plans from $9/month handle the full catalog.

Third-party widget failures

Chat widgets, payment forms, review carousels, social proof popups. They all load from external JavaScript. Their CDN goes down, your page layout shifts. Their script throws an uncaught error, a whole section collapses. They push a breaking update without telling you. Your checkout flow breaks on a Friday evening. The widget either vanishes (leaving a gap) or fails to load properly (leaving a zero-height container that collapses a section).

You can't test third-party failures in staging because they work fine against the vendor's dev environment. These failures show up in production, unpredictably, at the worst possible moment. A scheduled analysis of your production pages catches these the moment they happen.

Look for elements that were present in yesterday's analysis but are missing today. Or elements whose dimensions dropped to zero. The diff between two structural snapshots taken 24 hours apart tells you exactly what changed, without the noise that pixel comparison would add from normal content updates.

def compare_snapshots(previous, current):
    """Compare two analyze results. Flag disappeared or collapsed elements."""
    changes = []

    prev_map = {(e.get("tag"), e.get("text", "")[:40]): e
                for cat in previous.values() if isinstance(cat, list)
                for e in cat}

    curr_map = {(e.get("tag"), e.get("text", "")[:40]): e
                for cat in current.values() if isinstance(cat, list)
                for e in cat}

    # Elements that disappeared
    for key in prev_map:
        if key not in curr_map:
            changes.append(f"DISAPPEARED: {key[0]} '{key[1]}'")

    # Elements that collapsed
    for key in curr_map:
        if key in prev_map:
            pw, ph = prev_map[key]["rect"][2], prev_map[key]["rect"][3]
            cw, ch = curr_map[key]["rect"][2], curr_map[key]["rect"][3]
            if (pw > 50 and cw < 5) or (ph > 50 and ch < 5):
                changes.append(
                    f"COLLAPSED: {key[0]} '{key[1]}' "
                    f"from {pw}x{ph} to {cw}x{ch}"
                )

    return changes

Why coordinate analysis catches what pixel-diff misses

Percy, Applitools, BackstopJS, Chromatic. All four answer the same question: "do the pixels match the baseline?" That's useful for catching visual regressions where the design changed unintentionally. But it generates noise for everything else. A live stock counter changed from "12 left" to "9 left." A user review loaded with a different avatar. An A/B test served variant B instead of A. The timestamp in the footer says Tuesday instead of Monday. Each one flags a diff.

Teams respond by masking dynamic regions. Percy has "percyCSS" to hide elements. Applitools has "ignore regions." BackstopJS has "removeSelectors." Every mask is a decision not to test that part of the page. And masks need coordinates that break when the layout shifts.

Coordinate analysis doesn't compare pixels. It checks structural facts. Is the checkout button present? Width greater than zero? Located above y=800? Does the product image render at a reasonable size? These facts don't change when an ad rotates or a counter ticks down. Those facts only change when something actually breaks.

Coordinate data won't catch a color regression (brand blue turned to brand purple) or a font swap (Helvetica rendered as Arial because the font file 404'd). For those, combine structural analysis with a screenshot. The same API call with "with_screenshot": true gives you both. Use coordinates for automated pass/fail assertions. Use the screenshot for human review when the numbers look suspicious.

Parameters that matter for broken-element detection

  • include — categories to analyze: headings, interactive, forms, images, text, navigation. For broken-element detection, interactive and images are the highest-value targets.
  • width — viewport width in pixels. Run at 1280 for desktop, 768 for tablet, 390 for mobile. Broken elements often appear at specific breakpoints.
  • device — desktop, mobile, or tablet. Sets viewport and user agent together. Device emulation catches responsive layout bugs.
  • delay — seconds to wait after page load. Set to 2-3 for pages where JavaScript repositions elements after initial render. Without it, you might detect "broken" coordinates that would have been correct one second later.
  • block_cookies, block_ads, block_chats — remove overlay elements before analysis. Useful when you want to check the page's own structure without third-party overlays confusing the results.
  • with_screenshot — capture a screenshot alongside the analysis. When the coordinate check finds a problem, the screenshot provides visual proof for the bug report.
  • scope — CSS selector to limit analysis to a specific section. Analyze just the header, just the product grid, just the checkout form.
  • stealth — bypass bot detection on sites that block headless browsers. Without it, you might analyze a Cloudflare challenge page instead of the real content.

The full parameter reference is in the Page Analysis documentation. Everything that works for page analysis and element coordinates applies here.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call