1. Introduction to Accessibility at Discord
I'm Brandon Dale, a software engineer at Discord, and I'll be talking about accessibility at Discord. Our first big project is keyboard navigation, which ensures users can perform all actions with a keyboard. This is crucial for accessibility and also helps with screen reader compatibility. Let me demonstrate Discord's keyboard navigation. First, I'll focus on the message input, then use tab to navigate through focusable elements. I can also navigate through messages and use shortcuts for actions like replying. One challenge we faced was implementing focus rings, which turned out to have many edge cases. Using the CSS outline property caused complications, especially when dealing with containers with focusable elements and overflow hidden.
I'm Brandon Dale and I'm a software engineer at Discord working on the accessibility team. I'm here to talk about accessibility at Discord. This is going to be a broad engineering focused talk where I talk through a few of the more interesting problems we've worked on, the problem spaces around those, and then look at the technical details of the solutions that we've built.
Now, before I do that, I want to give a shout out to the rest of my team because I'm definitely here on behalf of a lot of really great people who have done a lot of the work that I'm going to be bragging about. So just some quick introductions. Our team is Evelyn, our engineer manager, John is another engineer, Saan is a designer, Nick helps us out with marketing, Meghan is another engineer, and then of course myself, another engineer.
Now, the first big project I want to talk about is keyboard navigation. This was something that we did last year and it was a project with a really broad goal of just making sure that you can do anything you need to do in Discord with only a keyboard. This is a really important accessibility feature because there's tons of folks out there who either can't or struggle to use a mouse. And we also found that good keyboard support is a relatively good proxy of how a screen reader might work in certain cases, since both of them rely on navigating a cursor between focusable elements. So we got a lot of benefits in both of those cases.
So let me just go ahead and demo what it looks like in Discord keyboard navigation, just in case you're not familiar with Discord and you haven't used keyboard navigation. So here I have it running, and I just want to point your attention down to the bottom here, because that's where I'm going to get started. So I'm going to focus this message input, hit tab, and now you'll see this blue focus ring around the message input. If I hit tab again, I can move through other focusable elements, shift tab to move back. Let me go to a channel that has some messages. Let's do React Internals. You'll see my focus ring persisted because I used the keyboard to get to this channel. And if I hit up, I can move up through messages, down takes me down. I can use keyboard shortcuts while focused on messages like R to reply, and then I can use similar arrow key navigation and all the other lists that you can see here. So before I talk about the project, I just want to shout out this blog post from John. He did a lot of the work that I'm going to be talking about and he wrote up this really excellent blog post on the topic. So if you want to read a longer form version of this, highly recommend you check it out. It has a lot of really interesting details.
Now the first big part of keyboard navigation that I want to talk about is focus rings, because these are deceptively simple. It seems like something that should be relatively easy, but we found there's a lot of edge cases that make this really tricky if you want to scale it. Now, traditionally, this is something that you would implement with the CSS outline property. And this is something that we tried to do, but we found there were a lot of complications there that I want to run through real quick. So there were a handful of problems with using outline that we ran into pretty quickly. The first was that using overflow hidden on a container that may have focusable elements ran the risk of clipping the outline.
2. Challenges with Focus Rings and Outlines
If I tab to this button, you'll see the focus ring on all sides except for the leftmost, due to it being outside the overflow area. Browsers clip this focus ring, which can be worked around by being careful with margins and paddings. The outline property can only be applied to the target element, not its container. CSS lacks a good solution for this, as the focus within pseudo class applies the style to any descendant. Additionally, the outline property doesn't adapt to different background colors. A future solution being worked on is the color contrast function in the CSS color module's level five specification. The outline offset property only allows for uniform application, unlike margin and padding.
So I'll show you here. If I tab to this button, you'll see you can see the focus ring on all sides except for the left most. And that's because it's just outside that overflow area. And as it stands, browsers will clip that focus ring. This is something that you can generally work around by being more careful with margins and paddings. But we were using overflow for some other reasons, so it was a little tricky.
And we also just wanted to avoid the potential uphill battle of always having to fix, you know, uses of overflow hidden to avoid this. The next thing we ran into is that the outline can only be applied to the target element. So, the element that is being focused. So, if you look to the right here, we have a chat input that looks a lot like what we have in Discord, where we have an input and then a button, and they're all in this sort of logical container. And ideally, what we want is, when I focus this input, we don't want that focus ring to be applied to the actual input. We want it to be applied on that container with the black border. And if you look at Discord, this is what we do. But with CSS and the outline property, there isn't a good way to do that right now. There's this focus within pseudo class that lets you say apply a style if any descendant is focused. And if we enable this and click in here, you can see it gives us what we want, but the caveat is that it applies if any descendant is focused. So, if I hit tab to get out of the input, you'll see that the button is focused, and that focus ring on the container remains. So, this property just isn't granular enough for us.
Another thing is that this outline property cannot automatically adapt to different background colors. So you'll see here we have a button, and this is generally what we have, is a design system-level button that's used in a lot of different contexts. And if I tab to the first one, that blue focus ring looks pretty good. But on the next one with this blurble background, not really. You know, it's hard to see. And we wanted a solution where designers and engineers didn't have to always manually think about and apply different focus rings depending on the context. And with outline, that's just not currently possible. Now, there is something being worked on called the color contrast function in a CSS color module's level five specification, but that is still an early work in progress, and it doesn't have any browser support yet. But we're really looking forward to this when it lands, because this will help solve a lot of these focus ring color adaptation problems. And then this is sort of a smaller, more annoying one, the outline offset property, which is what you can use to sort of offset the outline from the target element either outwards or inwards. It can only take a single value, so you can only apply it uniformly. So, unlike margin and padding and border radius, it won't let you apply an outline that is different on any of the sides.
3. Evolving Browser Handling of Border Radius
We had cases where we wanted a more asymmetrical offset. However, browsers have now fixed the issue of rectangular outlines around circular buttons. This shows how we work around platform constraints while hoping for native browser support in the future.
And we had a couple cases where we want a more asymmetrical offset. Now, this last one here, outline does not respect border radius, this has been true for a long time, but if I tab to this button here, which has a rounded border, you'll see that it actually is respecting the border radius. And that's because this was actually fixed. When we wrote our solution, when we were working on this, that wasn't the case. You know, browsers would still always show this rectangular outline around circular buttons, but when I was writing this talk, I tested it out and lo and behold, browsers have solved this issue. So I think this is a really great example of how we're really trying to work around a lot of the platform constraints, but the platform is also still evolving. And hopefully one day some of the solutions that we've built here will be handled natively by the browser.
4. Unified Focus Ring System and Saturation Slider
We built a unified focus ring system that is easy to use and has worked well for us. You can apply the focus ring around an interactive or focusable element using the focus ring API. The focus ring scope serves as a reference point for positioning. To use the focus ring, wrap it around the desired element and configure it with props like offset, within, ring target, and focus target. Another project we worked on is the saturation slider, which allows users to desaturate colors in the desktop application. This option was added to address accessibility concerns. By adjusting the saturation slider, users can see the effects on the UI, including buttons, links, and custom color choices.
So that's the problems that we ran into. Now this is the solution that we built. So we built a unified focus ring system and you can see here we also open sourced it. So if you are interested in using this, it's really easy to use and it has worked really well for us. So let me show you what it looks like to actually adopt this.
So here's the example application from the focus rings repo. You'll see there's two primary imports, focus ring and focus ring scope. Focus ring is the main API and it's what you'll use to render the focus ring around an interactive or focusable element. And then focus ring scope is sort of a reference point for focus ring. So if we look at it here, all you have to do with focus ring scope is provided a container ref which points to a DOM element and that'll be used as a reference point for things like positioning to make sure that things like occlusion on scroll work as expected. So you'll want to put one of these at the root of your application, as well as anytime you make a scrollable container or absolutely positioned container.
Now, as far as focus ring, it's really easy to use. All you have to do is find the interactive focusable element you'd like to apply the ring to and then wrap it around. You can add some additional props to configure it like an offset property, which will work with either a singular number, just like outline offset, or you can provide it an object like top four, left three, etc. You could also use the within value to treat it sort of like a focus within. So the ring will render anytime any descendant is focused. And then there's props like ring target and focus target, which both take refs and let you configure which element the focus ring is applied to and which element we are listening to for focus events. So that gives us that behavior I was talking about earlier with targeting a different element than is focused like in our chat input example.
The next project I want to talk about is the saturation slider, which was a relatively recent project where we added the option to desaturate colors in the desktop application. This was added because after our re-brand, we had some complaints from accessibility users that the high saturation was a little difficult for them. We wanted to make sure there was an option to ensure their Discord experience was still comfortable. So let me show you how you can use the saturation slider and how it affects the UI. So if I come into Discord, open my settings and go to accessibility, you'll see the slider here at the top currently set to 100 percent and you can drag it down to zero. And there's some example UI below it that you can use to see how the saturation changes affect things. So if I bring this down to, say, 50 percent, you'll see that the buttons and the link and all that other stuff, the slider has become a little less saturated. And I can even bring it all the way down to zero to give you the sort of greyscale experience. So I'll bring this up to, say, 100. Then we have this option below, apply to custom color choices, where you can also apply this to things like role colors, which are colors that are user defined. When you create a role, you can pick your own color. So you'll see here where it says Ultron, it's this nice bright pink.
5. Role Colors and Saturation
And if I drag this down with that enabled, it also gets desaturated. But if I disable this, it remains that bright color. The interesting part about the role color issue is that these colors are user defined. Just changing the saturation isn't guaranteed to mean that it's going to look good. You could be introducing some really tough contrast issues. So let me show you how we implemented that. Initially our colors were just implemented as simple hex values. We translated them into the HSL format and applied a little bit of math. For user defined colors, things get trickier because of the contrast requirements. We also adjust a brightness value, doing different things between dark and light mode. In dark mode, as saturation approaches zero, we increase brightness by 50%. In light mode, we halve the saturation to 0.5. This gives us the behavior of lighter text on dark and darker text in light mode.
And if I drag this down with that enabled, it also gets desaturated. But if I disable this, it remains that bright color.
Now, the interesting part about the role color issue is that these colors are user defined. And just changing the saturation isn't guaranteed to mean that it's going to look good. You could be introducing some really tough contrast issues.
So you'll notice here that as I let me make sure that it's on. Yep. Turn it back on. As I adjust the saturation downwards, the colors of these roles in the right all tend to normalize just towards this more dark gray or black color. But if I go to the dark theme, you'll see that they've all sort of normalized to this whiter color. So we've applied an additional sort of level of logic to make sure that these remain readable. So let me show you how we implemented that.
So initially our colors were just implemented as simple hex values. So what we did is we translated them into the HSL format. If you're not familiar, that stands for hue saturation lightness and it lets you define a color in those terms. Now, instead of just using the default saturation value, we apply a little bit of math. So what we do is we multiply it by this CSS variable saturation. Now, this will be a value between 0 and 1, and it's what's controlled by that saturation slider. So when the slider is at 50%, this will be 0.5 and it will multiply that value by that. So, at 0%, this will end up being zero giving us a gray scale value, and at 100%, it will be one, giving us the original color. So that's all the logic that's required for applying saturation to our own colors.
Now, for user defined colors, things get trickier because of the contrast requirements. So what we do is we also adjust a brightness value, and we do different things between dark and light mode. In dark mode, as saturation approaches zero, we approach 1.5 for brightness. So we increase brightness by 50%. Now, in light mode, we do the inverse. As saturation approaches zero, we halve it to 0.5, so 50%. So what this gives us is the behavior where, in dark mode, things get lighter. And in light mode, things get darker. And that gives us the behavior of lighter text on dark and darker text in light mode.
6. Accessibility Improvements and Runtime Checking
To improve accessibility, we use the CSS filter property for desaturation, contrast adjustments, and brightness adjustments. We added support for keyboard and screen readers in drag and drop interactions. Our drag and drop system, built on React D&D, now has an open-source library called React D&D Accessible Backend for keyboard and screen reader support. We're also working on runtime accessibility checking to automatically find and report issues to developers without interrupting their workflow.
And to actually accomplish that, we use the CSS filter property, where first we apply the desaturation. Then we also apply some contrast adjustments with the same saturation value. And then, finally, we do the brightness adjustments, which gives us that final dark or bright color.
So the next thing I want to talk about is accessible drag and drop. Drag and drop is a pretty common interaction pattern in Discord where you can reorder different things. And up until very recently, you could only do that with the mouse. But this last quarter, we shipped an update adding support for both keyboard and screen readers to do the same thing. So let me show you what it looks like. So if I come back to Discord and I'll use my keyboard to navigate over to my server list in the far left, I can hit command D on a Mac, and you can see now that I can use the up and down arrows to move. So I'm gonna put this below here. Hit enter and it goes away. I can also do that with my channels. Let me get over to those. So if I hit command D, I'm gonna put general at the top and it should just work. So this should be true automatically for any drag and drop surface in Discord.
Our drag and drop system is built on top of React D&D, which has been a workhorse of drag and drop in the React community for a long time, but in recent years has atrophied a little bit in terms of accessibility and feature development. We explored migrating to a more modern solution like React Beautiful D&D, but ran into some issues due to the complexity of our current implementation. We tried to find some open source options that would let us get what we want with React D&D, but we just couldn't find them so we went ahead and built it. This is a library we just open sourced very recently called React D&D Accessible Backend. This was written by John, and it implements keyboard and screen reader support for React D&D. If you're not familiar with React D&D, it uses the concept of a backend to sort of encapsulate and handle the different native events, so this is something that you can use alongside the other, more established backends to get keyboard and screen reader support out of the box. You can find it at that link, so check it out if you are, you know, currently using React D&D and are looking to improve your accessibility.
All right. So, the last big project I want to talk about is an experimental one, and that is runtime accessibility checking. This is something we're currently working on and exploring, and we have a lot of excitement around, and I just want to show you what it might look like for us and then talk about the technical implementation of the sort of the core runtime system. Now, here is an example of a very early version that we've been working on, just to give you an idea of what I'm talking about. Keep in mind that this is completely internal-only, developer-facing. This isn't something a Discord user will ever see, but it gives you an idea of what we're talking about with runtime accessibility checks. We want to be able to automatically find and report accessibility issues to developers as they are working on the product. There are existing automatic accessibility checking systems out there, but they almost all require you to stop what you're doing, open up some developer tool, run it, and then wait for the results, and we wanted to remove that point of friction.
7. Core Runtime System and Mutation Observer
We're working on a performant core runtime system for checking accessibility issues. We disable the system if the Navigator.scheduling.isInputPending API is not available, which tells us if a user is interacting with the interface. We track module-level state and have a function to reset the state. Our starting point is a mutation observer that looks for interesting mutations at the root document and runs our check. We reset our state whenever a mutation occurs to ensure the validity of our checks.
Now, the challenge here is that we want this to run pretty much without getting in the way at all, so we need to be sure that we're not doing anything that's too intensive and that we're not dropping frames or anything like that, which is really difficult because we have to do a lot of different calculations to check for accessibility issues.
So, I want to talk about the core runtime system that we're working on and the idea behind how we can make it performant. So, here is a simplified view of the code that we're working on now for the runtime system. So, the first thing to note here is there would usually be a big list of accessibility rules, which define logic and behavior for querying the relevant DOM nodes and how to check the relationships we want to validate, but that's a lot of complexity that we're not really interested in here, so I've excluded them for now.
The first thing to note is this should run check constant. Now, what we do is we disable this entire system if this specific API is not available. Navigator.scheduling.isInputPending. This is probably one you're not familiar with because it's only implemented in Chromium. If you want to read about it, I highly recommend this URL, web.dev.isInputPending. It's really good article from Google who's working on it that explains what it is and why we want to build it into the platform. In a simple sense, what this does is it tells us if a user is currently interacting with the interface. So have they clicked a button? Are they typing? Are they hovering over something? And what we do is we use this to bail out of our runtime because what we're doing isn't really the most important work. We want the application to remain responsive. So this gives us a way to do that, and we disable it if it doesn't exist because it doesn't really matter. This is a developer-facing tool, and most of our developers are going to be at least checking how things work in Chrome at some point.
After that, we have some module-level state where we track things, like timeout IDs, and then which rule we're currently checking, which node for that rule we're looking at, the current set of nodes, and then the actual check that we're trying to build up. Now, this is all module-level because it's effectively global state. We only want one instance of this thing running. Then we have a function here to reset that state back to the initial values.
Now, here's the most interesting starting point, our register function. You'll see we don't do anything if our should run check constant isn't true. Then our starting point is with a mutation observer. What we want to do is effectively look for mutations, all interesting mutations, at the root document, and run our check when that occurs. Now, this is something that happens really frequently. Any DOM update from the root is going to trigger this. So, we have to have some more logic down the line to make sure that this isn't happening too much or isn't firing too often. But you'll see we listened to all attribute, child list, and subtree changes. Now, the first thing we do in this on mutation callback is reset our state. And that's because any time a mutation comes in, we can't be confident that it hasn't invalidated the checks that we've already done. We don't know if it's an attribute change or a subtree change that may have fixed or added new issues.
8. Debouncing and Scheduling Logic
To handle the debouncing behavior, we start from scratch and perform timeout checking. The schedule check function is debounced for about 250 milliseconds and called using request idle callback. This ensures that the check is only run when the DOM is in a quiet state with idle time available.
So, the easiest way to handle that is to just start from scratch. Then we do some timeout checking, which gives us debouncing behavior. And lastly, we just debounce the schedule check for about 250 milliseconds. And then what we do in this schedule check function is just call request idle callback. So, we have some debouncing. And then after that debounce eventually runs, we schedule it for the next idle period. So, this gives us a really good experience where we're probably not going to be running this until the DOM is sort of in a quiet state and there's some idle time that we can use.