At Slack, we believe that designing an optimal keyboard experience is key to delivering a best-in-class product for all our customers. However, despite our design system components being individually accessible, we heard from keyboard users that we were still missing focus transitions in their end-to-end user experience. Non-sighted users who relied on a screenreader constantly found themselves confused about their whereabouts in the app due to lack of consistent and reliable focus movements as they navigated around.

Consider the example of a screenreader user who performs a series of actions like opening a thread, adding replies, adding reactions and deleting messages. Notice how the focus flows to the next actionable element at every step.

Gif that shows a good user experience when focus moves around seamlessly with every user action
Good keyboard user experience

Now consider the experience of that same user if focus movement fails at the first step of opening the thread. Once focus is lost, the user is unable to proceed.

Gif that shows a bad user experience when focus does not move correctly with every user action
Bad keyboard user experience

Our goal

Guide users when performing a flow using a keyboard. Any action performed with a keyboard should have the ability to hand off focus to the next action.

With this, customers using screen readers can become aware of state changes in the app (e.g. a menu opens or an entire view is changed) and can save them time by eliminating the need to navigate to the UI representing the next action.

The challenges

We have to account for a large number of interactions that could occur between different sections in Slack. One common use case is transitioning focus to a dialog when it opens and then managing focus after it closes. 

This can be challenging because:

  • The same dialog might be able to be opened from different buttons in the UI
  • When the dialog closes, focus might need to be transitioned to different places depending on the user’s action within the dialog. 

For example, if the user pressed esc, the focus might go back to the button that opened the dialog. But if the user takes a different action, then the focus might need to move elsewhere, such as a new UI that was revealed.

Additionally, as features in Slack keep evolving with time, it creates pressure on engineers to add bespoke solutions to move focus around in the app. These one-off solutions end up bloating the codebase and are hard to maintain and update over time.

On the technical side, because React re-renders the DOM in response to state changes, a state update could result in the previously focused element being removed from the DOM, in which case the focus is (by default) sent back to the body, resulting in the user having to “start from the top”. This puts more responsibility on engineers to think about where focus should go next.

Also, we had to think of a way to transition the keyboard focus only if the user was already in “keyboard mode” which is an internal user state that we would track to indicate to us that the user relies on a keyboard to navigate within the app. 

Considering all of the above challenges, building a common system was crucial; we wanted a standard way to empower feature teams to be able to move keyboard focus reliably with every user interaction.

The solution

We created a central component in React called FocusTransitionManager that wraps around the entire app and controls access to a central store using the React Context API. It is designed to be feature-agnostic and acts as a coordinator between components that need to move focus to other components, and for components that are listening for requests to set focus on themselves.

Similar to the browser pattern of using unique identifiers to set focus on DOM elements, we use unique identifiers on target components, so that they can receive instructions to set focus on themselves. In the code, we refer to this unique identifier as its focusKey and is used by every component that uses this system to uniquely identify itself in the app.

Diagram depicting communication between sending and receiving components using a central FocusTransitionManager component
FocusTransitionManager connecting sender and receiver components

Feature developers can decide which component to set the focusKey on in the component hierarchy, but the best practice is to set it closest to the component that has access to the refs that will be consuming focus in response. This will allow ref.focus() to be called in the same component when a focus request comes in. We created a simple naming convention internally when creating focus keys to avoid duplicate focusKey names from being created in the app.

Finally, to determine if users are in “keyboard mode”, we use internal heuristics to detect if the user is using a screen reader or if a focus event is preceded by a predetermined set of keys like “Tab” key or “Arrow” key to indicate that the user is navigating the app with the keyboard.

How FocusTransitionManager works internally

Let’s dive into the two API methods that are exposed by the FocusTransitionManager. The sender component, requesting that focus move somewhere, uses the following call: 

transitionFocusTo({ focusKey })
Flowchart of transitionFocusTo method setting focusKey and focusMetadata in context once all conditions are met
Code flow of the transitionFocusTo method

The receiver component, with the matching focusKey, has a check in the code to decide if it matches up using the following call:

shouldTransitionFocus({ focusKey })
Flowchart of shouldTransitionFocus method informing component to receive focus once all conditions are met
Code flow of the shouldTransitionFocus method

If the receiver’s check evaluates to true, then the FocusTransitionManager immediately cleans up the context data and the component can proceed to call ref.focus() in the code.

This API works for a simple use case of moving focus from A to B. But since apps are more dynamic than that, we extend the API to support some important use cases:

  • A blob called focusMetadata is used by the sender to set any additional information. 
transitionFocusTo({ focusKey, focusMetadata: {} })

Consider the use case where the sender wants to focus on a very specific message in the list (instead of the latest message in the list by default). 

focusMetadata: {
		channelId: '{channelId}',
 		msgTimestamp: '{msgTimestamp}'
    }

This can also be used for pseudo selector like values:

focusMetadata: {
		selector: 'lastVisibleMessage'
    }
  • A boolean called forceFocus forcibly moves focus even if a user is not a “keyboard user”. Consider the case where the composer view in Slack opens up with the input to compose a message. In such cases, focus always moves to the input.
transitionFocusTo({ focusKey, focusMetadata, forceFocus: true })
  • A function called additionalCondition is used by the receiving component to indicate to the FocusTransitionManager if it is not yet ready to receive focus. For example, consider a thread that is in the loading state because the replies are still being fetched. Here, the threads component needs to wait until the replies are available before shouldTransitionFocus evaluates to true.
shouldTransitionFocus({ focusKey, 
    additionalCondition: () => this.state.replies.length})  
{
    // Set focus using ref.focus() on the first reply
}

Apart from these extensions, the FocusTransitionManager is designed to reset the context variables to default values if a target is not found within a few seconds. This is achieved by attaching a timeout to every incoming request. Also, to avoid any concurrency issues, the FocusTransitionManager always prioritizes the focus request that is in progress and sees it to completion, ignoring any other focus requests that try to override the current one before it completes.

Impact of a central solution

Within just a couple of months, the FocusTransitionManager was adopted across 100+ user flows by feature teams. It was especially useful to integrate it at the site routing layer (i.e. when the URLs update to load or unload different sections in the app). When a URL change loads a new view, we pick up the focusKey associated with the new view and transition focus to the most likely action that the user is expected to take within that view. For example, say the composer view is being loaded in the primary view, we have one line of code at the routing layer:

transitionFocusTo({ focusKey: getFocusKeyForView(viewId)})

In this case, the primaryViewId points to the “composer” view, so when it opens up, the composer component automatically sets focus on the input since the next most likely action is that the user will type something in that input. This way, we are now able to scale this system to any views — such as Channels, All Threads, All Unreads, and Files — that are rendered in the primary section of the layout.

We were able to include this solution into our core design system’s components as well. For example, we used this solution for smart focus management in our Modal component to ensure that focus moves back to a reliable location in the app once the modal closes. Due to this change, all menus, popovers, and modals in Slack now have reliable focus movements regardless of the features using these components.

The success of this project has strengthened our belief that building central infrastructure to address accessibility challenges is powerful and scales well in large organizations that have many feature teams. The FocusTransitionManager is one such example of “Accessibility Systems” at Slack that designers and developers are able to leverage without worrying about the implementation details, and we are continuing to invest in more of these systems to help scale Slack’s accessibility features for all users.

This project would not have been possible without the support and contributions of the Slack Systems Team, especially Todd Kloots and Garrett Miller. Also, a special shoutout to the various feature teams at Slack who have helped us in this journey through their feedback and contributions.

Are you also excited about building Accessibility Systems at scale? If so, come work with us!