DEV Community

baran
baran

Posted on

Screen size is not viewport size: the consent banner that kept hiding our call button

I maintain the website of Schädlingsschutz24, a German pest control service. For a business like that, a phone call is the conversion that matters, so every page with a hero section has a big call button near the top.

Since late September the site only loads analytics after the visitor opts in. The consent notice is fixed to the bottom of the screen and has two buttons of equal weight, Reject and Accept. You can probably see where this is going: on a phone, the notice and the call button want the same pixels.

I fixed that twice. Each fix passed the test I gave it, and each test was too small. Here is what each test missed, and the check that runs now.

Fix 1: one page, one phone

The first measurement was on the homepage at 375 × 812. The notice took up 28% of the screen, and the top edge of the call button sat at 590 px, inside it.

The tempting way out is to make the notice smaller by making it worse: cut the explanation, shrink Reject into a grey text link, keep Accept big. That trade wasn't on the table. The first layer says what is measured, that no cookies are used, where the data goes and that consent can be withdrawn, and it links to the details. Cutting any of that to win a few pixels defeats the point of asking.

So the fix only tightened what could be tightened. The sentences got shorter without losing content, the padding on small screens shrank, and the notice got padding-bottom: max(0.5rem, env(safe-area-inset-bottom)) for phones with a home indicator. Homepage at 375 × 812: button visible. Done, I thought.

Fix 2: every page type, four phones

Two weeks later I measured again at 360 × 740, this time on more than the homepage. At that size the notice is 175 px tall and covers everything below y = 565 px. On six page types (homepage, location pages, location-plus-service pages, service pages and two landing pages) the call button started somewhere between 587 and 937 px. All six were covered, including the homepage I had already "fixed". A 740 px screen is simply shorter than an 812 px one.

The location page hero reads in a natural order: breadcrumbs, a region badge, the H1, an intro sentence, three short trust points, then the buttons. On a desktop that's fine. On a narrow phone, everything above the buttons wraps into several lines and pushes them down.

The fix reorders the hero on small screens only. The buttons move directly under the H1, and the intro and trust points follow. These are scoped styles in an Astro component:

/* Location page hero: call buttons straight after the H1 on phones */
@media (max-width: 640px) {
  .lhero__inner          { display: flex; flex-direction: column; }
  .lhero__inner > .badge { align-self: flex-start; }
  .lhero__actions        { order: 1; margin-top: 1.2rem; }
  .lhero__lead           { order: 2; }
  .lhero__meta           { order: 3; }
}
Enter fullscreen mode Exit fullscreen mode

order changes the visual order, not the DOM order. Read MDN's accessibility notes on order before you use it like this. Keyboard focus follows the DOM, so moving a link visually can make focus jump around the page (see WCAG 2.4.3, Focus Order). Here, the two elements that moved below the buttons contain no links or controls. The focus order is unchanged: the call button is still the first focusable element after the breadcrumbs. A screen reader reads the intro before the buttons, which is the order the content was written in.

To put numbers on the change, I re-created the old order on two location pages by injecting the previous CSS. Both were measured at 360 × 740, where the notice starts at 565 px:

Page Call button before Call button after
Pest control in Stuttgart 673–730 px, under the notice 415–472 px, 93 px clear
Kammerjäger in Konstanz 673–730 px, under the notice 415–472 px, 93 px clear

The two rows match because both pages have a five-line headline at that width. That is not true of every page, which turned out to matter.

After the change I checked each page type at 360 × 740, 375 × 667, 390 × 844 and 412 × 915. All clear. Done, I thought.

The third round wasn't a fix. It was a better test

Every page, not every template

The template decides the structure, but the content decides the height. Location pages have different headlines, most of them written for that town, and at phone widths they wrap to anything from three to five lines. Breadcrumbs wrap too when the town has a long name. One page per template tells you about the layout. It doesn't tell you about the worst case.

Nobody is going to measure every page by hand, but the browser can. A same-origin iframe is its own viewport: media queries, position: fixed and font loading behave as they would at that size, and the parent page can read the layout directly. I run this in DevTools on the live site, with the notice showing (clear your stored consent choice first):

// Paste into DevTools on any page of the same site.
const VIEWPORTS = [[360, 740], [375, 667], [375, 549]];
// The call button inside the hero, which is the first section of <main> on our pages.
const HERO_CTA = 'main > section:first-of-type a[href^="tel:"]';

const xml = await (await fetch('/sitemap-0.xml')).text();
const pages = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname);

function measure(path, w, h) {
  return new Promise((resolve, reject) => {
    const frame = document.createElement('iframe');
    frame.style.cssText =
      `position:fixed;top:0;left:0;width:${w}px;height:${h}px;` +
      'border:0;opacity:.01;pointer-events:none;z-index:-1';
    frame.onload = async () => {
      try {
        const doc = frame.contentDocument;
        await doc.fonts.ready; // web fonts change where lines break
        const cta = doc.querySelector(HERO_CTA);
        if (!cta) return resolve(null); // this page has no call button in its hero
        const noticeEl = doc.getElementById('consent');
        if (!noticeEl) throw new Error('Notice not showing: clear the stored consent choice');
        const notice = noticeEl.getBoundingClientRect();
        const button = cta.getBoundingClientRect();
        resolve({ path, size: `${w}×${h}`, clearance: Math.round(notice.top - button.bottom) });
      } catch (err) {
        reject(err);
      } finally {
        frame.remove();
      }
    };
    frame.src = path;
    document.body.append(frame);
  });
}

const rows = [];
for (const [w, h] of VIEWPORTS) {
  for (const path of pages) rows.push(await measure(path, w, h));
}
console.table(rows.filter((r) => r && r.clearance < 20).sort((a, b) => a.clearance - b.clearance));
Enter fullscreen mode Exit fullscreen mode

Negative clearance means the notice overlaps the button. The loop runs one page at a time, so a full sweep of a mid-sized site takes a few minutes. An iframe is not a phone, though. There's no browser interface and no touch, and the next section is about exactly that gap.

At 360 × 740 every hero page passed, and the tightest location pages still had 66 px to spare. At 375 × 667, two location pages had the bottom 9 px of the button under the notice. Both have five-line headlines and a breadcrumb that breaks onto a second line, because the towns are Villingen-Schwenningen and Heidenheim an der Brenz. Almost a third of the location pages had less than 20 px left. That's not broken yet, but each of them is one longer headline away from it.

667 is not the height you get

375 × 667 is the iPhone SE's screen size. It is not what Safari gives the page. I booted an iPhone SE (3rd generation) simulator on iOS 26.5 and opened a viewport-size page in Safari. At first load the viewport is 375 × 549. The status bar and Safari's toolbar take 118 px, and the fixed notice sits on top of whatever is left.

At 549 px, the notice starts at 374 px. In the simulator, the location pages I opened showed the headline and then the notice, with the call button underneath it. On both pages from the table, the button sits at 417–474 px at that size. Re-running the sweep at 375 × 549 showed it isn't just a few pages. The button was at least partly covered on every location page and on most of the other page types.

DevTools device presets are screen sizes. They're fine for breakpoints, which depend on width. They're the wrong numbers for anything that depends on the height of the first screen, because on a first visit the browser's toolbars are expanded. On the CSS side, the small, large and dynamic viewport units (svh, lvh, dvh) exist for exactly this gap. This DEV post on the vh unit family and web.dev's article on viewport units explain the differences.

Where it stands

At 360 × 740 and 375 × 667 the sweep is nearly clean: two pages are 9 px short. On a small iPhone, the call button is still hidden on first load until the visitor answers the notice. The phone number in the slim top bar stays visible on every page, so this was never a dead end. But the big button is the one the page is built around.

The options for the next change all leave the notice as it is:

  • a call button in the sticky header on small screens, so the hero and the notice stop competing for the same pixels;
  • moving the hero's call button above the H1 when the viewport is short, with something like @media (max-width: 640px) and (max-height: 600px);
  • a shorter hero on short viewports, with a smaller H1 and no breadcrumb row.

Whichever one ships, it isn't done until the sweep passes at 375 × 549.

What I'd tell myself three weeks ago

  1. Test the page with the most content above the fold, not the template. Better still, test all of them. The script above is a few minutes of waiting.
  2. For anything that depends on the first screen's height, measure window.innerHeight in real browsers with the toolbars expanded. A simulator's Safari counts. Put those numbers in your test matrix instead of screen sizes.
  3. Write the size you tested next to the word "fixed". "Fixed at 375 × 812" would have told me exactly what was still open.

Disclosure: I maintain the site linked above, and this post was written with the help of AI. The measurements are from 27 September, 10 October and 19 October 2026. The iPhone numbers come from Apple's iOS Simulator, not a physical device.

Top comments (0)