One of my favorite uses of Web Components is as a container for HTML elements that helps manage some aspect of state. In this case, we can wrap a couple of <button>
elements to create a set of toggle buttons, like you might use for a theme picker on your website. Let me know what you think over on the fediverse: @darth_mall@notacult.social.
Markup
<fieldset>
<legend>Choose one</legend>
<toggle-group>
<button value="one">One</button>
<button value="two">Two</button>
<button value="three">Three</button>
</toggle-group>
<fieldset>
The markup for our control is pretty simple. At it’s core, we just wrap some <button>
elements in our <toggle-group>
custom element. We’ll register this with some JavaScript later on to implement the toggle behavior. Then we give our control an accessible name using a <fieldset>
with a <legend>
. All credit for this ingenious use of <fieldset>
goes to Ben Myers.
We use the value
attribute on the buttons much like we would use them with <input type=radio>
, since, in effect, a single-selection toggle group like this is just a gussied up radio group.1
Default selection
With the above markup, none of our buttons will be selected by default. Selected buttons will be identified using the aria-pressed=true
attribute. So, if you’d like to set a default selection, you simply have to include aria-pressed=true
on whichever button should be selected.
Multi-select
When we come to the implementation of our <toggle-group>
Web Component, we’ll also include a boolean attribute for a multi-select mode. To use it, we simply include the multiple
attribute in our markup like this, <toggle-group multiple>
and that will turn on multi-selection.
Styles
button[aria-pressed="true"] {
background-color: dodgerblue;
border-color: dodgerblue;
color: black;
}
/* Make all buttons in the group the same width */
toggle-group {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
}
/* Eliminate rounded corners on inner edges of buttons. */
toggle-group button:not(:first-of-type) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
toggle-group button:not(:last-of-type) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
/* Prevent clicks on elements nested inside buttons */
toggle-group button * {
pointer-events: none;
}
Our styles are also pretty minimal. First, we define a style that communicates the toggled state for buttons. If you want, you can apply this to toggle-group button[aria-pressed=true]
instead of all buttons, but I like to just define the toggle state for all buttons on a site.
Next, we handle the layout of the buttons in our toggle group. Grid makes this really easy and ensures that we have no gaps between buttons and that all buttons have the same size.
The next two rules remove rounded corners on the internal edges of any of the buttons. If you don’t have rounded corners on your buttons, this won’t really matter, and the corners of your toggle group will be square. If you do round the corners of your buttons, then the corners of your toggle group will match whatever border radius you have set for your buttons, and we only straighten out the corners we need to so that the control looks like a single, segmented button.
Finally, we disable pointer events on elements contained inside of the buttons. This allows you to put an icon inside the buttons along with the text, without allowing it to be the target of a click event. This way, when we implement the click handler, we don’t have to worry about the event target being the SVG inside of the button instead of the button itself.
JavaScript
class ToggleGroup extends HTMLElement {
// Lifecycle methods
connectedCallback() {}
disconnectedCallback() {}
// Properties
get multiple() {}
set multiple(value) {}
get value() {}
set value(val) {}
// Internal methods
#handleClick(event) {}
#setState(state) {}
#togglePressed(button) {}
}
customElements.define("toggle-group", ToggleGroup);
ToggleGroup
class that will implement the control’s behavior, without any method implementation details.Life cycle methods
Since we’re not using shadow DOM for this component, there’s no real need to implement a constructor
. Instead, we can do all of our initialization in the connectedCallback
method.
connectedCallback() {
this.addEventListener("click", this.#handleClick);
}
disconnectedCallback() {
this.removeEventListener("click", this.#handleClick);
}
All we do in our life cycle methods is connect and disconnect our click handler. We could listen for clicks on each individual button, but this has several disadvantages:
- If we’ve defined the custom element before we encounter the opening tag,
connectedCallback
will fire as soon as<toggle-group>
is parsed; so if we go looking for<button>
elements inside our Web Component, we won’t find any, they’ll be added later and we’ll miss them - If any buttons are added or removed to our Web Component using JavaScript, we’ll miss them
- Having multiple event listeners is more costly than having just one
We could handle the first two problems using MutationObserver
, but that’s more complicated than just using event delegation.
Properties
get multiple() {
return this.hasAttribute("multiple");
}
set multiple(value) {
if (value === true) {
this.setAttribute("multiple", "");
} else {
this.removeAttribute("multiple");
}
}
multiple
property.The multiple
property on our Web Component mimics the multiple
attribute defined for <select>
elements. It is just a reflection of the attribute of the same name in JavaScript, i.e. it is written to and read from the attribute on the element. This makes it convenient for us to test whether or not this is a multi-select group when handling clicks, and also makes it convenient to set the selection mode from JavaScript—toggleGroup.multiple = true
is much nicer than toggleGroup.setAttribute("multiple", "")
.
get value() {
const value_list = [];
for (let btn of this.querySelectorAll("[aria-pressed=true]")) {
value_list.push(btn.value);
}
if (value_list.length === 0) return;
return this.multiple ? value_list : value_list[0];
}
set value(val) {
if (!Array.isArray(val)) {
// Ensure we are always working with an array, for simplicity.
this.#setState([val]);
} else if (val.length > 1 && !this.multiple) {
// If this is a single-select toggle group and there's more than one
// value in the array, we ignore all but the first value.
this.#setState([val[0]]);
} else {
// Assigned value is fine as-is.
this.#setState(val);
}
}
value
property.The value
property is modeled after the value
property on <input>
elements. The getter simply iterates over all of the children of the component that are toggled on and adds their value to an array of values. If the toggle group has multi-select enabled, it returns the array, otherwise it returns the first item in the array. If nothing is selected, it returns undefined
.
The setter will accept either a single value or an array of values, with a guard to ignore all but the first array element if the toggle group is in single-select mode. It just ensures that we’re passing a valid argument to the #setState
method so that the API for anyone using the Web Component via JavaScript has a nicer time, rather than requiring them to always pass an array.
Click handler
#handleClick(event) {
const target = event.target;
// Bail out, in case someone puts a non-button element inside this component
if (target.localName !== "button") return;
// Prevent form submission when the toggle group is inside a form element
// for progressive enhancement
event.preventDefault();
// If the toggle group is a multi-select, toggle the event target and
// we're all done, otherwise set the state of the control
if (this.multiple) {
this.#toggleState(target);
} else {
this.#setState([target.value]);
}
this.dispatchEvent(new CustomEvent(
"togglechange",
{ detail: { value: this.value } },
));
}
The first thing the handler does is check to see if the thing that was clicked was actually a button. In the event that someone put something other than a button inside the toggle group, we want to just ignore clicks and let the browser do whatever else it wants to do. If it is a button, we want to prevent the default browser behavior, because this toggle group could be inside a form and we don’t want the form submitting (more on this when we get to progressive enhancement).
With those guards in place, the next thing to do is check to see if this toggle group is a multi-select group or not. If it is a multi-select group, then all we have to do is toggle the state of whatever button was clicked. If it’s a single-select group, we just pass the value of the event target to #setState
.
Set state method
#setState(state) {
for (let btn of this.querySelectorAll("button")) {
const pressed = state.includes(btn.value) ? "true" : "false";
btn.setAttribute("aria-pressed", pressed);
}
}
The set state method is a utility to set the aria-pressed
attributes on all the buttons to match the desired state. The argument, state
, is an array of strings corresponding to the values on each button that should be turned on; all other buttons will be turned off. This method is private because it doesn’t include any checks on the arguments—it assumes that state
is an array. This allows us to skip unnecessary checks when we call internally from our click handler and we know the correct format of the argument to pass. We can then push all of the checks to be more permissive in what we accept from authors using this Web Component into the value
setter for a nicer API.
Toggle pressed method
#togglePressed(button) {
const currentValue = button.getAttribute("aria-pressed");
const toggledValue = currentValue === "true" ? "false" : "true";
button.setAttribute("aria-pressed", toggledValue);
}
The toggle state method is a utility to make it easier to flip the aria-pressed
attribute on any given button. It gets the current value of aria-pressed
on an element, uses that to determine what the next value should be, and then sets the next value on the element.
Progressive Enhancement
If you have the ability to respond to form submissions, you can use a form to progressively enhance your toggle group.
<form>
<fieldset>
<legend>Choose one</legend>
<toggle-group>
<button name="choose-one" value="one">One</button>
<button name="choose-one" value="two">Two</button>
<button name="choose-one" value="three">Three</button>
</toggle-group>
<fieldset>
</form>
By default, the buttons of a form will submit the form, and without a method
or action
property, the form will submit to the current URL as a GET
request. By adding a name
to our buttons, we ensure that clicking a button will request the current URL with ?choose-one=VALUE
as the query string, where VALUE
is the value
assigned to the button that was clicked. You can use this to process state on the server and set cookies or do whatever you need to do, so that your toggle group works even if JavaScript doesn’t.
This is why we called event.preventDefault
in our click handler. If JavaScript is working, we can prevent the form submission and respond to an event fired by the Web Component.
Without forms
If you can’t handle form submissions, your best bet may be to hide the control if JavaScript has failed. This can be easily done with CSS.
toggle-group:not(:defined) {
display: none;
}
This will hide the control, but if you’re using <fieldset>
to label the control, you’ll have a label that labels nothing. This can be fixed with :has
, if your browser targets support that.
fieldset:has(toggle-group:not(:defined)) {
display: none;
}
Footnotes
It would therefore be a good idea to ask yourself whether you really want a toggle group, or if you’d be better off just using a group of radio buttons. ↩︎