Brandon Dail
Brandon Dail
Senior engineer leading accessibility at Discord. React.js core collaborator once upon a time.
Accessibility at Discord
React Advanced Conference 2021React Advanced Conference 2021
22 min
Accessibility at Discord
Hey everyone, my name is Brandon Dale and I'm a software engineer at Discord working on the accessibility team. And today I'm here to talk about accessibility at Discord. This is going to be a pretty broad engineering focused talk where I just talk through a few of the more interesting problems we've worked on, the problem spaces around those, and then look at some of 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, who is our engineering manager. John is another engineer. San is a designer. Nick helps us out with marketing. Megan 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 the 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. Let me just go ahead and demo what it looks like in Discord keyboard navigation, just in case you're not familiar with Discord or 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, I 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. 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 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 really a good way to do that right now. Now there is this focus within pseudo class, which lets you say, apply a style if any descendant is focused. And if we enable this and click in here, you can see it sort of 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 blurple background, not really. 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 CSS color modules level 5 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. 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. 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. 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 were 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 provide it 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, et cetera. You can also use the within value to treat it sort of like a focus within, so the ring will render any time 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 rebrand, we had some complaints from accessibility users that the high saturation was a little difficult for them. So we wanted to make sure that there was an option to ensure that 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 then go to accessibility, you'll see the slider here at the top currently set to 100%, and you can drag it all the way 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%, 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 grayscale 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. 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 zero and one, 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 0, giving us a grayscale value, and at 100%, it will be 1, 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 0, we approach 1.5 for brightness. So we increase brightness by 50%. Now in light mode, we do the inverse. As saturation approaches 0, 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. 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 lists 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 going to 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 going to 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 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 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. 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 that 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 a really good article from Google 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? 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. And 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. So you'll see we don't do anything if our should run check constant isn't true. And then our starting point is with a mutation observer. So 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, right? Like 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 listen to all attribute, child list, and subtree changes. Now the first thing we do in this onMutation 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. 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. The next thing is run accessibility checks. And this is kind of the core logic. We check for the rule. We populate the DOM nodes for that rule if we haven't already. If there are no relevant DOM nodes, we just move on to the next rule. And then what we do is we do a little bit of smart scheduling logic. So this is taken from that is input pending blog post. And effectively what we do is we just take a current timestamp. We set a deadline for 16 milliseconds. And then we loop through the nodes. And if there is input pending, if we've seen that the user is trying to interact, or if we've hit our deadline, we break out of this loop. And then after this, we have some logic for rescheduling the next check. And then here we just have the actual logic for running the check. And then we do some hashing to make sure that we don't report the same issue too many times. So you can imagine that you may have a list that has maybe hundreds of the same element. We don't want to report that as 100 different issues. We can identify that these are probably the same instance of the same kind of element. Then after that, this is where we check to see if there is another node that we should check for this rule. And if we do, we schedule a new one. Otherwise we move on to the next rule. And that's basically it. All of those APIs put together give us a really flexible and performant runtime system for running these potentially expensive checks. Now, this is something that we do want to open source eventually, but it's also something that's currently a work in progress. So definitely keep an eye out for more on this. All right. Those are all the really cool projects I wanted to talk about. I really appreciate everybody listening. The last thing I wanted to call out is if you have any accessibility feedback, maybe you use some of our accessibility features. We do have an accessibility feedback form at this URL, dis.gd.a11y. We'd love to hear your feedback so we can make sure that we build the most accessible product we can. So thanks again.