Fun fact: it’s possible to create responsive components without any media queries at all. Certainly, if we had container queries, those would be very useful for responsive design at the component level. But we don’t. Still, with or without container queries, we can do things to make our components surprisingly responsive. We’ll use concepts from Intrinsic Web Design, brought to us by Jen Simmons.
Let’s dive together into the use case described below, the solutions regarding the actual state of CSS, and some other tricks I’ll give you.
A responsive “Cooking Recipe” card
I recently tweeted a video and Pen of a responsive card demo I built using a recipe for pizza as an example. (It’s not important to the technology here, but I dropped the recipe at the end because it’s delicious and gluten free.)
The demo here was a first attempt based on a concept from one of Stéphanie Walter’s talks. Here is a video to show you how the card will behave:
And if you want to play with it right now, here’s the Pen.
Let’s define the responsive layout
A key to planning is knowing the actual content you are working, and the importance of those details. Not that we should be hiding content at any point, but for layout and design reasons, it’s good to know what needs to be communicated first and so forth. We’ll be displaying the same content no matter the size or shape of the layout.
Let’s imagine the content with a mobile-first mindset to help us focus on what’s most important. Then when the screen is larger, like on a desktop, we can use the additional space for things like glorious whitespace and larger typography. Usually, a little prioritization like this is enough to be sure of what content is needed for the cards at any and all viewport sizes.
Let’s take the example of a cooking recipe teaser:
In her talk, Stéphanie had already did the job and prioritized the content for our cards. Here’s what she outlined, in order of importance:
Image: because it’s a recipe, you eat with your eyes!
Title: to be sure what you’re going to cook.
Keywords: to catch key info at the first glance.
Rating info: for social proof.
Short description: for the people who read.
Call to action: what you expect the user to do on this card.
This may seem like a lot, but we can get all of that into a single smart card layout!
Non-scalable typography
One of the constraints with the technique I’m going to show you is that you won’t be able to get scalable typography based on container width. Scalable typography (e.g. “fluid type”) is commonly done with the with viewport width (vw) unit, which is based on the viewport, not the parent element.
So, while we might be tempted to reach for fluid type as a non-media query solution for the content in our cards, we won’t be able to use fluid type based on some percentage of the container width nor element width itself, unfortunately. That won’t stop us from our goal, however!
A quick note on “pixel perfection”
Let’s talk to both sides here…
Designers: Pixel perfect is super ideal, and we can certainly be precise at a component level. But there has to be some trade-off at the layout level. Meaning you will have to provide some variations, but allow the in-betweens to be flexible. Things shift in responsive layouts and precision at every possible screen width is a tough ask. We can still make things look great at every scale though!
Developers: You’ll have to be able to fill the gaps between the layouts that have prescribed designs to allow content to be readable and consistent between those states. As a good practice, I also recommend trying to keep as much of a natural flow as possible.
Remember, what we’re striving for is not just a responsive card, but one that doesn’t rely on any media queries. It’s not that media queries should be avoided; it’s more about CSS being powerful and flexible enough for us to have other options available.
To build our responsive card, I was wondering if flexbox would be enough or if I would need to do it with CSS grid instead. Turns out flexbox in indeed enough for us this time, using the behavior and magic of the flex-wrap and flex-basis properties in CSS.
The gist of flex-wrap is that it allows elements to break onto a new line when the space for content gets too tight. You can see the difference between flex with a no-wrap value and with wrapping in this demo:
The flex-basis value of 200px is more of an instruction than a suggestion for the browser, but if the container doesn’t offer enough space for it, the elements move down onto a new line. The margin between columns even force the initial wrapping.
I used this wrapping logic to create the base of my card. Adam Argyle also used it on the following demo features four form layouts with a mere 10 lines of CSS:
In his example, Adam uses flex-basis and flex-grow (used together in flex shorthand property) )to allow the email input to take three times the space occupied by the name input or the button. When the browser estimates there is not enough rooms to display everything on the same row, the layout breaks itself into multiple lines by itself, without us having to manage the changes in media queries.
I also used clamp() function to add even more flexibility. This function is kind of magical. It allows us to resolve a min() and a max() calculation in a single function. The syntax goes like this:
clamp(MIN, VALUE, MAX)
It’s like resolving a combination of the max() and min() functions:
With all of these new-fangled CSS powers, I created a flexible responsive card without any media queries. It might be best to view this demo in a new tab, or with a 0.5x option in the embed below.
Something you want to note right away is that the HTML code for the 2 cards are exactly the same, the only difference is that the first card is within a 65% wide container, and the second one within a 35% wide container. You can also play with the dimension of your window to test its responsiveness.
The important part of the code in that demo is on these selectors:
.recipe is the parent flex container.
.pizza-box is a flex item that is the container for the card image.
.recipe-content is a second flex item and is the container for the card content.
Now that we know how flex-wrap works, and how flex-basis and flex-grow influence the element sizing, we just need to quickly explain the clamp() function because I used it for responsive font sizing in place of where we may have normally reached for fluid type.
I wanted to use calc() and custom properties to calculate font sizes based on the width of the parent container, but I couldn’t find a way, as a 100% value has a different interpretation depending on the context. I kept it for the middle value of my clamp() function, but the end result was over-engineered and didn’t wind up working as I’d hoped or expected.
/* No need, really */ font-size: clamp(1.4em, calc(.5em * 2.1vw), 2.1em);
Here’s where I landed instead:
font-size: clamp(1.4em, 2.1vw, 2.1em);
That’s what I did to make the card title’s size adjust against the screen size but, like we discussed much earlier when talking about fluid type, we won’t be able to size the text by the parent container’s width.
Instead, we’re basically saying this with that one line of CSS:
I want the font-size to equal to 2.1vw (2.1% of the viewport width), but please don’t let it go below 1.4em or above 2.1em.
This maintains the title’s prioritized importance by allowing it to stay larger than the rest of the content, while keeping it readable. And, hey, it still makes grows and shrinks on the screen size!
And let’s not forget about responsive images, The content requirements say the image is the most important piece in the bunch, so we definitely need to account for it and make sure it looks great at all screen sizes. Now, you may want to do something like this and call it a day:
max-width: 100%; height: auto;
But that’s doesnt always result in the best rendering of an image. Instead, we have the object-fit property, which not only responds to the height and width of the image’s content-box, but allows us to crop the image and control how it stretches inside the box when used with the object-position property.
As you can see, that is a lot of properties to write down. It’s mandatory because of the explicit width and height properties in the HTML <img> code. If you remove the HTML part (which I don’t recommend for performance reason) you can keep the object-* properties in CSS and remove the others.
An alternative recipe for no media queries
Another technique is to use flex-grow as a unit-based growing value, with an absurdly enormous value for flex-basis. The idea is stolen straight from the Heydon Pickering’s great “Holy Albatross” demo.
Proportional dimensions are created by flex-grow while the flex-basis dimension can be either invalid or extremely high. The value gets extremely high when calc(70ch - 100%), the value of --modifier, reaches a positive value. When the values are extremely high each of them fills the space creating a column layout; when the values are invalid, they lay out inline.
The value of 70ch acts like the breakpoint in the recipe component (almost like a container query). Change it depending on your needs.
Let’s break down the ingredients once again
Here are the CSS ingredients we used for a media-query-less card component:
The clamp() function helps resolve a “preferred” vs. “minimum” vs. “maximum” value.
The flex-basis property with a negative value decides when the layout breaks into multiple lines.
The flex-grow property is used as a unit value for proportional growth.
The vw unit helps with responsive typography.
The object-fit property provides finer responsiveness for the card image, as it allows us to alter the dimensions of the image without distorting it.
Going further with quantity queries
I’ve got another trick for you: we can adjust the layout depending on the number of items in the container. That’s not really a responsiveness brought by the dimension of a container, but more by the context where the content lays.
There is no actual media query for number of items. It’s a little CSS trick to reverse-count the number of items and apply style modifications accordingly.
Looks tricky, right? This selector allows us to apply styles from the last-child and all it’s siblings. Neat!
Una Kravets explains this concept really well. We can translate this specific usage like this:
.container > :nth-last-child(n+3): The third .container element or greater from the last .container in the group.
.container > :nth-last-child(n+3) ~ *: The same exact thing, but selects any .container element after the last one. This helps account for any other cards we add.
Hugo Giraudel’s “Selectors Explained” tool really helps translate complex selectors into plain English, if you’d like another translation of how these selectors work.
Another way to get “quantity” containers in CSS is to use binary conditions. But the syntax is not easy and seems a bit hacky. You can reach me on Twitter if you need to talk about that — or any other tricks and tips about CSS or design.
Is this future proof?
All the techniques I presented you here can be used today in a production environment. They’re well supported and offer opportunities for graceful degradation.
Worst case scenario? Some unsupported browser, say Internet Explorer 9, won’t change the layout based on the conditions we specify, but the content will still be readable. So, it’s supported, but might not be “optimized” for the ideal experience.
Maybe one day we will finally get see the holy grail of container queries in the wild. Hopefully the Intrinsic Web Design patterns we’ve used here resonate with you and help you build flexible and “intrinsicly-responsive” components in the meantime.
Let’s get to the “rea” reason for this post… the pizza! 🍕
Gluten free pan pizza recipe
You can pick the toppings. The important part is the dough, and here is that:
Ingredients
3¼ cups (455g) gluten free flour
1 tablespoon, plus 1 teaspoon (29g) brown sugar
2 teaspoons of kosher salt
1/2 cube of yeast
2½ cups (400 ml) whole almond milk
4 tablespoons of melted margarine
1 tablespoon of maizena
Instructions
Mix all the dry ingredients together.
Add the liquids.
Let it double size for 2 hours. I’d recommend putting a wet dish towel over your bowl where the dough is, and place the dish close to a hot area (but not too hot because we don’t want it to cook right this second).
Put it in the pan with oil. Let it double size for approximately 1 hour.
A run of Web Components news crossed my desk recently so I thought I’d group it up here.
To my mind, one of the best use cases for Web Components is pattern libraries. Instead of doing, say, <ul class="nav nav-tabs"> like you would do in Bootstrap or <div class="tabs"> like you would in Bulma, you would use a custom element, like <designsystem-tabs>.
The new Shoelace library uses the sl namespace for their components. It’s a whole pattern library entirely in Web Components. So the tabs there are <sl-tab-group> elements.
Why is that good? Well, for one thing, it brings a component model to the party. That means, if you’re working on a component, it has a template and a stylesheet that are co-located. Peeking under the hood of Shoelace, you can see this is all based on Stencil.
Another reason it’s good is that it means components can (and they do) use the Shadow DOM. This offers a form of isolation that comes right from the web platform. For CSS folks like us, that means the styling for a tab in the tab component is done with a .tab class (hey, wow, cool) but it is isolated in that component. Even with that generic of a name, I can’t accidentally mess with some other component on the page that uses that generic class, nor is some other outside CSS going to mess with the guts here. The Shadow DOM is a sort of wall of safety that prevents styles from leaking out or seeping in.
I just saw the FAST framework¹ too, which is also a set of components. It has tabs that are defined as <fast-tabs>. That reminds me of another thing I like about the Web Components as a pattern library approach: if feels like it’s API-driven, even starting with the name of the component itself, which is literally what you use in the HTML. The attributes on that element can be entirely made up. It seems the emerging standard is that you don’t even have to data-* prefix the attributes that you also make up to control the component. So, if I were to make a tabs component, it might be <chris-tabs active-tab="lunch" variation="rounded">.
Perhaps the biggest player using Web Components for a pattern library is Ionic. Their tabs are <ion-tabs>, and you can use them without involving any other framework (although they do support Angular, React, and Vue in addition to their own Stencil). Ionic has made lots of strides with this Web Components stuff, most recently supporting Shadow Parts. Here’s Brandy Carney explaining the encapsulation again:
Shadow DOM is useful for preventing styles from leaking out of components and unintentionally applying to other elements. For example, we assign a .button class to our ion-button component. If an Ionic Framework user were to set the class .button on one of their own elements, it would inherit the Ionic button styles in past versions of the framework. Since ion-button is now a Shadow Web Component, this is no longer a problem.
However, due to this encapsulation, styles aren’t able to bleed into inner elements of a Shadow component either. This means that if a Shadow component renders elements inside of its shadow tree, a user isn’t able to target the inner element with their CSS.
The encapsulation is a good thing, but indeed it does make styling “harder” (on purpose). There is an important CSS concept to know: CSS custom properties penetrate the Shadow DOM. However, it was decided — and I think rightly so — that “variablizing” every single thing in a design system is not a smart way forward. Instead, they give each bit of HTML inside the Shadow DOM a part, like <div part="icon">, which then gives gives the ability to “reach in from the outside” with CSS, like custom-component::part(icon) { }.
I think part-based styling hooks are mostly fine, and a smart way forward for pattern libraries like this, but I admit some part of it bugs me. The selectors don’t work how you’d expect. For example, you can’t conditionally select things. You also can’t select children or use the cascade. In other words, it’s just one-off, or like you’re reaching straight through a membrane with your hand. You can reach forward and either grab the thing or not, but you can’t do anything else at all.
Speaking of things that bug people, Andrea Giammarchi has a good point about the recent state of Web Components:
Every single library getting started, including mine, suggest we should import the library in order to define what [sic] supposed to be a “portable Custom Element”.
Google always suggests LitElement. Microsoft wants you to use FASTElement. Stencil has their own Component. hyperHTML has their own Component. Nobody is just using “raw” Web Components. It’s weird! What strikes me as the worst part about that is that Web Components are supposed to be this “native platform” thing meaning that we shouldn’t need to buy into some particular technology in order to use them. When we do, we’re just as locked to that as we would be if we just used React or whatever.
Andrea has some ideas in that article, including the use of some new and smaller library. I think what I’d like to see is a pattern library that just doesn’t use any library at all.
FAST calls itself a “interface system,” then a “UI framework” in consecutive sentences on the homepage. Shoelaces calls itself a “library” but I’m calling it a “pattern library.” I find “design system” to be the most commonly used term to describe the concept, but often used more broadly than a specific technology. FAST uses that term in the code itself for the wrapper element that controls the theme. I’d say the terminology around all this stuff is far from settled.
I’ve been doing a bit of refactoring this week at Sentry and I noticed that we didn’t have a generic List component that we could use across projects and features. So, I started one, but here’s the rub: we style things at Sentry using Emotion, which I have only passing experience with and is described in the docs as…
[…] a library designed for writing css styles with JavaScript. It provides powerful and predictable style composition in addition to a great developer experience with features such as source maps, labels, and testing utilities. Both string and object styles are supported.
If you’ve never heard of Emotion, the general idea is this: when we’re working on big codebases with lots of components, we want to ensure that we can control the cascade of our CSS. So, let’s say you have an .active class in one file and you want to make sure that doesn’t impact the styles of a completely separate component in another file that also has a class of.active.
Emotion tackles this problem by adding custom strings to your classnames so they don’t conflict with other components. Here’s an example of the HTML it might output:
<div class="css-1tfy8g7-List e13k4qzl9"></div>
Pretty neat, huh? There’s lots of other tools and workflows out there though that do something very similar, such as CSS Modules.
To get started making the component, we first need to install Emotion into our project. I’m not going to walkthrough that stuff because it’s going to be different depending on your environment and setup. But once that’s complete we can go ahead and create a new component like this:
import React from 'react'; import styled from '@emotion/styled'; export const List = styled('ul')` list-style: none; padding: 0; `;
This looks pretty weird to me because, not only are we writing styles for the <ul> element, but we’re defining that the component should render a <ul>, too. Combining both the markup and the styles in one place feels odd but I do like how simple it is. It just sort of messes with my mental model and the separation of concerns between HTML, CSS, and JavaScript.
In another component, we can import this <List> and use it like this:
import List from 'components/list'; <List>This is a list item.</List>
The styles we added to our list component will then be turned into a classname, like .oefioaueg, and then added to the <ul> element we defined in the component.
But we’re not done yet! With the list design, I needed to be able to render a <ul> and an <ol> with the same component. I also needed a version that allows me to place an icon within each list item. Just like this:
The cool (and also kind of weird) thing about Emotion is that we can use the as attribute to select which HTML element we’d like to render when we import our component. We can use this attribute to create our <ol> variant without having to make a custom type property or something. And that happens to look just like this:
<List>This will render a ul.</List> <List as="ol">This will render an ol.</List>
That’s not just weird to me, right? It’s super neat, however, because it means that we don’t have to do any bizarro logic in the component itself just to change the markup.
It was at this point that I started to jot down what the perfect API for this component might look like though because then we can work our way back from there. This is what I imagined:
So after making this sketch I knew we’d need two components, along with the ability to nest icon subcomponents within the <ListItem>. We can start like this:
import React from 'react'; import styled from '@emotion/styled'; export const List = styled('ul')` list-style: none; padding: 0; margin-bottom: 20px; ol& { counter-reset: numberedList; } `;
That peculiar ol& syntax is how we tell emotion that these styles only apply to an element when it’s rendered as an <ol>. It’s often a good idea to just add a background: red; to this element to make sure your component is rendering things correctly.
Next up is our subcomponent, the <ListItem>. It’s important to note that at Sentry we also use TypeScript, so before we define our <ListItem> component, we’ll need to set our props up first:
Now we can add our <IconWrapper> component that will size an <Icon> component within the ListItem. If you remember from the example above, I wanted it to look something like this:
That IconBusiness component is a preexisting component and we want to wrap it in a span so that we can style it. Thankfully, we’ll need just a tiny bit of CSS to align the icon properly with the text and the <IconWrapper> can handle all of that for us:
Once we’ve done this we can finally add our <ListItem> component beneath these two, although it is considerably more complex. We’ll need to add the props, then we can render the <IconWrapper> above when the icon prop exists, and render the icon component that’s passed into it as well. I’ve also added all the styles below so you can see how I’m styling each of these variants:
export const ListItem = styled(({icon, className, children}: ListItemProps) => ( <li className={className}> {icon && ( <IconWrapper> {icon} </IconWrapper> )} {children} </li> ))<ListItemProps>` display: flex; align-items: center; position: relative; padding-left: 34px; margin-bottom: 20px; /* Tiny circle and icon positioning */ &:before, & > $ {IconWrapper} { position: absolute; left: 0; } ul & { color: #aaa; /* This pseudo is the tiny circle for ul items */ &:before { content: ''; width: 6px; height: 6px; border-radius: 50%; margin-right: 15px; border: 1px solid #aaa; background-color: transparent; left: 5px; top: 10px; } /* Icon styles */ $ {p => p.icon && ` span { top: 4px; } /* Removes tiny circle pseudo if icon is present */ &:before { content: none; } `} } /* When the list is rendered as an <ol> */ ol & { &:before { counter-increment: numberedList; content: counter(numberedList); top: 3px; display: flex; align-items: center; justify-content: center; text-align: center; width: 18px; height: 18px; font-size: 10px; font-weight: 600; border: 1px solid #aaa; border-radius: 50%; background-color: transparent; margin-right: 20px; } } `;
And there you have it! A relatively simple <List> component built with Emotion. Although, after going through this exercise I’m still not sure that I like the syntax. I reckon it sort of makes the simple stuff really simple but the medium-sized components much more complicated than they should be. Plus, it could be pretty darn confusing to a newcomer and that worries me a bit.
But everything is a learning experience, I guess. Either way, I’m glad I had the opportunity to work on this tiny component because it taught me a few good things about TypeScript, React, and trying to make our styles somewhat readable.
The title of this Sara Soueidan article speaks to me. I’m a big fan of the idea that some CSS is best applied globally, and some CSS is best applied scoped to a component. I’m less interested in how that is done and more interested in just seeing that conceptual approach used in some fashion.
Sara details an approach where components don’t have too much styling by default, but have CSS custom properties applied to them that are ready to take values should you choose to set them.
For each pattern, I’ve found myself modifying the same properties whenever I needed to use it — like the font, colors (text, background, border), box shadow, spacing, etc. So I figured it would be useful and time-saving if I created variables for those properties, define those variables in the ‘root’ of the component, and ‘pass in’ the values for these variables when I use the pattern as I need. This way I can customize or theme the component by changing the property values in one rule set, instead of having to jump between multiple ones to do so.
If you follow the undercurrent of the JavaScript community, there seems to be a divide as of late. It goes back over a decade. Really, this sort of strife has always been. Perhaps it is human nature.
Whenever a popular framework gains traction, you inevitably see people comparing it to rivals. I suppose that is to be expected. Everyone has a particular favorite.
Lately, the framework everyone loves (to hate?) is React. You often see it pitted against others in head-to-head blog posts and feature comparison matrices of enterprise whitepapers. Yet a few years ago, it seemed like jQuery would forever be king of the hill.
Frameworks come and go. To me, what is more interesting is when React — or any JS framework for that matter — gets pitted against the programming language itself. Because of course, under the hood, it is all built atop JS.
The two are not inherently at odds. I would even go so far as to say that if you do not have a good handle on JS fundamentals, you probably are not going to reap the full benefits of using React. It can still be helpful, similar to using a jQuery plugin without understanding its internals. But I feel like React presupposes more JS familiarity.
HTML is equally important. There exists a fair bit of FUD around how React affects accessibility. I think this narrative is inaccurate. In fact, the ESLint JSX a11y plugin will warn of possible accessibility violations in the console.
Recently, an annual study of the top 1 million sites was released. It shows that for sites using JS frameworks, there is an increased likelihood of accessibility problems. This is correlation, not causation.
This does not necessarily mean that the frameworks caused these errors, but it does indicate that home pages with these frameworks had more errors than on average.
In a manner of speaking, React’s magic incantations work regardless of whether you recognize the words. Ultimately, you are still responsible for the outcome.
Philosophical musings aside, I am a firm believer in choosing the best tool for the job. Sometimes, that means building a single page app with a Jamstack approach. Or maybe a particular project is better suited to offloading HTML rendering to the server, where it has historically been handled.
Either way, there inevitably comes the need for JS to augment the user experience. At Reaktiv Studios, to that end I have been attempting to keep most of our React components in sync with our “flat HTML” approach. I have been writing commonly used functionality in vanilla JS as well. This keeps our options open, so that our clients are free to choose. It also allows us to reuse the same CSS.
If I may, I would like to share how I built our <Tabs> and <Accordion> React components. I will also demonstrate how I wrote the same functionality without using a framework.
Hopefully, this lesson will feel like we are making a layered cake. Let us first start with the base markup, then cover the vanilla JS, and finish with how it works in React.
Since we need JavaScript to make interactive widgets either way, I figured the easiest approach — from a server side implementation standpoint — would be to require only the bare minimum HTML. The rest can be augmented with JS.
The following are examples of markup for tabs and accordion components, showing a before/after comparison of how JS affects the DOM.
I have added id="TABS_ID" and id="ACCORDION_ID" for demonstrative purposes. This is to make it more obvious what is happening. But the JS that I will be explaining automatically generates unique IDs if nothing is supplied in the HTML. It would work fine either way, with or without an id specified.
Okay. Now that we have seen the aforementioned HTML examples, let us walk through how we get from before to after.
First, I want to cover a few helper functions. These will make more sense in a bit. I figure it is best to get them documented first, so we can stay focused on the rest of the code once we dive in further.
File: getDomFallback.js
This function provides common DOM properties and methods as no-op, rather than having to make lots of typeof foo.getAttribute checks and whatnot. We could forego those types of confirmations altogether.
Since live HTML changes can be a potentially volatile environment, I always feel a bit safer making sure my JS is not bombing out and taking the rest of the page with it. Here is what that function looks like. It simply returns an object with the DOM equivalents of falsy results.
It generates a unique string that can be used to associate DOM elements with one another. It is handy, because then the author of an HTML page does not have to ensure that every tabs and accordion component have unique IDs. In the previous HTML examples, this is where TABS_ID and ACCORDION_ID would typically contain the randomly generated numeric strings instead.
// ========== // Constants. // ========== const BEFORE = '0.'; const AFTER = ''; // ================== // Get unique string. // ================== const unique = () => { // Get prefix. let prefix = Math.random(); prefix = String(prefix); prefix = prefix.replace(BEFORE, AFTER); // Get suffix. let suffix = Math.random(); suffix = String(suffix); suffix = suffix.replace(BEFORE, AFTER); // Expose string. return `$ {prefix}_$ {suffix}`; }; // Export. export { unique };
On larger JavaScript projects, I would typically use npm install uuid. But since we are keeping this simple and do not require cryptographic parity, concatenating two lightly edited Math.random() numbers will suffice for our string uniqueness needs.
File: tablist.js
This file does the bulk of the work. What is cool about it, if I do say so myself, is that there are enough similarities between a tabs component and an accordion that we can handle both with the same *.js file. Go ahead and scroll through the entirety, and then we will break down what each function does individually.
// Helpers. import { getDomFallback } from './getDomFallback'; import { unique } from './unique'; // ========== // Constants. // ========== // Boolean strings. const TRUE = 'true'; const FALSE = 'false'; // ARIA strings. const ARIA_CONTROLS = 'aria-controls'; const ARIA_DISABLED = 'aria-disabled'; const ARIA_LABELLEDBY = 'aria-labelledby'; const ARIA_HIDDEN = 'aria-hidden'; const ARIA_MULTISELECTABLE = 'aria-multiselectable'; const ARIA_SELECTED = 'aria-selected'; // Attribute strings. const DISABLED = 'disabled'; const ID = 'id'; const ROLE = 'role'; const TABLIST = 'tablist'; const TABINDEX = 'tabindex'; // Event strings. const CLICK = 'click'; const KEYDOWN = 'keydown'; // Key strings. const ENTER = 'enter'; const FUNCTION = 'function'; // Tag strings. const LI = 'li'; // Selector strings. const ACCORDION_ITEM_ICON = 'accordion__item__icon'; const ACCORDION_ITEM_ICON_SELECTOR = `.$ {ACCORDION_ITEM_ICON}`; const TAB = 'tab'; const TAB_SELECTOR = `[$ {ROLE}=$ {TAB}]`; const TABPANEL = 'tabpanel'; const TABPANEL_SELECTOR = `[$ {ROLE}=$ {TABPANEL}]`; const ACCORDION = 'accordion'; const TABLIST_CLASS_SELECTOR = '.accordion, .tabs'; const TAB_CLASS_SELECTOR = '.accordion__item, .tabs__item'; const TABPANEL_CLASS_SELECTOR = '.accordion__panel, .tabs__panel'; // =========== // Get tab ID. // =========== const getTabId = (id = '', index = 0) => { return `tab_$ {id}_$ {index}`; }; // ============= // Get panel ID. // ============= const getPanelId = (id = '', index = 0) => { return `tabpanel_$ {id}_$ {index}`; }; // ============== // Click handler. // ============== const globalClick = (event = {}) => { // Get target. const { key = '', target = getDomFallback() } = event; // Get parent. const { parentNode = getDomFallback(), tagName = '' } = target; // Set later. let wrapper = getDomFallback(); /* ===== NOTE: ===== We test for this, because the method does not exist on `document.documentElement`. */ if (typeof target.closest === FUNCTION) { // Get wrapper. wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback(); } // Is `<li>`? const isListItem = tagName.toLowerCase() === LI; // Is multi? const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE; // Valid key? const isValidKey = !key || key.toLowerCase() === ENTER; // Valid target? const isValidTarget = !target.hasAttribute(DISABLED) && target.getAttribute(ROLE) === TAB && parentNode.getAttribute(ROLE) === TABLIST; // Valid event? const isValidEvent = isValidKey && isValidTarget; // Continue? if (isValidEvent) { // Get panel. const panelId = target.getAttribute(ARIA_CONTROLS); const panel = wrapper.querySelector(`#$ {panelId}`) || getDomFallback(); // Get booleans. let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE; let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE; // List item? if (isListItem) { boolPanel = FALSE; boolTab = TRUE; } // [aria-multiselectable="false"] if (!isMulti) { // Get tabs & panels. const childTabs = wrapper.querySelectorAll(TAB_SELECTOR); const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR); // Loop through tabs. childTabs.forEach((tab = getDomFallback()) => { tab.setAttribute(ARIA_SELECTED, FALSE); }); // Loop through panels. childPanels.forEach((panel = getDomFallback()) => { panel.setAttribute(ARIA_HIDDEN, TRUE); }); } // Set individual tab. target.setAttribute(ARIA_SELECTED, boolTab); // Set individual panel. panel.setAttribute(ARIA_HIDDEN, boolPanel); } }; // ==================== // Add ARIA attributes. // ==================== const addAriaAttributes = () => { // Get elements. const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR); // Loop through. allWrappers.forEach((wrapper = getDomFallback()) => { // Get attributes. const { id = '', classList } = wrapper; const parentId = id || unique(); // Is accordion? const isAccordion = classList.contains(ACCORDION); // Get tabs & panels. const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR); const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR); // Add ID? if (!wrapper.getAttribute(ID)) { wrapper.setAttribute(ID, parentId); } // Add multi? if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) { wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE); } // =========================== // Loop through tabs & panels. // =========================== for (let index = 0; index < childTabs.length; index++) { // Get elements. const tab = childTabs[index] || getDomFallback(); const panel = childPanels[index] || getDomFallback(); // Get IDs. const tabId = getTabId(parentId, index); const panelId = getPanelId(parentId, index); // =================== // Add tab attributes. // =================== // Tab: add icon? if (isAccordion) { // Get icon. let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR); // Create icon? if (!icon) { icon = document.createElement(I); icon.className = ACCORDION_ITEM_ICON; tab.insertAdjacentElement(AFTER_BEGIN, icon); } // [aria-hidden="true"] icon.setAttribute(ARIA_HIDDEN, TRUE); } // Tab: add id? if (!tab.getAttribute(ID)) { tab.setAttribute(ID, tabId); } // Tab: add controls? if (!tab.getAttribute(ARIA_CONTROLS)) { tab.setAttribute(ARIA_CONTROLS, panelId); } // Tab: add selected? if (!tab.getAttribute(ARIA_SELECTED)) { const bool = !isAccordion && index === 0; tab.setAttribute(ARIA_SELECTED, bool); } // Tab: add role? if (tab.getAttribute(ROLE) !== TAB) { tab.setAttribute(ROLE, TAB); } // Tab: add tabindex? if (tab.hasAttribute(DISABLED)) { tab.removeAttribute(TABINDEX); tab.setAttribute(ARIA_DISABLED, TRUE); } else { tab.setAttribute(TABINDEX, 0); } // Tab: first item? if (index === 0) { // Get parent. const { parentNode = getDomFallback() } = tab; /* We do this here, instead of outside the loop. The top level item isn't always the `tablist`. The accordion UI only has `<dl>`, whereas the tabs UI has both `<div>` and `<ul>`. */ if (parentNode.getAttribute(ROLE) !== TABLIST) { parentNode.setAttribute(ROLE, TABLIST); } } // ===================== // Add panel attributes. // ===================== // Panel: add ID? if (!panel.getAttribute(ID)) { panel.setAttribute(ID, panelId); } // Panel: add hidden? if (!panel.getAttribute(ARIA_HIDDEN)) { const bool = isAccordion || index !== 0; panel.setAttribute(ARIA_HIDDEN, bool); } // Panel: add labelled? if (!panel.getAttribute(ARIA_LABELLEDBY)) { panel.setAttribute(ARIA_LABELLEDBY, tabId); } // Panel: add role? if (panel.getAttribute(ROLE) !== TABPANEL) { panel.setAttribute(ROLE, TABPANEL); } } }); }; // ===================== // Remove global events. // ===================== const unbind = () => { document.removeEventListener(CLICK, globalClick); document.removeEventListener(KEYDOWN, globalClick); }; // ================== // Add global events. // ================== const init = () => { // Add attributes. addAriaAttributes(); // Prevent doubles. unbind(); document.addEventListener(CLICK, globalClick); document.addEventListener(KEYDOWN, globalClick); }; // ============== // Bundle object. // ============== const tablist = { init, unbind, }; // ======= // Export. // ======= export { tablist };
Function: getTabId and getPanelId
These two functions are used to create individually unique IDs for elements in a loop, based on an existing (or generated) parent ID. This is helpful to ensure matching values for attributes like aria-controls="…" and aria-labelledby="…". Think of those as the accessibility equivalents of <label for="…">, telling the browser which elements are related to one another.
const getTabId = (id = '', index = 0) => { return `tab_$ {id}_$ {index}`; };
const getPanelId = (id = '', index = 0) => { return `tabpanel_$ {id}_$ {index}`; };
Function: globalClick
This is a click handler that is applied at the document level. That means we are not having to manually add click handlers to a number of elements. Instead, we use event bubbling to listen for clicks further down in the document, and allow them to propagate up to the top. Conveniently, this is also how we can handle keyboard events such as the Enter key being pressed. Both are necessary to have an accessible UI.
In the first part of the function, we destructure key and target from the incoming event. Next, we destructure the parentNode and tagName from the target.
Then, we attempt to get the wrapper element. This would be the one with either class="tabs" or class="accordion". Because we might actually be clicking on the ancestor element highest in the DOM tree — which exists but possibly does not have the *.closest(…) method — we do a typeof check. If that function exists, we attempt to get the element. Even still, we might come up without a match. So we have one more getDomFallback to be safe.
// Get target. const { key = '', target = getDomFallback() } = event; // Get parent. const { parentNode = getDomFallback(), tagName = '' } = target; // Set later. let wrapper = getDomFallback(); /* ===== NOTE: ===== We test for this, because the method does not exist on `document.documentElement`. */ if (typeof target.closest === FUNCTION) { // Get wrapper. wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback(); }
Then, we store whether or not the tag that was clicked is a <li>. Likewise, we store a boolean about whether the wrapper element has aria-multiselectable="true". I will get back to that. We need this info later on.
We also interrogate the event a bit, to determine if it was triggered by the user pressing a key. If so, then we are only interested if that key was Enter. We also determine if the click happened on a relevant target. Remember, we are using event bubbling so really the user could have clicked anything.
We want to make sure it:
Is not disabled
Has role="tab"
Has a parent element with role="tablist"
Then we bundle up our event and target booleans into one, as isValidEvent.
Assuming the event is indeed valid, we make it past our next if check. Now, we are concerned with getting the role="tabpanel" element with an id that matches our tab’s aria-controls="…".
Once we have got it, we check whether the panel is hidden, and if the tab is selected. Basically, we first presuppose that we are dealing with an accordion and flip the booleans to their opposites.
This is also where our earlier isListItem boolean comes into play. If the user is clicking an <li> then we know we are dealing with tabs, not an accordion. In which case, we want to flag our panel as being visible (via aria-hiddden="false") and our tab as being selected (via aria-selected="true").
Also, we want to ensure that either the wrapper has aria-multiselectable="false" or is completely missing aria-multiselectable. If that is the case, then we loop through all neighboring role="tab" and all role="tabpanel" elements and set them to their inactive states. Finally, we arrive at setting the previously determined booleans for the individual tab and panel pairing.
// Continue? if (isValidEvent) { // Get panel. const panelId = target.getAttribute(ARIA_CONTROLS); const panel = wrapper.querySelector(`#$ {panelId}`) || getDomFallback(); // Get booleans. let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE; let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE; // List item? if (isListItem) { boolPanel = FALSE; boolTab = TRUE; } // [aria-multiselectable="false"] if (!isMulti) { // Get tabs & panels. const childTabs = wrapper.querySelectorAll(TAB_SELECTOR); const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR); // Loop through tabs. childTabs.forEach((tab = getDomFallback()) => { tab.setAttribute(ARIA_SELECTED, FALSE); }); // Loop through panels. childPanels.forEach((panel = getDomFallback()) => { panel.setAttribute(ARIA_HIDDEN, TRUE); }); } // Set individual tab. target.setAttribute(ARIA_SELECTED, boolTab); // Set individual panel. panel.setAttribute(ARIA_HIDDEN, boolPanel); }
Function: addAriaAttributes
The astute reader might be thinking:
You said earlier that we start with the most bare possible markup, yet the globalClick function was looking for attributes that would not be there. Why would you lie!?
Or perhaps not, for the astute reader would have also noticed the function named addAriaAttributes. Indeed, this function does exactly what it says on the tin. It breathes life into the base DOM structure, by adding all the requisite aria-* and role attributes.
This not only makes the UI inherently more accessible to assistive technologies, but it also ensures the functionality actually works. I prefer to build vanilla JS things this way, rather than pivoting on class="…" for interactivity, because it forces me to think about the entirety of the user experience, beyond what I can see visually.
First off, we get all elements on the page that have class="tabs" and/or class="accordion". Then we check if we have something to work with. If not, then we would exit our function here. Assuming we do have a list, we loop through each of the wrapping elements and pass them into the scope of our function as wrapper.
Inside the scope of our looping function, we destructure id and classList from wrapper. If there is no ID, then we generate one via unique(). We set a boolean flag, to identify if we are working with an accordion. This is used later.
We also get decendants of wrapper that are tabs and panels, via their class name selectors.
Tabs:
class="tabs__item" or
class="accordion__item"
Panels:
class="tabs__panel" or
class="accordion__panel"
We then set the wrapper’s id if it does not already have one.
If we are dealing with an accordion that lacks aria-multiselectable="false", we set its flag to true. Reason being, if developers are reaching for an accordion UI paradigm — and also have tabs available to them, which are inherently mutually exclusive — then the safer assumption is that the accordion should support expanding and collapsing of several panels.
// Get attributes. const { id = '', classList } = wrapper; const parentId = id || unique(); // Is accordion? const isAccordion = classList.contains(ACCORDION); // Get tabs & panels. const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR); const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR); // Add ID? if (!wrapper.getAttribute(ID)) { wrapper.setAttribute(ID, parentId); } // Add multi? if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) { wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE); }
Next, we loop through tabs. Wherein, we also handle our panels.
You may be wondering why this is an old school for loop, instead of a more modern *.forEach. The reason is that we want to loop through two NodeList instances: tabs and panels. Assuming they each map 1-to-1 we know they both have the same *.length. This allows us to have one loop instead of two.
Let us peer inside of the loop. First, we get unique IDs for each tab and panel. These would look like one of the two following scenarios. These are used later on, to associate tabs with panels and vice versa.
tab_WRAPPER_ID_0 or tab_GENERATED_STRING_0
tabpanel_WRAPPER_ID_0 or tabpanel_GENERATED_STRING_0
for (let index = 0; index < childTabs.length; index++) { // Get elements. const tab = childTabs[index] || getDomFallback(); const panel = childPanels[index] || getDomFallback(); // Get IDs. const tabId = getTabId(parentId, index); const panelId = getPanelId(parentId, index); /* NOTE: Cut, for brevity. */ }
As we loop through, we first ensure that an expand/collapse icon exists. We create it if necessary, and set it to aria-hidden="true" since it is purely decorative.
Next, we check on attributes for the current tab. If an id="…" does not exist on the tab, we add it. Likewise, if aria-controls="…" does not exist we add that as well, pointing to our newly created panelId.
You will notice there is a little pivot here, checking if we do not have aria-selected and then further determining if we are not in the context of an accordion and if the index is 0. In that case, we want to make our first tab look selected. The reason is that though an accordion can be fully collapsed, tabbed content cannot. There is always at least one panel visible.
Then we ensure that role="tab" exists.
It is worth noting we do some extra work, based on whether the tab is disabled. If so, we remove tabindex so that the tab cannot receive :focus. If the tab is not disabled, we add tabindex="0" so that it can receive :focus.
We also set aria-disabled="true", if need be. You might be wondering if that is redundant. But it is necessary to inform assistive technologies that the tab is not interactive. Since our tab is either a <div> or <li>, it technically cannot be disabled like an <input>. Our styles pivot on [disabled], so we get that for free. Plus, it is less cognitive overhead (as a developer creating HTML) to only worry about one attribute.
ℹ️ Fun Fact: It is also worth noting the use of hasAttribute(…) to detect disabled, instead of getAttribute(…). This is because the mere presence of disabled will cause form elements to be disabled.
If the HTML is compiled, via tools such as Parcel…
Markup like this: <tag disabled>
Is changed to this: <tag disabled="">
In which case, getting the attribute is still a falsy string.
In the days of XHTML, that would have been disabled="disabled". But really, it was only ever the existence of the attribute that mattered. Not its value. That is why we simply test if the element has the disabled attribute.
Lastly, we check if we are on the first iteration of our loop where index is 0. If so, we go up one level to the parentNode. If that element does not have role="tablist", then we add it.
We do this via parentNode instead of wrapper because in the context of tabs (not accordion) there is a <ul>element around the tab <li> that needs role="tablist". In the case of an accordion, it would be the outermost <div> ancestor. This code accounts for both.
// Tab: add icon? if (isAccordion) { // Get icon. let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR); // Create icon? if (!icon) { icon = document.createElement(I); icon.className = ACCORDION_ITEM_ICON; tab.insertAdjacentElement(AFTER_BEGIN, icon); } // [aria-hidden="true"] icon.setAttribute(ARIA_HIDDEN, TRUE); } // Tab: add id? if (!tab.getAttribute(ID)) { tab.setAttribute(ID, tabId); } // Tab: add controls? if (!tab.getAttribute(ARIA_CONTROLS)) { tab.setAttribute(ARIA_CONTROLS, panelId); } // Tab: add selected? if (!tab.getAttribute(ARIA_SELECTED)) { const bool = !isAccordion && index === 0; tab.setAttribute(ARIA_SELECTED, bool); } // Tab: add role? if (tab.getAttribute(ROLE) !== TAB) { tab.setAttribute(ROLE, TAB); } // Tab: add tabindex? if (tab.hasAttribute(DISABLED)) { tab.removeAttribute(TABINDEX); tab.setAttribute(ARIA_DISABLED, TRUE); } else { tab.setAttribute(TABINDEX, 0); } // Tab: first item? if (index === 0) { // Get parent. const { parentNode = getDomFallback() } = tab; /* We do this here, instead of outside the loop. The top level item isn't always the `tablist`. The accordion UI only has `<dl>`, whereas the tabs UI has both `<div>` and `<ul>`. */ if (parentNode.getAttribute(ROLE) !== TABLIST) { parentNode.setAttribute(ROLE, TABLIST); } }
Continuing within the earlier for loop, we add attributes for each panel. We add an id if needed. We also set aria-hidden to either true or false depending on the context of being an accordion (or not).
Likewise, we ensure that our panel points back to its tab trigger via aria-labelledby="…", and that role="tabpanel" has been set.
// Panel: add ID? if (!panel.getAttribute(ID)) { panel.setAttribute(ID, panelId); } // Panel: add hidden? if (!panel.getAttribute(ARIA_HIDDEN)) { const bool = isAccordion || index !== 0; panel.setAttribute(ARIA_HIDDEN, bool); } // Panel: add labelled? if (!panel.getAttribute(ARIA_LABELLEDBY)) { panel.setAttribute(ARIA_LABELLEDBY, tabId); } // Panel: add role? if (panel.getAttribute(ROLE) !== TABPANEL) { panel.setAttribute(ROLE, TABPANEL); }
At the very end of the file, we have a few setup and teardown functions. As a way to play nicely with other JS that might be in the page, we provide an unbind function that removes our global event listeners. It can be called by itself, via tablist.unbind() but is mostly there so that we can unbind() before (re-)binding. That way we prevent doubling up.
Inside our init function, we call addAriaAttributes() which modifies the DOM to be accessible. We then call unbind() and then add our event listeners to the document.
Finally, we bundle both methods into a parent object and export it under the name tablist. That way, when dropping it into a flat HTML page, we can call tablist.init() when we are ready to apply our functionality.
There is a scene in Batman Begins where Lucius Fox (played by Morgan Freeman) explains to a recovering Bruce Wayne (Christian Bale) the scientific steps he took to save his life after being poisoned.
Lucius Fox: “I analyzed your blood, isolating the receptor compounds and the protein-based catalyst.”
Bruce Wayne: “Am I meant to understand any of that?”
Lucius Fox: “Not at all, I just wanted you to know how hard it was. Bottom line, I synthesized an antidote.”
↑ When working with a framework, I think in those terms.
Now that we know “hard” it is — not really, but humor me — to do raw DOM manipulation and event binding, we can better appreciate the existence of an antidote. React abstracts a lot of that complexity away, and handles it for us automatically.
File: Tabs.js
Now that we are diving into React examples, we will start with the <Tabs> component.
Here is the content from our Tabs.js file. Note that in React parlance, it is standard practice to name the file with the same capitalization as its export default component.
We start out with the same getTabId and getPanelId functions as in our vanilla JS approach, because we still need to make sure to accessibly map tabs to components. Take a look at the entirey of the code, and then we will continue to break it down.
Due to a <Tabs> component always having something active and visible, this function contains some logic to determine whether an index of a given tab should be the lucky winner. Essentially, in sentence form the logic goes like this.
This current tab is active if:
Its index matches the activeIndex, or
The tabs UI has only one tab, or
It is the first tab, and the activeIndex tab does not exist.
const getIsActive = ({ activeIndex = null, index = null, list = [] }) => { // Index matches? const isMatch = index === parseFloat(activeIndex); // Is first item? const isFirst = index === 0; // Only first item exists? const onlyFirstItem = list.length === 1; // Item doesn't exist? const badActiveItem = !list[activeIndex]; // Flag as active? const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem); // Expose boolean. return !!isActive; };
Function: getTabsList
This function generates the clickable <li role="tabs"> UI, and returns it wrapped in a parent <ul role="tablist">. It assigns all the relevant aria-* and role attributes, and handles binding the onClickand onKeyDown events. When an event is triggered, setActiveIndex is called. This updates the component’s internal state.
It is noteworthy how the content of the <li> is derived. That is passed in as <div label="…"> children of the parent <Tabs> component. Though this is not a real concept in flat HTML, it is a handy way to think about the relationship of the content. The children of that <div> become the the innards of our role="tabpanel" later.
This function parses the incoming children of the top level component and extracts the content. It also makes use of getIsActive to determine whether (or not) to apply aria-hidden="true". As one might expect by now, it adds all the other relevant aria-* and role attributes too. It also applies any extra className or style that was passed in.
It also is “smart” enough to wrap any string content — anything lacking a wrapping tag already — in <p> tags for consistency.
This is the main component. It sets an internal state for an id, to essentially cache any generated uuid() so that it does not change during the lifecycle of the component. React is finicky about its key attributes (in the previous loops) changing dynamically, so this ensures they remain static once set.
We also employ useState to track the currently selected tab, and pass down a setActiveIndex function to each <li> to monitor when they are clicked. After that, it is pretty straightfowrard. We call getTabsList and getPanelsList to build our UI, and then wrap it all up in <div role="tablist">.
It accepts any wrapper level className or style, in case anyone wants further tweaks during implementation. Providing other developers (as consumers) this flexibility means that the likelihood of needing to make further edits to the core component is lower. Lately, I have been doing this as a “best practice” for all components I create.
As you may have deduced — due to the vanilla JS example handling both tabs and accordion — this file has quite a few similarities to how Tabs.js works.
Rather than belabor the point, I will simply provide the file’s contents for completeness and then speak about the specific areas in which the logic differs. So, take a gander at the contents and I will explain what makes <Accordion> quirky.
While most of our <Accordion> logic is similar to <Tabs>, it differs in how it stores the currently active tab.
Since <Tabs> are always mutually exclusive, we only really need a single numeric index. Easy peasy.
However, because an <Accordion> can have concurrently visible panels — or be used in a mutually exclusive manner — we need to represent that to useState in a way that could handle both.
If you were beginning to think…
“I would store that in an object.”
…then congrats. You are right!
This function does a quick check to see if isMulti has been set to true. If so, we use the spread syntax to apply the existing activeItems to our newState object. We then set the current index to its boolean opposite.
const handleClick = (event = {}) => { const { key = '' } = event; if (!disabled) { // Early exit. if (key && key.toLowerCase() !== 'enter') { return; } // Keep active items? const state = isMulti ? activeItems : null; // Update active item. const newState = { ...state, [index]: !activeItems[index], }; // Set active item. setActiveItems(newState); } };
For reference, here is how our activeItems object looks if only the first accordion panel is active and a user clicks the second. Both indexes would be set to true. This allows for viewing two expanded role="tabpanel" simultaneously.
Whereas if we were not operating in isMulti mode — when the wrapper has aria-multiselectable="false" — then activeItems would only ever contain one key/value pair.
Because rather than spreading the current activeItems, we would be spreading null. That effectively wipes the slate clean, before recording the currently active tab.
Hopefully you found this article informative, and maybe even learned a bit more about accessibility and JS(X) along the way. For review, let us look one more time at our flat HTML example and and the React usage of our <Tabs>component. Here is a comparison of the markup we would write in a vanilla JS approach, versus the JSX it takes to generate the same thing.
I am not saying that one is better than the other, but you can see how React makes it possible to distill things down into a mental model. Working directly in HTML, you always have to be aware of every tag.
↑ One of these probably looks preferrable, depending on your point of view.
Writing code closer to the metal means more direct control, but also more tedium. Using a framework like React means you get more functionality “for free,” but also it can be a black box.
That is, unless you understand the underlying nuances already. Then you can fluidly operate in either realm. Because you can see The Matrix for what it really is: Just JavaScript™. Not a bad place to be, no matter where you find yourself.
It might be pretty useful! If you end up using this thing hundreds of times, now you have the ability to refactor a little bit of HTML across your app very easily. You already have that power in CSS because of the class name there, but now you have HTML control too. Feel it.
But wait. Maybe this is limiting… an <h2>? What if that really should have been an <h4> in some usages? What’s the approach there? Maybe an API of sorts?
And a forced paragraph tag wrapper around that content? That’s a little limiting, isn’t it? Maybe that should be a <div> so that it could take arbitrary HTML inside it, like multiple paragraphs.
I’m still forcing card there. We could drop that so that it isn’t assumed, or build another aspect of the Card API providing a way to opt-out of it.
Even the <div> wrapper is presumptuous. Perhaps that tag name could be passed in so that you could make it into a <section> or <article> or whatever you want.
Maybe it’s better to assume nothing actually, making our card like this:
That way anything you want to change, you have the freedom to change. At least then it’s flexibility while being relaxed about it, rather than this kind of “flexibility”:
There might be perfectly good reasons to do that, or it might be the result of componentizing because it’s “free” and just feels like that’s how things are done in an architecture that supports it.
There is a balance. If a component is too strict, it runs the risk of that people won’t use them because they don’t give them what they need. And if they’re too loose, people might not use them because they don’t provide any value, and, even if they did use them, they don’t offer any cohesiveness.
I don’t have any answers here, I just find it fascinating.
We’ll get to that, but first, a long-winded introduction.
I’m still not in a confident place knowing a good time to use native web components. The templating isn’t particularly robust, so that doesn’t draw me in. There is no state management, and I like having standard ways of handling that. If I’m using another library for components anyway, seems like I would just stick with that. So, at the moment, my checklist is something like:
Not using any other JavaScript framework that has components
Templating needs aren’t particularly complex
Don’t need particularly performant re-rendering
Don’t need state management
I’m sure there is tooling that helps with these things and more (the devMode episode with some folks from Stencil was good), but if I’m going to get into tooling-land, I’d be extra tempted to go with a framework, and probably not framework plus another thing with a lot of overlap.
The reasons I am tempted to go with native web components are:
They are native. No downloads of frameworks.
The Shadow DOM is a true encapsulation in a way a framework can’t really do.
I get to build my own HTML element that I use in HTML, with my own API design.
It sorta seems like the sweet spot for native web components is design system components. You build out your own little API for the components in your system, and people can use them in a way that is a lot safer than just copy and paste this chunk of HTML. And I suppose if consumers of the system wanted to BYO framework, they could.
So you can use like <our-tabs active-tab="3"> rather than <div class="tabs"> ... <a href="#3" class="tab-is-active">. Refactoring the components certainly gets a lot easier as changes percolate everywhere.
I’ve used them here on CSS-Tricks for our <circle-text> component. It takes the radius as a parameter and the content via, uh, content, and outputs an <svg> that does the trick. It gave us a nice API for authoring that abstracted away the complexity.
So!
It occurred to me a “code block” might be a nice use-case for a web component.
The API would be nice for it, as you could have attributes control useful things, and the code itself as the content (which is a great fallback).
It doesn’t really need state.
Syntax highlighting is a big gnarly block of CSS, so it would be kinda cool to isolate that away in the Shadow DOM.
It could have useful functionality like a “click to copy” button that people might enjoy having.
Altogether, it might feel like a yeah, I could use this kinda component.
This probably isn’t really production ready (for one thing, it’s not on npm or anything yet), but here’s where I am so far:
Here’s a thought dump!
What do you do when a component depends on a third-party lib? The syntax highlighting here is done with Prism.js. To make it more isolated, I suppose you could copy and paste the whole lib in there somewhere, but that seems silly. Maybe you just document it?
Yanking in pre-formatted text to use in a template is super weird. I’m sure it’s possible to do without needing a <pre> tag inside the custom element, but it’s clearly much easier if you grab the content from the <pre>. Makes the API here just a smidge less friendly (because I’d prefer to use the <code-block> alone).
I wonder what a good practice is for passing along attributes that another library needs. Like is data-lang="CSS" OK to use (feels nicer), and then convert it to class="language-css" in the template because that’s what Prism wants? Or is it better practice to just pass along attributes as they are? (I went with the latter.)
People complain that there aren’t really “lifecycle methods” in native web components, but at least you have one: when the thing renders: connectedCallback. So, I suppose you should do all the manipulation of HTML and such before you do that final shadowRoot.appendChild(node);. I’m not doing that here, and instead am running Prism over the whole shadowRoot after it’s been appended. Just seemed to work that way. I imagine it’s probably better, and possible, to do it ahead of time rather than allow all the repainting caused by injecting spans and such.
The whole point of this is a nice API. Seems to me thing would be nicer if it was possible to drop un-escaped HTML in there to highlight and it could escape it for you. But that makes the fallback actually render that HTML which could be bad (or even theoretically insecure). What’s a good story for that? Maybe put the HTML in HTML comments and test if <!-- is the start of the content and handle that as a special situation?
Anyway, if you wanna fork it or do anything fancier with it, lemme know. Maybe we can eventually put it on npm or whatever. We’ll have to see how useful people think it could be.
I recently wrote an article explaining how you can create a countdown timer using HTML, CSS and JavaScript. Now, let’s look at how we can make that a reusable component by porting it into Vue using basic features that the framework provides.
Why do this at all? Well there are few reasons, but two stand out in particular:
Keeping UI in sync with the timer state: If you look at the code from the first post, it all lives in the timerInterval function, most noticeably the state management. Each time it runs (every second) we need to manually find the proper element on our document — whether it’s the time label or the remaining time path or whatever — and change either its value or an attribute. Vue comes with an HTML-based template syntax that allows you to declaratively bind the rendered DOM to the underlying Vue instance’s data. That takes all the burden of finding and updating proper UI elements so we can rely purely on the component instance’s properties.
Having a highly reusable component: The original example works fine when only one timer is present on our document, but imagine that you want to add another one. Oops! We rely the element’s ID to perform our actions and using the same ID on multiple instances would prevent them from working independently. That means we would have to assign different IDs for each timer. If we create a Vue component, all it’s logic is encapsulated and connected to that specific instance of the component. We can easily create 10, 20, 1,000 timers on a single document without changing a single line in the component itself!
Here’s the same timer we created together in the last post, but in Vue.
Vue uses an HTML-based template syntax that allows you to declaratively bind the rendered DOM to the underlying Vue instance’s data. All Vue.js templates are valid HTML that can be parsed by spec-compliant browsers and HTML parsers.
Let’s create our component by opening a new file called BaseTimer.vue. Here’s the basic structure we need for that:
// Our template markup will go here <template> // ... </template> // Our functional scripts will go here <script> // ... </script> // Our styling will go here <style> // ... </style>
In this step, we will concentrate on the <template> and <style> sections. Let’s move our timer template to the <template> section and all our CSS to <style> section. The markup mostly consists of SVG and we can use the exact same code we used from the first article.
<template> // The wrapper for the timer <div class="base-timer"> // This all comes from the first article <svg class="base-timer__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> <g class="base-timer__circle"> <circle class="base-timer__path-elapsed" cx="50" cy="50" r="45"></circle> <path id="base-timer-path-remaining" stroke-dasharray="283" class="base-timer__path-remaining $ {remainingPathColor}" d=" M 50, 50 m -45, 0 a 45,45 0 1,0 90,0 a 45,45 0 1,0 -90,0 " ></path> </g> </svg> // The label showing the remaining time <span id="base-timer-label" class="base-timer__label" > $ {formatTime(timeLeft)} </span> </div> </template> // "scoped" means these styles will not leak out to other elements on the page <style scoped> .base-timer { position: relative; width: 100px; height: 100px; } </style>
Let’s have a look at the template we just copied to identify where we can use our framework. There are few parts that are responsible for making our timer count down the time and show the remaining time.
stroke-dasharray: A value passed to the SVG <path> element that is responsible for holding the remaining time.
remainingPathColor: A CSS class responsible for changing the color of the timer’s circular ring, giving is a way to visually indicate that time is running out.
formatTime(timeLeft): A value responsible for showing how much time is left inside the timer
We can control our timer by manipulating those values.
Constants and variables
OK, let’s go down to our <script> section and see what Vue gives us out of the box to make our life easier. One thing it lets us do is define our constants up front, which keeps them scoped to the component.
In the last post, we spent a little time tweaking the stroke-dasharray value to make sure the animation of the timer’s top layer (the ring that animates and changes color as time progresses) is perfectly in line with its bottom layer (the gray ring that indicates past time). We also defined “thresholds” for when the top layer should change colors (orange at 10 remaining seconds and red at five seconds). We also created constants for those colors.
We can move all of those directly into the <script> section:
<script> // A value we had to play with a bit to get right const FULL_DASH_ARRAY = 283; // When the timer should change from green to orange const WARNING_THRESHOLD = 10; // When the timer should change from orange to red const ALERT_THRESHOLD = 5; // The actual colors to use at the info, warning and alert threshholds const COLOR_CODES = { info: { color: "green" }, warning: { color: "orange", threshold: WARNING_THRESHOLD }, alert: { color: "red", threshold: ALERT_THRESHOLD } }; // The timer's starting point const TIME_LIMIT = 20; </script>
Now, let’s have a look at our variables:
let timePassed = 0; let timeLeft = TIME_LIMIT; let timerInterval = null; let remainingPathColor = COLOR_CODES.info.color;
We can identify two different types of variables here:
Variables in which the values are directly re-assigned in our methods:
timerInterval: Changes when we start or stop the timer
timePassed: Changes each second when the timer is running
Variables in which the values change when other variables change:
timeLeft: Changes when the value of timePassed changes
remainingPathColor: Changes when the value of timeLeft breaches the specified threshold
It is essential to identify that difference between those two types as it allows us to use different features of the framework. Let’s go through each of the type separately.
Variables in which values are directly re-assigned
Let’s think what we want to happen when we change the timePassed value. We want to calculate how much time is left, check if we should change the top ring’s color, and trigger re-render on a part of our view with new values.
Vue comes with its own reactivity system that updates the view to match the new values of specific properties. To add a property to Vue’s reactivity system we need to declare that property on a data object in our component. By doing that,Vue will create a getter and a setter for each property that will track changes in that property and respond accordingly.
<script> // Same as before export default { data() { return { timePassed: 0, timerInterval: null }; } </script>
There are two important things we need to remember.
We need to declare all reactive variables in our data object up front. That means if we know that a variable will exist but we don’t know what the value will be, we still need to declare it with some value. If we forgot to declare it in data it will not be reactive, even if it is added later.
When declaring our data option object, we always need to return a new object instance (using return). This is vital because, if we don’t follow this rule, the declared properties will be shared between all instances of the component.
You can see that second issue in action:
Variables in which values change when other variable change
These variables rely on the value of another variable. For example, timeLeft relies purely on timePassed. In our original example that uses vanilla JavaScript, we were calculating that value in the interval that was responsible for changing the value of timePassed. With Vue, we can extract that value to a computed property.
A computed property is a function that returns a value. These values are bound to the dependency values and only update when required. Even more importantly, computed properties are cached, meaning they remember the values that the computed property depends on and calculate the new value only if that dependent property value changed. If the value does not change, the previously cached value is returned.
<script> // Same as before computed: { timeLeft() { return TIME_LIMIT - this.timePassed; } } } </script>
The function passed to the computed property must be a pure function. It can’t cause any side effects and must return a value. Also, the output value must only be dependent on the values passed into the function.
Now, we can move more logic to computed properties:
circleDasharray: This returns a value previously that is calculated in the setCircleDasharray method.
formattedTimeLeft: This returns a value from the formatTime method.
timeFraction: This is an abstraction of the calculateTimeFraction method.
remainingPathColor: This is an abstraction of the setRemainingPathColor method.
Let’s start with formatTime(timeLeft). How we can dynamically bind the rendered value to our formattedTimeLeftcomputed property?
Vue uses HTML-based template syntax that allowsus to declaratively bind the rendered DOM to the underlying data of the Vue instance. That means all properties are available in the template section. To render any of them, we use text interpolation using the “Mustache” syntax (double curly braces, or {{ }}).
Next will be stroke-dasharray. We can see we don’t want to render that value. Instead, we want to change the value of the <path> attribute. Mustache cannot be used inside HTML attributes, but fear not! Vue comes with another way: the v-bind directive. We can bind a value to an attribute like this:
To facilitate the usage of that directive, we can also use a shorthand.
<path :stroke-dasharray="circleDasharray"></path>
The last one is remainingPathColor, which adds a proper class to an element. We can do that using the same v-bind directive as above, but assign the value to the class attribute of an element.
We have our template ready, we moved all variables to data or computed, and we got rid off most of the methods by creating corresponding computed properties. We are still missing one vital part, though: we need to start our timer.
Methods and component lifecycle hooks
If we look at our startTimer method, we can see that all the calculations, changes in attributes, etc. happen in the interval.
Since we’ve already moved all that logic into the computed property, all we need to do in our timerInterval is change the value of timePassed — the rest will happen magically in the computed properties
<script> // Same as before methods: { startTimer() { this.timerInterval = setInterval(() => (this.timePassed += 1), 1000); } } </script>
We have the method ready, but we still don’t call it anywhere. Each Vue component comes with a series of hooks that allows us to run a specific logic within a specific period of the component’s lifecycle. These are called lifecycle hooks. In our case, as we want to call our method immediately when the component gets loaded. That makes mounted the lifecycle hook what we want.
<script> // Same as before mounted() { this.startTimer(); }, // Same methods as before </script>
That’s it, we just turned our timer into a consistent and reusable component using Vue!
Let’s say we now want to use this component in another component. That requires a few things:
This example shows how we can move a component from vanilla JavaScript to a component-based front-end framework, like Vue.
We can now treat the timer as a standalone component where all the markup, logic and styling is contained in a way that won’t leak out to or conflict with other elements. Components are often children of a larger parent component that assembles multiple components together — like a form or perhaps a card — where the parent’s properties can be accessed and shared. Here’s an example of the timer component where it’s taking orders from a parent component
I hope I got you interested in Vue and the power of components! I’d encourage you to go to Vue docs to get more detailed description of the features we used in our example. There’s so much Vue can do!
The idea behind most of web applications is to fetch data from the database and present it to the user in the best possible way. When we deal with data there are cases when the best possible way of presentation means creating a list.
Depending on the amount of data and its content, we may decide to show all content at once (very rarely), or show only a specific part of a bigger data set (more likely). The main reason behind showing only part of the existing data is that we want to keep our applications as performant as possible and avoid loading or showing unnecessary data.
If we decide to show our data in “chunks” then we need a way to navigate through that collection. The two most common ways of navigating through set of data are:
The first is pagination, a technique that splits the set of data into a specific number of pages, saving users from being overwhelmed by the amount of data on one page and allowing them to view one set of results at a time. Take this very blog you’re reading, for example. The homepage lists the latest 10 posts. Viewing the next set of latest posts requires clicking a button.
The second common technique is infinite scrolling, something you’re likely familiar with if you’ve ever scrolled through a timeline on either Facebook or Twitter.
The Apple News app also uses infinite scroll to browse a list of articles.
We’re going to take a deeper look at the first type in this post. Pagination is something we encounter on a near-daily basis, yet making it is not exactly trivial. It’s a great use case for a component, so that’s exactly what we’re going to do. We will go through the process of creating a component that is in charge of displaying that list, and triggering the action that fetches additional articles when we click on a specific page to be displayed. In other words, we’re making a pagination component in Vue.js like this:
Let’s go through the steps together.
Step 1: Create the ArticlesList component in Vue
Let’s start by creating a component that will show a list of articles (but without pagination just yet). We’ll call it ArticlesList. In the component template, we’ll iterate through the set of articles and pass a single article item to each ArticleItem component.
Now we need to create a method that will load the next page, the previous page or a selected page.
In the pageChangeHandle method, before loading new articles, we change the currentPage value depending on a property passed to the method and fetch the data respective to a specific page from the API. Upon receiving new data, we replace the existing articles array with the fresh data containing a new page of articles.
The component will accept currentPage and pageCount properties from the parent component and emit proper actions back to the parent when the next or previous button is clicked. It will also be responsible for disabling buttons when we are on the first or last page to prevent moving out of the existing collection.
That was the easy part. Now we need to create a list of page numbers, each allowing us to select a specific page. The number of pages should be customizable and we also need to make sure not to show any pages that may lead us beyond the collection range.
Requirement 2: Allow the user to go to a specific page within a range
Let’s start by creating a component that will be used as a single page number. I called it BasePaginationTrigger. It will do two things: show the page number passed from the BasePagination component and emit an event when the user clicks on a specific number.
In the script section, we need to add one more method (onLoadPage) that will be fired when the loadPage event is emitted from the trigger component. This method will receive a page number that was clicked and emit the event up to the ArticlesList component.
Requirement 3: Change the range of page numbers based on the current page
OK, now we have a single trigger that shows us the current page and allows us to fetch the same page again. Pretty useless, don’t you think? Let’s make some use of that newly created trigger component. We need a list of pages that will allow us to jump from one page to another without needing to go through the pages in between.
We also need to make sure to display the pages in a nice manner. We always want to display the first page (on the far left) and the last page (on the far right) on the pagination list and then the remaining pages between them.
We have three possible scenarios:
The selected page number is smaller than half of the list width (e.g. 1 – 2 – 3 – 4 – 18)
The selected page number is bigger than half of the list width counting from the end of the list (e.g. 1 – 15 – 16 – 17 – 18)
All other cases (e.g. 1 – 4 – 5 – 6 – 18)
To handle these cases, we will create a computed property that will return an array of numbers that should be shown between the next and previous buttons. To make the component more reusable we will accept a property visiblePagesCount that will specify how many pages should be visible in the pagination component.
Before going to the cases one by one we create few variables:
visiblePagesThreshold:- Tells us how many pages from the centre (selected page should be shown)
paginationTriggersArray: Array that will be filled with page numbers
visiblePagesCount: Creates an array with the required length
Scenario 1: The selected page number is smaller than half of the list width
We set the first element to always be equal to 1. Then we iterate through the list, adding an index to each element. At the end, we add the last value and set it to be equal to the last page number — we want to be able to go straight to the last page if we need to.
Scenario 2: The selected page number is bigger than half of the list width counting from the end of the list
Similar to the previous scenario, we start with the last page and iterate through the list, this time subtracting the index from each element. Then we reverse the array to get the proper order and push 1 into the first place in our array.
We know what number should be in the center of our list: the current page. We also know how long the list should be. This allows us to get the first number in our array. Then we populate the list by adding an index to each element. At the end, we push 1 into the first place in our array and replace the last number with our last page number.
And we are done! We just built a nice and reusable pagination component in Vue.
When to avoid this pattern
Although this component is pretty sweet, it’s not a silver bullet for all use cases involving pagination.
For example, it’s probably a good idea to avoid this pattern for content that streams constantly and has a relatively flat structure, like each item is at the same level of hierarchy and has a similar chance of being interesting to the user. In other words, something less like an article with multiple pages and something more like main navigation.
Another example would be browsing news rather than looking for a specific news article. We do not need to know where exactly the news is and how much we scrolled to get to a specific article.
That’s a wrap!
Hopefully this is a pattern you will be able to find useful in a project, whether it’s for a simple blog, a complex e-commerce site, or something in between. Pagination can be a pain, but having a modular pattern that not only can be re-used, but considers a slew of scenarios, can make it much easier to handle.