If you want to create a button from scratch, you must first create the universe
The title is a tongue-in-cheek parody of a quote from Carl Sagan
And it ties in very well with the thought exercise we are doing today.
When we start learning about web accessibility the first thing we hear one of two things:
The first rule of ARIA is to not use ARIA.
And:
If there is a native element, use that instead of recreating the element.
But people never explain why this advice is given, and why we should not recreate things from scratch.
Iâm going to try to explain why recreating some components from scratch is usually a Sisyphean taskbutton from scratch.
Newtonian laws of UX
Native elements have a set of expectations on their behaviour. They follow some laws of UX. (See what I did there? lol)
Users on your application expect to interact with the element in specific ways and expect that the element will behave in specific ways.
For references, we will use the following links as a base of what a button is, how it behave, and how users expect to interact with it.
- Button role on MDN
(External link) - Button pattern on APG
(External link) - Button role on ARIA specification
(External link) - Button element on MDN
(External link) - Web Content Accessibility Guidelines (WCAG) 2.2
(External link)
Those references give us the following list or requirements.
Along with the items are the WCAG Success Criteria (SC) that represent that requirement.
- Have a role of
button. (SC 4.1.2(External link) ) - Have an accessible label. (SC 4.1.2
(External link) , and SC 1.3.1(External link) ) - Be focusable. (SC 2.4.3
(External link) , and SC 2.4.7(External link) ) - Activate with a mouse click.
- Activate by touch.
- Activate by stylus and other pointing devices.
- Activate on Space and Enter keys when focused. (SC 2.1.1
(External link) ) - Have a type and: submit a form; reset a form; or not do anything with the related form.
- A
nameandvalue, and form attributes when participating in forms. (SC 4.1.2(External link) ) - Participate in form validation. (SC 3.3.1
(External link) ) - Support states, like
disabled. (SC 4.1.2(External link) ) - Support newer APIs like Popover API
(External link) , Invoker Commands API(External link) , or Interest Invokers API(External link) .
If that didnât scare you already of trying to re-implement a button from scratch, buckle up, because we will work through that list and I hope that will discourage you from recreating elements.
Custom element nuclei
Our custom element nucleus will be a new tag. We do so because custom tags<span> by default. That gives us an âempty stateâ to start working on.
We then add a role of button to it. Now it will be exposed to the accessibility tree as a button.
NOTE: styling the element will be left as an exercise to the reader for two reasons:
- It will make things unnecessarily longer for this blog post.
- Custom elements can be styled however you like, with no general restrictions.
<sagan-buttonrole="button">A button from scratch</sagan-button>So far so goodâ¦
Periodic table of ARIA
Our button should also have an accessible label, that can be either provided by the developer or as a last case fallback derived from the elementâs content.
The problem here is specifically icon buttons. If we update our markup to something like this:
<sagan-button role="button">ð©</sagan-button>It will be read by a screen reader as something like1:
Button, pile of poo
There are a few issues here:
- Emojis are read out loud, by their full legal name
(External link) . - Not all browsers/operating systems support all emojis. It may display as a replacement character
(External link) . - This gives no clue to the user what that button does.
- It starts failing WCAG SC 2.4.6
(External link) and SC 4.1.2(External link) .
To remediate this we could add a label to the button, and mark the emoji as presentational and/or hide it, so it is not announced by screen readers.
The code will look as follows:
<sagan-button role="button"aria-label="Data Sharing Options">
<spanrole="presentation"aria-hidden="true">ð©</span>
</sagan-button>The focusable universe
The component now looks like a button, is announced like a button, but cannot be interacted with yet.
The first part is to make the element keyboard focusable, to do so, we need to add the tabindex attribute.
This will make the element become part of the tab order of the page
<sagan-button
role="button"
aria-label="Data Sharing Options"
tabindex="0"
>
<span role="presentation" aria-hidden="true">ð©</span>
</sagan-button>The value for the attribute here is 0 so the element will follow the existing order of the page, any positive value would make it jump in front of other elements and the focus of the page move all over, so it is not recommended
Fundamental element interactions
We then need to add some way to activate the component. Click, touch, and pointer interactions are ways to interact with the element using those modalities.
They cover, click for mouse buttons, touch for touch screens, and pointer for everything else like stylus.
NOTE: For maintaining compatibility, browsers do map both touch and pointer events (to some extent) to click events but here we are implementing all of them independently for the sake of argument.
Some keyboard events may also be mapped by default, but it gets more complicated than that...
We then add the events. Starting with the mouseup for handling mouse events:
<sagan-button
role="button"
aria-label="Data Sharing Options"
tabindex="0"
onomouseup="console.log('mouse')"
>
<span role="presentation" aria-hidden="true">ð©</span>
</sagan-button>Letâs also add two more events:
ontouchendfor handling when the user lifts their finger from the element.onpointerupfor handling other pointer devices, like styluses.
Now our component looks like this:
<sagan-button
role="button"
aria-label="Data Sharing Options"
tabindex="0"
onmouseup="console.log('mouse')"
ontouchend="console.log('touch')"
onpointerup="console.log('pointer')"
>
<span role="presentation" aria-hidden="true">ð©</span>
</sagan-button>JS galaxy formation
The code for the custom element is getting a little bit verbose on the HTML side of things.
We will give it a little bit more powers by using JavaScript, registering the component
NOTE: To make event handling easier, it is done using the handleEvent method of our component.
The code for the element looks like this:
class SaganButton extends HTMLElement {
// A reference to the element internals,
// this allows us to do some thigns with the element we cannot do otherwise.
/**@type {ElementInternals} */
#internals;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.#internals = this.attachInternals();
// Add back the things we had before.
this.#internals.role = 'button';
this.#internals.ariaLabel = 'Data Sharing Options';
this.tabindex = 0;
}
/**
*@param {Event} evt
*/
handleEvent(evt) {
// We here filter by event type
switch (evt.type) {
// For all of those we want to do some action.
// So here we use a fallthrough here.
case 'mouseup':
case 'touchend':
case 'pointerup':
this.#doButtonAction(evt);
break;
}
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<slot></slot>
`;
// We add the event listeners
// when the element is added to the DOM.
this.addEventListener('mouseup', this);
this.addEventListener('touchend', this);
this.addEventListener('pointerup', this);
}
disconnectedCallback() {
// And remove the listeners
// when the element is removed from the DOM.
this.removeEventListener('mouseup', this);
this.removeEventListener('touchend', this);
this.removeEventListener('pointerup', this);
}
/**
*@param {Event} evt
*/
#doButtonAction(evt) {
// TODO: Implement the actual button action.
}
}Okay, we are back to where we were before adding JS into the mix, but now with more complexity! ð
Cosmic Keyboard Interaction (CKI)
The button can be activated with clicks, touches, and pointers, but not using the keyboard. We now need to add a keyup and keydown event listener so the component can be interacted with using the keyboard.
NOTE: this is the bare minimum for a semi-working keyboard accessible button.
There are more nuance on the events and expectations. Adrian Roselli covers this topic in more details.
class SaganButton extends HTMLElement {
connectedCallback() {
// ...
this.addEventListener('keyup', this);
this.removeEventListener('keydown', this);
}
disconnectedCallback() {
// ...
this.removeEventListener('keyup', this);
this.removeEventListener('keydown', this);
}
/**
*@param {Event} evt
*/
handleEvent(evt) {
switch (evt.type) {
// ...
case 'keyup':
this.#handleSpaceActivation(evt);
break;
case 'keydown':
this.#handleEnterActivation(evt);
break;
}
}
/**
*@param {KeyboardEvent} evt
*/
#handleSpaceActivation(evt) {
// Don't do anything if the space bar is not pressed.
if (evt.key !== ' ') {
return;
}
if (document.activeElement !== this) {
return;
}
this.#doButtonAction(evt);
}
/**
*@param {KeyboardEvent} evt
*/
#handleEnterActivation(evt) {
if (evt.key !== 'Enter') {
return;
}
this.#doButtonAction(evt);
}
}Absolute zero: When elements become disabled
Now we are getting into attribute territory, this means implementing behaviors to match the native <button> element.
From the MDN docs on the <button> elementdisabled attribute. Letâs use it as a starting point for the pattern to handle attributes we will use.
The attribute pattern is as follows:
- Add the attribute to the
observedAttributesarray. - Create a getter and setter pair for the corresponding property.
- On the setter, reflect the normalized new value on the attribute.
- On the getter, return the normalized attribute value.
- On attribute changes, update the properties.
The pattern allows for both attributes and properties to be kept in sync, as well as validating and normalizing value as needed, as well as falling back to default values when needed.
It is the beginning of a reactive system, and can be used as the basis to implement dynamic updates to the DOM. But I digressâ¦
For the disabled attribute, we first need to set the ariaDisabled property of the elementInternals. Then add a new state to the element so it can be styled by CSS.
class SaganButton extends HTMLElement {
// Specify the attributes to observe.
static observedAttributes = [
'disabled'
];
// Add a setter...
/**
*@param {boolean} newValue
*/
set disabled(newValue) {
this.toggleAttribute('disabled', newValue);
// Note: `aria-disabled` is a string of "true" or "false", not a boolean.
this.#internals.ariaDisabled = newValue ? 'true' : 'false';
if (newValue) {
this.#internals.states.add('--disabled');
} else {
this.#internals.states.delete('--disabled');
}
}
// Then a getter...
get disabled() {
return this.hasAttribute('disabled');
}
// Watch for changes on the attribute.
/**
*@param {string} name
*@param {string | null} oldValue
*@param {string | null} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) {
return;
}
switch (name) {
case 'disabled':
this.disabled = newValue !== null;
break;
}
}
// ...
}To add styles to the disabled state the CSS is similar to the one below:
sagan-button:state(--disabled) { /* Styles for disabled state */ }Element interactions
Buttons started out as elements related to forms, so there are a lot of attributes for interacting with, you guested, forms!
Again from MDN docs on the <button> element, here are the attributes related to forms:
type: The button type, it can be one of 3 options (submit,reset,button). The options trigger the associated form submission, form reset, or donât do anything.form: the associated form, this is useful for the case where the button is not in the sub tree of the form element.formaction: a URL to submit the form to, this is so you can have different buttons submitting the form to different URLs.formmethod: Same as the above, different buttons can send different methods. HTML baby!formenctype: This also allows for different ways of sending the form data over the wire2.formnovalidate: Says the form should skip validation when this button is used.formtarget: If the form should open a new tab on submission or not.nameandvalue: if present those are added to the form on submission.
Aside from the attributes, there are also some properties related to the Validation API
validity: The validity state of the button, it only makes sense for inputs, for buttons it will usually be the same.validationMessage: The error message to show to the user if there is an error for this button.willValidate: If this button will be a part of the constraint validation for the form or not.checkValidity/reportValidty: Methods to validate the element.setCustomValidity: Sets a custom error message to this element.
Ah, yes, we also need to set the formAssociated static property to the element so everything works, without it, we would have a lot of errors.
Well⦠Letâs get to writing all thatâ¦
class SaganButton extends HTMLElement {
// To make it interact with forms...
static formAssociated = true;
static observedAttributes = [
'type',
'form',
'formaction',
'formmethod',
'formenctype',
'formnovalidate',
'formtarget',
'name',
'value'
// ...
];
/**
*@param {string | null} newValue
*/
set type(newValue) {
// The default for button types is "submit",
// So we validate and default to it if the value does not match.
if (newValue && ['submit', 'reset', 'button'].includes(newValue?.toLowerCase())) {
this.setAttribute('type', newValue);
} else {
this.setAttribute('type', 'submit');
}
}
get type() {
return this.getAttribute('type') ?? 'submit';
}
/**
*@param {string | null} newValue
*/
set form(newValue) {
if (newValue === null) {
this.removeAttribute('form');
return;
}
// Here we need to check if the form is a valid element.
// If it is, we then set the attribute.
const form = document.getElementById(newValue);
if (form) {
this.setAttribute('form', newValue);
}
}
/**
*@returns {HTMLFormElement | null}
*/
get form() {
// Here we get the value from the attribute as the first option.
const formId = this.getAttribute('form');
if (formId) {
return document.getElementById(formId);
}
// Then fallback to whatever the browser has set.
return this.#internals.form;
}
/**
*@param {string | null} newValue
*/
set formAction(newValue) {
if (newValue === null) {
this.removeAttribute('formaction');
return;
}
this.setAttribute('formaction', newValue);
}
get formAction() {
return this.getAttribute('formaction');
}
/**
*@param {string | null} newValue
*/
set formMethod(newValue) {
// The default method is "get"
if (newValue && ['post', 'get', 'dialog'].includes(newValue.toLowerCase())) {
this.setAttribute('formmethod', newValue);
} else {
this.setAttribute('formmethod', 'get');
}
}
get formMethod() {
return this.getAttribute('formmethod') ?? 'get';
}
/**
*@param {string | null} newValue
*/
set formEnctype(newValue) {
// The default value is "form url encoded".
if (
newValue &&
[
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain'
].includes(newValue.toLowerCase())
) {
this.setAttribute('formenctype', newValue);
} else {
this.setAttribute('formenctype', 'application/x-www-form-urlencoded');
}
}
get formEnctype() {
return this.getAttribute('formenctype') ?? 'application/x-www-form-urlencoded';
}
/**
*@param {boolean} newValue
*/
set formNoValidate(newValue) {
this.toggleAttribute('formnovalidate', newValue);
}
get formNoValidate() {
return this.hasAttribute('formnovalidate');
}
/**
*@param {string | null} newValue
*/
set formTarget(newValue) {
// The default value is "_self".
if (newValue && ['_self', '_blank', '_parent', '_top'].includes(newValue.toLowerCase())) {
this.setAttribute('formtarget', newValue);
} else {
this.setAttribute('formtarget', '_self');
}
}
get formTarget() {
return this.getAttribute('formtarget') ?? '_self';
}
/**
*@param {string | null} newValue
*/
set name(newValue) {
if (newValue === null) {
this.removeAttribute('name');
return;
}
this.setAttribute('name');
}
get name() {
return this.getAttribute('name');
}
/**
*@param {string | null} newValue
*/
set value(newValue) {
if (newValue === null) {
this.removeAttribute('value');
return;
}
this.setAttribute('value', newValue);
// We need to set the button's value.
this.#internals.setFormValue(newValue);
// And the validation state.
this.#internals.setValidity({});
// And the valid/invalid states.
this.#internals.states.add('--valid');
this.#internals.states.delete('--invalid');
}
get value() {
return this.getAttribute('value');
}
get willValidate() {
if (this.type === 'button' || this.type === 'reset') {
return false;
}
if (this.closest('datalist')) {
return false;
}
if (this.disabled) {
return false;
}
if (!this.#internals.validity.customError) {
return false;
}
return this.#internals.willValidate;
}
get validationMessage() {
return this.willValidate ? this.#internals.validationMessage : '';
}
get validity() {
return this.#internals.validity;
}
checkValidity() {
return this.#internals.checkValidity();
}
reportValidity() {
return this.#internals.reportValidity();
}
/**
*@param {string} message
*/
setCustomValidity(message) {
if (message) {
this.#internals.setValidity({ customError: true }, message);
this.#internals.states.add('--invalid');
this.#internals.states.delete('--valid');
} else {
this.#internals.setValidity({});
this.#internals.states.add('--valid');
this.#internals.states.delete('--invalid');
}
}
/**
*@param {string} name
*@param {string | null} oldValue
*@param {string | null} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
// ...
switch (name) {
// ...
case 'type':
this.type = newValue;
break;
case 'form':
this.form = newValue;
break;
case 'formaction':
this.formAction = newValue;
break;
case 'formmethod':
this.formMethod = newValue;
break;
case 'formenctype':
this.formEnctype = newValue;
break;
case 'formnovalidate':
this.formNoValidate = newValue !== null;
break;
case 'formtarget':
this.formTarget = newValue;
break;
case 'name':
this.name = newValue;
break;
case 'value':
this.value = newValue;
break;
}
}
}For every actionâ¦
After a wall of text, we write the logic for what to actually do once the button is activated. We defined a #doButtonAction method before but didnât implement it.
Now is the time to implement that method:
class SaganButton extends HTMLElement {
// ...
#doButtonAction() {
// First check if the button is disabled,
// On that case nothing should happen.
if (this.disabled) {
return;
}
// Then check the button type.
if (this.type === 'button') {
return;
}
// Here we reset the form if the button is a reset one.
if (this.type === 'reset') {
this.form?.reset();
return;
}
// Finally, if it is a submit button,
// AND IF we wired everything correctly,
// The form will be handled by the browser.
this.form?.requestSubmit(this);
}
// ...
}We could also handle all the validations and form submission by ourselves, including the button and form validations. But this is already a painful and long enough post as it is.
So to avoid more self inflicted pain, in the end, we let the browser handle the form submission for us.
Event listener horizon
There are a couple of things that happen when the button is activated that are not covered:
- The component should change to the
activestate. - If any listener calls
preventDefaultthe default action that we just implemented should not run.
If we simply register event listeners to the button, as we have so far with addEventListener it would make our default event listener not work, as it may run out of order with other event listeners added by the user using our component.
We want to run an event listener before and another after every other listener. To do so we need to wrap the addEventListener, as well as all the on* attributes
Here is the code for wrapping the addEventListener method:
class SaganButton extends HTMLElement {
/**@type {Element['addEventListener']} */
#originalAddEventListener;
constructor() {
// ...
// Add event listeners for to set the active state.
this.addEventListener('pointerdown', this.#setActiveState);
this.addEventListener('touchstart', this.#setActiveState);
this.addEventListener('mousedown', this.#setActiveState);
this.addEventListener('keydown', this.#setActiveState);
// Keep a reference to the original method.
this.#originalAddEventListener = this.addEventListener;
// Wrap the original method.
this.addEventListener = this.#wrappedAddEventListener;
}
/**
*@template {keyof ElementEventMap} K
*@param {K} type
*@param {EventListenerOrEventListenerObject} listener
*@param {boolean | EventListenerOptions} [options]
*/
#wrappedAddEventListener(type, listener, options) {
const defaultEvents = ['click', 'pointerup', 'touchend', 'keyup', 'keydown'];
// If one of the default events,
// Remove the default event listener, and re-add it as the last one.
if (defaultEvents.includes(type)) {
this.removeEventListener(type, this);
this.#originalAddEventListener.call(this, type, listener, options);
this.addEventListener(type, this);
} else {
this.#originalAddEventListener.call(this, type, listener, options);
}
}
#setActiveState() {
if (this.disabled) {
return;
}
this.#internals.states.add('--active');
}
/**
*@param {Event} evt
*/
#doButtonAction(evt) {
// After the disabled check...
this.#internals.states.delete('--active');
if (evt.defaultPrevented) {
return;
}
// ...
}
}For the attribute event listeners, there are two caveats to note:
- The attributes accept a string.
- The property accepts a function.
For the property case, it would be as simple as calling the #wrappedAddEventListener.
The problem is with the attribute, as it can accept a number of things that should be parsed as JavaScript code
Here is one example for the onmosueup attribute and property:
class SaganButton extends HTMLElement {
static observedAttributes = [
// ...
'onmouseup'
];
// Keep a list of event listeners for attributes.
/**@type {Record<string, EventListener>} */
#attributeEventListeners = {};
/**
*@param {EventListener | null} newValue
*/
set onmouseup(newValue) {
if (!newValue) {
// Check if there is already a saved listener.
const savedListener = this.#attributeEventListeners['mouseup'];
if (savedListener) {
// Then remove it, and delete the reference.
this.removeEventListener('mouseup', savedListener);
delete this.#attributeEventListeners['mouseup'];
}
} else {
// Add a new listener and save the reference.
this.addEventListener('mouseup', newValue);
this.#attributeEventListeners['mouseup'] = newValue;
}
}
get onmouseup() {
return this.#attributeEventListeners['mouseup'] ?? null;
}
/**
*@param {string} name
*@param {string | null} oldValue
*@param {string | null} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
// ...
switch (name) {
// ...
case 'onmouseup':
this.onmouseup = this.#parseAttributeListener(newValue);
break;
}
}
/**
*@param {string | null} value
*/
#parseAttributeListener(value) {
// If no value is passed it returns null.
if (!value) {
return null;
}
try {
// NOTE: This is very bad practice!
// It creates an immediatly invoked function body out of the value given.
// When it is called, it will execute whatever is passed as the value.
return new Function('evt', `(${value})(evt);`);
} catch (err) {
// If there is an error with the function, returns null.
console.error(err);
return null;
}
}
}Unexplored API space
There are APIs like the Popover API
I will skip those because they require extra code or relate to other elements around the button like dialogs. Also this post is just north of 4000 words, so yeahâ¦
In closing
USE SEMANTIC HTML!
Thatâs is. Donât reinvent the wheel if you donât absolutely need to.
It is too much work, and a lot of extra burden for you to maintain.
The example code for this button component is almost 500 lines of JS (see it below), where a native HTML button is 0 lines of JS.
All that work to achieve only some of the functionality! That is an insane amount of work for almost no return, so do yourself a favour and just use the semantic native options.
Appendix A - Frequently Asked Questions (FAQ)
Question: But what if I only need a subset of features?
Even if you use only part of the functionality, using semantic HTML is less code to maintain and makes your codebase easier to reason about.
On the plus side, if you ever need some other functionality, it is already there, provided by the platform, no extra code.
Question: But what if the element doesnât allow me to use my brand colours?
Styling elements would have been a big issue on ye olde IE days. Nowadays it is mostly okay.
This MDN guide on styling forms
Also, have you heard about custom selects
Another point is that hand rolling a component is a lot of work and most of the time breaks user expectations, talk to the designer and ask the question: is this the best user experience or could we use another pattern?
Lastly, inaccessible pages are liable to legal actions. Better keep those lawyers happy and the fines away, am I right?
Question: But what if the elements donât play well with [insert framework of choice here]?
Well⦠Sometimes we canât choose the tools we use, but we still can make the best out of them. For the code you write, use more semantic HTML and test things out.
Question: But my agent generates horrible inaccessible code, what should I do?
Unfortunately LLMs generate inaccessible code by default, but not all is lost, this article proposes solutions to the problem
Appendix B - The final code
All the glorious almost 500 lines of it!
Footnotes
-
The exact reading by the screen reader is not the point here, the main problem is that emojis are read out loud by a name that is usually not intuitive.
Back to reference 1 -
In modern development, where everything is sent to the server as JSON, this is a forgotten piece of how basic HTML can do a lot!
Back to reference 2
Source: hackernews