
In 2008, Apple’s Safari team had a problem: web developers had noticed that links, buttons, and several input elements were not matching the :focus
selector when clicked. Similarly, clicking on those elements did not fire a focus
or focusin
event. The developers dutifully submitted bugs against Webkit and were waiting for help.
That help never came.
The reason is that macOS didn’t transfer focus to operating system and application buttons when they were clicked and the stakeholders decided to be consistent with the operating system instead of with their competitors’ browsers. The discussions have lasted 13 years and are still ongoing, but the team at Apple is adamant: buttons, links, and non-text inputs won’t get focus.
Helpfully, the team agreed that a workaround would be to implement :focus-visible
, the CSS selector that matches elements that are awaiting input; they are focused but not activated. Clicking a button or link both focuses and activates it, but tabbing into them will match this focus-only selector. This partial solution would indeed help, but it stalled in April of 2021, three years after it was started and 11 years after it was suggested.
This failure has started to snowball. Safari introduced :focus-within
in early 2017, beating most of their competitors to market with this newly standardized pseudo selector. But it wouldn’t match any container when a button, link, or non-text input was clicked. In fact, if the user were focused on a text input (matching :focus-within
selector for its container) and then clicked on one of those troublesome elements, the focus left the container entirely.
Edit (8/29/2022)
Safari has implemented:focus-visible
in version 15.4. The bugs described in this article for:focus
still exist, though. Clicking any problematic element does not fire focus events, and does not match the:focus
or:focus-within
selectors.
It’s clear that Apple’s Webkit/Safari team are not going to fix this issue. There is an excellent polyfill available for :focus-visible
from the WICG. There is a good polyfill available for :focus-within
as well. But I have not found a polyfill for :focus
, so I wrote one:
Edit (1/22/2024)
I created a test suite and support matrix for all the different ways that an element can be made click focusable or rendered unfocusable (there are 108 test cases so far!). Safari is by far the least supported but even Firefox and Chrome/Edge had failing tests. Notably, no browser properly handles click focusability on the generated control elements for<audio>
and<video>
elements. Building out this test suite also showed that the previous polyfill I made was insufficient for all of the use-cases. I wrote a new, more comprehensive polyfill here.
Polyfill Breakdown
The heart of this polyfill is the Element.focus()
method call (on line #112 in the gist above) that dispatches the focus
and focusin
events. Once the browser sees these events associated with an element, this element matches the :focus
selector.
There are a few events we could listen to in order to dispatch that focus
event when the element is clicked: mousedown
, mouseup
, and click
.
A note on events
If you’re not familiar with how events work in the browser, there are two phases. The capturing phase starts at the topmost element,
<html>
, and proceeds down through the DOM towards the element that was clicked. The bubbling phase begins after the capture phase ends, and it starts at the lowermost element that was clicked and proceeds upwards (bubbles) toward the HTML element. Any node in this pathway can attach an event listener that is triggered when the event hits that node.
Any of these event listeners can stop the event from continuing and attach listeners for following events that could prevent them from firing. This is important. We don’t want some other event listener to prevent our listener from firing. That means we want to attach to the earliest event, mousedown
, and during the capturing phase. This is done on line #81 in the gist above.
However, Element.focus()
fires synchronously, meaning that the focus
and focusin
events would fire before the blur
and focusout
events. The correct order of events is:
- mousedown [targeting the clicked element]
- blur [targeting the previously active element]
- focusout [targeting the previously active element]
- focus [targeting the clicked element]
- focusin [targeting the clicked element]
- mouseup [targeting the clicked element]
- click [targeting the clicked element]
We can use a zero second setTimeout
call (line #56 in the gist above) to enqueue the Element.focus()
call after all of the currently queued events. This moves our forced focus
event after the blur
and focusout
events, but this also moves it after the mouseup
and click
events, which is not ideal.
To ensure that the mouseup
and click
events fire after our forced focus
event, we need to capture and resend them. We don’t want to capture every mouseup
and click
event, so we need to add the listeners only when we are going to force a focus
event (line #41–42) and remove them when we’re done (line #58–59). Lastly, we need to re-dispatch those events with all the same data as they originally had, such as mouse position (line #67).
Lastly, we need to only do all of this work in Safari (see line #80) and only when the unsupported elements are clicked. The wonderful folks at allyjs.io have provided a very useful test document for checking what elements get focus. After comparing the results of clicking each of these elements in different browsers, I found the following elements needed this polyfill:
- anchor links with href attribute
- non-disabled buttons
- non-disabled textarea
- non-disabled inputs of type
button
,reset
,checkbox
,radio
, andsubmit
- non-interactive elements (button, a, input, textarea, select) that have a
tabindex
with a numeric value - audio elements
- video elements with
controls
attribute
The function isPolyfilledFocusEvent
(line #22–36) checks for these cases.
I hope this helps.
And I hope Safari supports this stuff natively sometime.
EDIT (15/1/2022): Real-world production use of the polyfill has revealed two bugs. First, the redispatched events should be dispatched from the event targets rather than from the document. Events not fired from the previous target
are stripped of their target
property and do not trigger default behaviors like navigating on link clicks.

Second, the capturing of possible out-of-order mouseup
and click
events needs to be handled differently. By adding the listeners dynamically, they were being added after any listeners that were added after the polyfill runs. This means that the other listeners would trigger first before the event being captured, and then they would trigger again when the event was redispatched. The solution is to add the polyfill’s listeners for mouseup
and click
immediately, and toggle a capturing
flag on and off instead of attaching and removing listeners.


EDIT (21/1/2022): More production use has revealed another bug. Clicking on an already focused element should not fire another focus (or focusin) event. However, the polyfill was blindly firing a focus event on every click, or mousedown, of an affected element. To prevent this from happening, we check if the event target element is the same as the document’s active element before proceeding with the polyfilled focus behavior.
A side effect of not firing focus on every click is that Safari returns focus to the body element if it decides that the target element isn’t focusable. To stop this, we call preventDefault()
on the mousedown handler. (How did I know that would work? I didn’t. It took some trial and error to find out when and how Safari was moving focus to the body.)

EDIT (23/1/2022): Clicks on label elements should redirect focus to the labeled element. In order to detect that a click was occurring on a label, we have two choices: (1) on every mousedown
event that we are listening to, we could climb the DOM tree all the way to the body element to see if the event triggered on an element in a label, or (2) detect when the pointer is inside a label before the mousedown
event. Option 1 would work but it would be very expensive, especially on large pages. For option 2, we had to find how to detect the user was inside a label before the mousedown
event fired.
The event mouseenter
fires before the mousedown
event (even on touch devices!) and it fires for each element that the mousedown
event would bubble through. That means that each element has a mouseenter
(and mouseleave
) event that it is the target of. (Since we’re using delegated listeners, we cannot rely on currentTarget
because that would always be null.)
We added a function to get the redirected focus target when the user is inside a label element, and we use that element to detect if it already has focus and to focus on it if not.

EDIT (17/2/2022): We needed to account for possibly nested children in focusable elements that accept children, such as buttons, anchor tags or arbitrary elements with a tabindex
attribute. Since we were traversing the DOM looking for focusable parents anyway, we decided to remove the mouseenter
and mouseleave
listeners we were using for discovering focusable label
elements and fold that use case into the new strategy. We also discovered a bug in our check for the tabindex
attribute: the HTMLElement property tabIndex
reports a value of -1
if the attribute has not been explicitly set. The solution is to use .getAttribute('tabindex')
to determine if the value has been explicitly set.
