In this article
I ran into a good one recently, so I am writing it up.
I have a chat widget that gets embedded into other people's websites. It runs inside an iframe, and inside that there is a hotline button — a single <a href="tel:..."> wrapped around an SVG icon. Simple enough that nobody would expect it to break.
It broke anyway. Tapping it on iPhone did nothing at all. Android and desktop dialled just fine.
It took me a while to realise that "tapping does nothing" was not one bug here. It was three different bugs stacked on top of each other. And the third one was not in the code at all.
Bug 1: the SVG icon was eating the tap
The original markup:
<a href="tel:19001234" class="call-button" aria-label="Call hotline 1900 1234">
<svg class="call-button__icon" aria-hidden="true">…</svg>
</a>
The icon covers almost the whole button, so most taps land on it rather than on the <a>. Safari treats the child SVG element as its own hit target: event.target comes back as <svg> or <path>, not the link.
With an https:// link this is harmless — the event still bubbles and the browser still navigates. But with non-web schemes like tel:, Safari is far pickier, and the tap triggers nothing at all.
The fix is one line, but it has to go in the right place:
/* Right: the icon is transparent to pointers, so taps land on the <a> */
.call-button__icon {
pointer-events: none;
}
Do not put pointer-events: none on .call-button itself — that kills the entire button.
The icon here is purely decorative, it already carries aria-hidden="true", and the link already has an aria-label, so nothing is lost in terms of accessibility.
After that fix I was fairly confident it was done. It was not.
Bug 2: WebKit blocks non-web schemes inside a cross-origin iframe
Still no call. But this time there was a very valuable clue that I nearly walked past:
Tapping does nothing, but long-pressing the button brings up a Safari menu with a "Call" item in it.
That sentence says something very specific: Safari understands that this is a phone number. It parsed the href, it knows what it is supposed to do. It is simply refusing to do it.
This is not a parsing bug, it is policy. WebKit blocks navigation to non-web schemes (tel:, mailto:, sms:, app schemes) when that navigation happens inside a cross-origin iframe. My widget runs on chat.example.com embedded into example.com — different origins, even though they share a root domain.
What cost me time was a comment already sitting in the code, claiming this could not happen:
// In the same frame Safari recognises the scheme and opens the dialer;
// the embedding page sets no `sandbox`, so iframe navigation is not blocked.
That comment is wrong. WebKit blocks based on the frame's origin; sandbox has nothing to do with it. But it was written confidently and its reasoning sounded plausible, so it sent me looking somewhere else for quite a while.
A wrong comment is far more dangerous than no comment. With no comment, people go read the code. With a wrong comment, people believe it.
Three target values, three different outcomes
This is the part I remember most from the whole episode. For a tel: link inside an iframe on iOS:
target | What happens |
|---|---|
_blank | Safari opens a blank tab and then has nothing to navigate to. The dialer never appears. |
| Omitted | The navigation stays inside the iframe, so WebKit blocks it. Tapping does nothing. |
_top | The navigation happens on the outermost frame, iOS picks it up and opens the dialer. |
The easy mistake: dropping _blank is correct, but it is not enough. Plenty of answers online stop at "don't use target="_blank" for tel:". True — but if you are inside an iframe, leaving it off is still broken. It has to be _top.
// Choose by SCHEME, not by channel name,
// because mailto: and sms: hit exactly the same problem
const props = isExternalScheme(url)
? { target: "_top" }
: { target: "_blank", rel: "noopener noreferrer" };
The usual worry: does _top navigate the parent page away? No. The operating system swallows the tel: scheme before it ever becomes a real navigation, and the parent page stays put. And when the widget is opened directly rather than embedded, _top is simply the current frame, so nothing changes.
Bug 3: the one that was not in the code
With _top in place I tapped again. This time there was a response — an error dialog:
Safari cannot open the page because the address is invalid.
At first glance that looks worse than before: from "nothing happens" to a red error. I nearly reverted the fix.
Luckily the screenshot had one telling detail: the debugger's title bar read VIRTUAL, along with the device name iPhone 15 Pro Max and version 17.5.
That is the iOS Simulator. And the Simulator has no Phone app.
With no app registered to handle the tel: scheme, all Safari can do is report that the address is invalid. On a real device, the exact same tap brings up a confirmation dialog and then dials.
This detail also explained a symptom I had misread from the very beginning:
"Anything from iOS 15.5 up fails to dial. Tried 17.5 too, same thing."
That sounds like a version bug. In reality every Simulator version behaves this way, because no version of it has a Phone app. When a symptom shows up across every version, the culprit usually is not the version — it is something the whole test environment has in common.
The final confirmation
Long-pressing the button in the Simulator brings up this menu:
1 (900) 232-343
├─ Send Message
├─ Add to Contacts
└─ Copy Phone Number
The "Call" item is precisely what is missing. On a real iPhone it is the first one in the list.
Put together, it closes the loop:
- Before the fix: silent tap, menu on long-press. Safari understood it was a phone number but deliberately refused to go — so it was being blocked.
- After the fix: tapping produces "address is invalid". The navigation reached the operating system; it is no longer being blocked.
- Long-press: the menu is missing "Call". The OS has no app to receive it.
All three point at the same conclusion: the code was already correct, what was missing was a device with a Phone app. That error dialog was not a regression — it was proof the fix had worked.
Symptom lookup table
If you have a tel: link that won't work on iOS, this is how to read the symptoms:
| Symptom | Likely cause |
|---|---|
| Tap does nothing, long-press shows no menu either | The tap never reached the <a>; suspect a child element eating it (bug 1) |
| Tap does nothing, long-press shows a complete menu | Safari understands but refuses to go; suspect a cross-origin iframe (bug 2) |
| Tap produces "address is invalid" | Navigation reached the OS but no app received it; suspect the Simulator (bug 3) |
| Long-press menu is missing the "Call" item | Almost certainly the Simulator |
| A blank tab opens and just sits there | You still have target="_blank" |
An invisible dependency worth knowing about
The _top fix only works because the iframe carries no sandbox attribute.
Add sandbox and the frame loses all of its default privileges, including the right to navigate the top-level frame. The call button dies immediately, with no error in the console. Tapping it simply does nothing.
This is exactly the kind of change someone will make six months from now for a perfectly good reason ("tightening up iframe security"), and nobody will connect it to the hotline no longer working. So I wrote a warning right where the iframe is created, along with the minimum set of flags you have to add back if you genuinely need sandbox:
<iframe
src="https://chat.example.com/widget"
title="Support chat"
sandbox="allow-scripts allow-same-origin allow-popups allow-top-navigation-by-user-activation"
></iframe>
Leave out allow-top-navigation-by-user-activation and the call button stops working.
Don't make it more complicated than it needs to be
Faced with the state of things at bug 3, the natural reflex is to catch the click in JS and push the tel: out some other way. Don't. Two reasons:
It does not fix the bug you are looking at. Every route ends at the same place: handing the tel: scheme to the operating system. The Simulator has no Phone app, so whether you go through window.top.location, window.open or a hidden <a> tag, you get the same error message.
It can also break the button on real devices. iOS only opens the dialer while the tap still counts as a user gesture. Moving to JS handling — especially with preventDefault or any asynchronous step in the way — makes it very easy to lose that gesture. You end up trading a button that "doesn't work in the Simulator" for one that "doesn't work on a real iPhone."
That is a terrible trade.
A plain <a href="tel:..." target="_top"> is already the right answer. Leave it alone.
Three things I took away
One symptom can be several bugs stacked together. Fixing bug 1 and still seeing the failure does not mean the fix for bug 1 was wrong. It is very easy to revert a correct fix simply because it did not make the symptom disappear.
Seeing an error is sometimes a good sign. Going from "nothing happens" to "an error dialog appears" usually means you have pushed the problem one stage further along. Read the error carefully before deciding to roll a fix back.
Always ask "real device or simulator?" before diagnosing. With schemes that open apps outside the browser, the test environment can be the culprit itself. And when someone reports that "every version is broken," suspect the environment before you suspect the version.
After all that, the final fix came down to two lines: one pointer-events: none and one target="_top". This entire post is the time it took me to find those two lines.
If you have hit a stranger tel: case than this one, email me at info@nguyenlap.net or reach me on Facebook — I would like to hear about it.