How to build an accessible chatbot

Posted:
Tagged with:

Intro

Pretty much everywhere we go on the web these days, we'll be greeted by some form of chat interface and their presence is showing no signs of slowing down. They're kind of like the self-service checkouts in supermarkets, in that they weren't quite as common when organisations had to actually pay people to be on the other end of these things, but now, as "AI" is seemingly everywhere, and organisations have discovered they don't have to pay people to answer questions, boom, they're popping up everywhere.

I'm not going to delve too deeply into my opinions on AI, sure, it has its uses in some scenarios, but ultimately it's not something I'm overly keen on, I'd much rather speak to a person than get a response devoid of human characteristics. I also prefer my responses to be accurate, not "best matched" based upon wherever the "AI" scraped that response from. Anyway, this isn't an article about AI, not all chatbots are AI, most chatbots are in fact, just poorly marked up, which presents a huge barrier for folks with disabilities.

I recently tested a chatbot and whilst I have no doubt the backend stuff is highly complex, particularly if it's just a computer answering the questions, I built myself a tiny prototype, just to explore the difficulty and effectiveness of my suggested solution. It wasn't overly difficult, but then I really did only build an ultra basic prototype, without styles and missing many bells and whistles. Obviously I always start any task with "How can I make this accessible?" mindset, and when I factor that in from the start, I personally find it easier,

This isn't going to be an advanced chatbot, I don't personally see the point in me wasting endless hours getting it to scrape info from other sources, so if you've stumbled across this article in the hope of finding out how to build an AI chatbot, that bit is not included, but be like everybody else here, and learn how to build an accessible chat interface. So, just to be clear, we are going to build a chatbot, which functions as it should and it should function in a way that works for everybody, it won't however, be very smart, I'm only going to have answers to a few pre set questions and a suggestion for everything else. I'll be honest here, I have absolutely zero idea how to account for certain aspects of language, typos, spelling mistakes and anything else that could result in you and me having the same question, but a different way of asking it. That's also not something I'm. going to explore as, well, the interface is my bit, I'll just fudge a few silly questions and answers as a proof of concept.

Because I felt like I needed a theme or something, for my chat bot, and I like to be a little different, were not going to be asking about fruit or cats, I'll theme it a little based upon the Terminator movies franchise, just because it seemed fun to do that, with my fake AI chatbot.

So, a chatbot typically comprises of a trigger button located in a distant corner of the screen, once clicked, a popup or dialog appears with all of the chat's UI, and seldom are they as simple as an input, a button and a chat pane, they often have a couple of other controls, for additional features. They also often have rich responses, which can take the form of interactive elements that a user may interact with, perhaps buttons, with suggested quick questions or thumbs up and thumbs down things. I'll add the thumbs up/down things, as in the wild, we're often training AI by using these, and that's what makes them so common, I guess.

Want a chatbot primer?

My colleague, Marufa has done a great case study, delving into several popular chat applications and outlining some common issues.

We need a proper page to put it on

I rarely build a page to put my demos on, but on this occasion, I feel it's kinda important, as a chat trigger button is usually in a bottom corner and last in the sequential focus order, so, it can take a massive amount of effort to get there. Sure, tabbing in reverse is a thing, but not everybody can see the chat trigger, so they may only discover it right at the end, or not at all. Also, why make folk go up in reverse? I know there are shortcuts to get back to the address bar and stuff, which would obviously save having to reverse tab through all of your bookmarks and extensions and what not, but still, it doesn't hurt to add a couple of easy ways to get to the chat, does it? The page is just going to be fluff, there will be links, they won't go anywhere (other than back to the top of the page), it'll just be nonsense and only exists as a placeholder page to pop the chat on. I'm not going to include the code for this, as it's largely pointless.

I have added a "Skip to chat link" at the top of the page, it acts exactly the same as a skip link, in fact, it is adjacent to one, but it is just a useful shortcut to the chat trigger.

Let's build the trigger button

No progressive enhancement on this one I'm afraid, sure we could get the chat to open and stuff, but then what? Maybe it's possible with some fancy backend stuff? I honestly don't know, that's not my wheelhouse, I just build the frontend stuff, if a backend ninja tells me they can do it without JS, then great. in any instance I would of course provide an alternative contact method, so phone, email, socials, whatever and the user can choose one of those ifthe backend wizardry isn't possible.

We'd have position: relative; on our <body> element, that's all we need to stick this in a bottom corner, so let's get the HTML written.

<footer>
<!-- footer stuff -->
<button class="chat__trigger" id="chatTrigger" accesskey="9" aria-haspopup="dialog" aria-expanded="false" aria-controls="chatPanel">
<svg class="chat__trigger-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4.016 17.84c.316.32.476.758.433 1.203a16.33 16.33 0 0 1-.601 3c2.097-.484 3.375-1.047 3.953-1.34.328-.168.71-.207 1.062-.11 1.024.27 2.078.41 3.137.407 5.996 0 10.5-4.21 10.5-9S17.996 3 12 3 1.5 7.215 1.5 12c0 2.203.926 4.246 2.516 5.84m-.739 5.86c-.355.07-.71.132-1.07.19-.3.051-.527-.261-.406-.542.129-.313.254-.633.363-.953l.004-.016c.371-1.078.676-2.32.789-3.48C1.113 17.054 0 14.64 0 12 0 6.203 5.371 1.5 12 1.5S24 6.203 24 12s-5.371 10.5-12 10.5a13.374 13.374 0 0 1-3.52-.46c-.78.394-2.46 1.112-5.203 1.66"/>
</svg>
<span class="chat__trigger-label">Chat</span>
</button>
</footer>
<span class="message__container visually-hidden" aria-live="polite"></span>

Naturally, we want a button, as it will do button things, as opposed to link things, I'll just go over the bits I have done:

Rationale for aria-haspopup

As I build things "mobile first" and consider the cramped screen "real estate" on mobile, I have not gone for a floating round button in a bottom corner, I have opted for a chat button that occupies the full width of the screen, fixed to the bottom. This "design" is relatively common, but not as common as the floating button. I added an extra visual affordance, a chevron, to indicate the panel will popup. The panel is a `\`, so my use of \`aria-haspopup="dialog"` is legitimate, here.

The spec says the attribute SHOULD only be used if there is a visual indicator, and it includes chevrons as an example.

Now, when we view on the larger viewport, I do not show the chevron and that is a choice that I made for aesthetic purposes. Taking the wording of the ARIA 1.2 and 1.3 spec "If there is no visual indication that an element will trigger a popup, authors are advised to consider whether use of aria-haspopup is necessary, and avoid using it when it's not.", I am advised to consider whether it is necessary, on larger screens, here is my justification:

  • The button itself is functionally identical, it does not operate any differently at all, on any viewport, it just looks a little different
  • Across all viewports, the chat panel is functionally the same, it expands/pops up, it has the same roles, states and properties, they have small visual differences, but the differences stop there
  • The chevron on a smaller screen is an affordance, a visual cue that informs a user that something will pop up, as the visible label is "chat", it will be clear that a chat interace will expand and/or popup into view
  • The absense of a chevron on a larger screen does not actually remove this visual affordance, in my view, this is because the floating button exists on a layer which is on top of all primary content layers, it has a small shadow to appear elevated, it is fixed to the bottom-right corner, content can scroll underneath it and it contains a chat icon, in a circular button. Chat interfaces are common, users know and expect that these things will pop up and/or expand above everything else, in the top most layer. The affordance may not be as explicit as a chevron, but it is strongly implied and in my view, therefore, expected
  • Consistency is a fundamental aspect of accessible web development, controls should have the same names across pages, links should be clear and consistently named, help should be consistently placed across screens, and much more. Not every screen reader user is blind, some low vision users will adjust the zoom level on a per site basis, sites with a smaller base font will be zoomed more, sites with a larger base font may be zoomed less. We would be providing two different sets of cues for 2 identical use cases if I stripped out the aria-haspopup property on larger screens. A user that is audibly provided that information on their laptop, at home, who then accesses later, on their phone receives an inconsistent experience, for two views that are functionally identical. This could be further complicated if the user zooms in higher on one page, then zooms out more on another, they then access the control and are given completely different ARIA information, yet nothing has actually changed
  • I've added aria-expanded and aria-controls as the expanded state is ordinarily required with the aria-haspopup property and the aria-controls property is too. We could make that assumption from comboboxes and listbox menu items, etc

I'll never be as smart as some of the folk that work on standards and specs, and I'm cool with that, I'll just continue to learn and always do my best. The spec doesn't really consider my implementation, in fact, it can't account for every possible pattern. Should I be alerted to the fact a screen reader user or ideally, several screen reader users stated my usage of this is not helpful, I'd hold my hands up and make it better, equally, I would do so should an ARIA purist pull me up on it. That would of course beg the question, why does the dialog value even exist? I believe my implementation is correct, I researched it, a lot, I read the thread I mentioned, ARIA 1.2 and 1.3 and further searched, I tested and honestly, without personally knowing any screen reader users, I'm unsure what else I could do to validate my usage of the property, at this stage. It is only a recommendation, we haven't failed anything, not that is ever the most important aspect of accessibility, the most important bit to me, is always people.

If you use those attributes, please do so with a little caution, if you think I am wrong and wish to school me, please do get in touch, our email address is at the bottom of each page.


I'm not going to provide the CSS, here and my justification for that is I made the mistake of not planning out the component. I changed many things, added multiple new features and generally made a mess of my CSS, so I don't want to show it off. I say that, but there will of course be a CodePen and all of the code is there, even though it isn't pretty.

Let's make the chat panel

Straight into the HTML, here, as I am aware I waffle:

 <dialog class="chat__panel" aria-labelledby="chatTitle" id="chatPanel" data-clean>
<div class="chat__upper">
<h1 class="chat__title" id="chatTitle">AISHA Chat</h1>
<button class="chat__close-btn" id="chatCloseBtn"><span class="visually-hidden">Minimise</span></button>
<button class="chat__delete-btn" id="chatDeleteBtn" aria-controls="confPanel" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M10.398 14.398H12V27.2h-1.602ZM15.2 14.398h1.6V27.2h-1.6ZM20 14.398h1.602V27.2H20Zm0 0" style="stroke:none;fill-rule:nonzero;fill-opacity:1"/>
<path d="M26.398 3.2H20V1.601C20 .715 19.285 0 18.398 0h-4.796C12.715 0 12 .715 12 1.602v1.597H5.602C4.715 3.2 4 3.918 4 4.801V8c0 .883.715 1.602 1.602 1.602v20.796c0 .887.714 1.602 1.597 1.602h17.602c.883 0 1.597-.715 1.597-1.602V9.602C27.285 9.602 28 8.882 28 8V4.8c0-.882-.715-1.6-1.602-1.6ZM13.602 1.601h4.796v1.597h-4.796ZM24.8 30.398H7.199V9.602h17.602ZM26.398 8H5.602V4.8h20.796Zm0 0" style="stroke:none;fill-rule:nonzero;fill-opacity:1"/>
</svg>
<span class="visually-hidden">Delete chat</span>
</button>
<fieldset class="chat__confirm-panel" id="confPanel">
<legend>Are you sure you want to delete this chat?</legend>
<button class="chat__confirm-btn" id="confirmYes">Yes</button>
<button class="chat__confirm-btn" id="confirmNo">No</button>
</fieldset>
</div>
<div class="chat__window" role="log" aria-labelledby=”cLog” tabindex="0">
<h2 class="chat__window-title" id="cLog">Chat log</h2>
<div class="chat__bubbles"></div>
</div>
<div class="chat__lower">
<label class="chat__input-label" for="chatInput">Ask me anything...</label>
<div class="chat__input-wrapper">
<textarea type="text" id="chatInput" class="chat__input"></textarea>
<button type="button" class="chat__submit" id="sendQ"><span class="visually-hidden">Send</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
<path d="M10.648 29.523a.517.517 0 0 0 .809.426l3.563-2.43-4.372-2.085ZM31.832 2.098a.517.517 0 0 0-.578-.086L.512 17.094a.912.912 0 0 0-.512.828.922.922 0 0 0 .52.82l8.105 3.86 16.2-13.317L10.632 23.56l10.094 4.808a.908.908 0 0 0 1.242-.488l9.996-25.211a.513.513 0 0 0-.133-.57Zm0 0"/>
</svg>
</button>
</div>
</div>
</dialog>

Quick explainer:

Warning: If you are brave enough to delve into my messy CSS, you will discover I have used the "field-sizing:" CSS property, which increases the height of the input, when new lines of text are added, up until the hard limit I set on the element itself. This is to save me adding that functionality with JavaScript, it does not work in Firefox or Safari, at this moment in time (no surprises there), it is available in Safari Technology Preview, at the time of writing, so presumably, it will be supported in regular Safari before the next mass extinction event

Just because I'm quite dull, I called our chat widget AISHA, as yup, you guessed it, it begins with AI. I ddn't put too much thought into this, but Artificial Intelligence Should Have Accessibility was the best I could come up with. I know it's not the AI that should have it in this case, just the widget, but I felt like I had to have some thinly veiled dig at the majority of these widgets.

The base JavaScript

I'm going to do this a little differently, in that I will put the core JS here, the stuff that gets it to open and closes it, but there really is little point in me hogging most of this page with redundant JS and even CSS, as I have done a lot of "spoofing", by just making it work like a chat widget, so that code is pretty useless outside of this demo.. It will of course all be on the CodePen, as there will be a functional example. For my purposes, this is literally proof-of-concept/prototype, my code is messy, I'd build it much better next time, I would of course plan it out, which makes building it a lot more logical. Anyway, here's the core bits of code:

// Some variable for reuse

const chatTrigger = document.getElementById('chatTrigger');
const chatPanel = document.getElementById('chatPanel');
const questionInput = document.getElementById('chatInput');
const msgBox = document.querySelector('.message__container');
const chatWindow = document.querySelector('.chat__window');
const chatLabel = document.querySelector('.chat__trigger-label');
const chatCloseBtn = document.getElementById('chatCloseBtn');
const chatDeleteBtn = document.getElementById('chatDeleteBtn');
let typingTime = 3000;
let aishaTyping = false;
let cIdx = 1;
let disableSend = false
let currTime = new Date();
let screenWidth = window.matchMedia('(width <= 39.99em)');
let smallViewport = screenWidth.matches;


// Handle the clicks on the trigger, delegate to the open or close function
// based upon the crrent state of aria-expanded
chatTrigger.addEventListener('click', () => {
if (chatTrigger.getAttribute('aria-expanded') === 'false') {
openChat()
} else {
closeChat()
}
})

// Open the chat, if it's the first time, call the welcome() function
// Check for a notification, if it's there, remove it
const openChat = () => {
chatTrigger.setAttribute('aria-expanded', 'true');
chatPanel.show();

questionInput.focus();
document.body.setAttribute('data-chat', 'open');
msgBox.innerText = '';
if (chatWindow.hasAttribute('data-notification')) {
document.querySelector('.chat__badge').remove();
chatLabel.innerText = 'Chat';
}
if (chatPanel.hasAttribute('data-clean')) {
welcome();
}
}

// Close the chat
const closeChat = () => {
chatTrigger.setAttribute('aria-expanded', 'false');
document.body.setAttribute('data-chat', 'closed');
chatPanel.close();
}

chatCloseBtn.addEventListener('click', () => {
closeChat();
})

// Handle clicking on the delete button, which just shows the confirm actions
chatDeleteBtn.addEventListener('click', () => {
chatDeleteBtn.getAttribute('aria-expanded') === 'false' ? chatDeleteBtn.setAttribute('aria-expanded', 'true') : chatDeleteBtn.setAttribute('aria-expanded', 'false')
})

// Handle the confirm buttons and call the deleteChat() function if Yes was clicked
document.querySelectorAll('.chat__confirm-btn').forEach((btn) => {
btn.addEventListener('click', (evt) => {
if (evt.target.id === 'confirmYes') {
deleteChat();
}
chatDeleteBtn.setAttribute('aria-expanded', 'false');
chatDeleteBtn.focus();
})
});

// Delete everything, set chat back to initial state
const deleteChat = () => {
chatBubbles.innerHTML = '';
cIdx = 1;
questionInput.value = '';
questionInput.focus();
questionSendBtn.removeAttribute('disabled');
disableSend = false;
aishaTyping = false;
chatWindow.removeAttribute('data-rich-response');
chatPanel.setAttribute('data-clean', '');
welcome();
}

// Listen for any changes to the viewport size, we are only interested in large or small
screenWidth.onchange = (evt) => {
if (evt.matches) {
smallViewport = true;
} else {
smallViewport = false;
}
}

// If a user is viewing on a smaller viewport, the panel covers the screen, so after a small
// delay, I'm auto closing the panel if they tab out, but only on smaller screens
chatPanel.addEventListener('focusout', (evt) => {
if (document.body.getAttribute('data-chat') === 'open' && smallViewport) {
if (chatPanel.contains(evt.relatedTarget)) return;
setTimeout(() => {
closeChat();
}, 1500);
}
})

// Close the panel if a user presses Esc
// if there focus is inside the panel when they press Esc, move it to the trigger, else, leave it wherever it is
window.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && document.body.getAttribute('data-chat') === 'open') {
if (document.activeElement.closest('.chat__panel')) chatTrigger.focus()
closeChat();
}
})

Functionality

Styling considerations

I'll just summarise what I have done with styles, as the file ended up unwieldy, so again, the actual file will be on the CodePen.

Mobile considerations

We used the <dialog> element to create a non-modal dialog and I explained my reasoning for that, as it's useful to be able to interact with the page whilst communicating with the chat agent or the computer impersonating an agent. I mentioned earlier that this isn't really feasible on a mobile, as there simply isn't enough screen to display the widget and the page, together. Obviously we know that "mobile" is just a common term for smaller viewport size and we know that users on larger displays may zoom the viewport and trigger the "mobile" view. As our widget will cover the whole screen up to a certain breakpoint, what value does it have if it remains open, when a user tabs out? I can't think of any, so I have implemented an auto-close feature, only when the screen is "small", because otherwise a sighted keyboard user isn't going to be able to track keyboard focus and they'd need to close it to interact with the page, anyway. i do this after a small 1.5s delay. I'm definitely not precious, here, maybe there is a better way? This only happens when we detect the viewport as being "small", as it makes sense to leave it open on larger displays.

The CodePen

This has taken a significant amount of time and research, I've looked at dozens of chat widgets, tested with screen readers and other AT, and I failed to plan, so I found myself shoehorning functionality in, adding new stylistic elements and honestly, my code feels pretty gross, I'm ashamed, but at this stage, I cannot justify refactoring it all, as I have several tasks on my backlog, unrelated to MTA. That being said, this is a primer, it's intended to open discussion between teams, and provide a decent starting point for a better implementation, I have explained how some things would not work in many situations and there is definitely no one-size fits all approach for chat widgets, the internal structure and semantics will vary depending on application.

It will likely be possible to break this, or get it acting a little odd. So, so you can test, I will provide the questions that are set to provide a response, each is case insensitive and the question mark isn't required:

Wrapping up

Well, we will just file this one under "We had a stab at it" We got a lot correct, we had to make one or two major assumptions along the way and we also had to limit the functionality a little, in order to demo it. This wasn't because it is too complex, it's because we would need to have a more advanced model for messages, we would need to allow continuous exchange, allow a barrage of questions and then store those and answer each one in sequence. That isn't overly difficult, but it would be time-consuming and also, it would make my code more awful (if that's possible). I would really need to refactor this, before I added any additional complexity, and hopefully I can come back to this at some point and give this a good tidy up.

The core functionality of this works well, that was the easier part, as we didn't attempt to interpret questions to allow for some marging of language differences, our attempt is quite limited and flaky. We also limit it to a turn-based exchange, a choice I made to just demo something that works for one particular use case, but the use of landmarks would not be ideal for anything without such a limit.

We added a small delay to responses, to show the typing indicator and announce that the agent is typing, we could of course have just sent them straight away, but typing indicators are conventional on even AI chats, as they like to give off human characteristic vibes, for whatever reason.

So, please do only take this example as a starting point, there are kinks to iron out, discussions to be had and of course, above all, user testing with those folks who matter most

Share on:

TwitterLinkedIn

Site preferences

Please feel free to display our site, your way by finding the preferences that work best for you. We do not track any data or preferences at all, should you select any options in the groups below, we store a small non-identifiable token to your browser's Local Storage, this is required for your preferencesto persist across pages accordion be present on repeat visits. You can remove those tokens if you wish, by simply selecting Unset, from each preference group.

Theming

Theme
Code block theme

Code theme help

Code block themes can be changed independent of the site theme.

  • Default: (Unset) Code blocks will have the same theme as the site theme.
  • Light 1: will be default for users viewing the light theme, this maintains the minimum 7:1 (WCAG Level AAA) contrast ratio we have used throughout the site, it can be quite difficult to identify the differences in colour between various syntax types, due to the similarities in colour at that contrast ratio
  • Light 2: drops the contrast for syntax highlighting down to WCAG Level AA standards (greater than 4.5:1)
  • Dark: Syntax highlighting has a minimum contrast of 7:1 and due to the dark background differences in colour may appear much more perceivable

Motion

Motion & animation

Motion & animation help

  • Default (Unset): Obeys device settings, if present. If no preference is set, there are subtle animations on this site which will be shown. If you have opted for reduce motion, smooth scrolling as well as expanding and collapsing animations will no longer be present, fading transtitions and micro animations will still be still present.
  • None: All animations and transitions are completely removed, including fade transitions.

Links

Underline all links

Underline all links help

  • Default (Unset): Most links are underlined, with a few exceptions such as: the top level links in the main navigation (on large screens), cards, tags and icon links.
  • Yes: Will add underlines to the exceptions outlined above, resulting in every link being underlined

Text and paragraphs

Font size (main content)

Font size help

This setting does not apply to the site's header or footer regions

  • Default (Unset): Font sizes are set to site defaults
  • Selecting Large or Largest will increase the font size of the main content, the size of the increase depends on various factors such as your display size and/or zoom level. The easiest way to determine which option suits you best would be to view this text after clicking either size's button
Letter spacing

Letter spacing help

  • Default (Unset): Default letter spacing applies
  • Increased: Multiplies the font size by 0.12 and adds the sum as spacing between each character
Line height

Line height help

  • Default (Unset): all text has a minimum line height of 1.5 times the size of the text
  • Increased: all text has a line height of twice the size of the text
Line width

Line width help

  • Default (Unset): all text has a maximum line width of 80 REM units (this averages around 110 characters per line)
  • Decreased: all text has a maximum line width of 55 CH units (this averages around 80 characters per line)
Paragraph spacing

Paragraph spacing help

  • Default (Unset): The space between paragraphs is equivalent to 1.5 times the height of the paragraph's text
  • Increased: The space between paragraphs is equivalent to 2.25 times the height of the paragraph's text
Word spacing preference

Word spacing help

  • Default (Unset): No modifications to word spacing are present
  • Increased: Spaces between words are equivalent to 0.16 times the font size