Tag: Vanilla

Creating a Smart Navbar With Vanilla JavaScript

Sticky, or fixed, navigation is a popular design choice because it gives users persistent access to navigate the site. On the other hand, it takes up space on the page and sometimes covers content is a way that’s less than appealing.

A possible solution? Smart navigation.

Let’s define “smart navigation” as:

  1. Visible at the top of the page
  2. Visible when the user moves up the page (wherever they may have scrolled to)
  3. Hidden when the user moves down the page

Here’s an example of how that might work:

It‘s all the convenience of sticky positioning, with an added fullscreen benefit. This sort of smart navigation is already commonly (think of the URL bar in many mobile browsers), but is sometimes a hassle to implement without a library or plugin. So, in this article, we’ll discuss how to build one using CSS and vanilla JavaScript.

Side note: People have different definitions of what scrolling down a page means (imagine how some trackpad preferences scroll the page up when you move your fingers down). For the purposes of this article, scrolling down refers to moving towards the bottom of the page.

Let’s look at the code

Here’s some example HTML. Our smart navigation will be the <nav> which sits above the <main>:

<nav>   <div class="logo">     Logo   </div>   <div class="links">     <a href="#">Link 1</a>     <a href="#">Link 2</a>     <a href="#">Link 3</a>     <a href="#">Link 4</a>   </div> </nav> <main>   <!--Place the content of your page here--> </main>

It’s important to note that elements are only sticky relative to their parent container. The parent container of <nav> should be the body tag; it shouldn’t be placed within another tag on the page.

The CSS for our smart navigation looks like this:

nav {   position: sticky;   top: 0;   display: flex;   flex-wrap: wrap;   justify-content: space-between;   padding: 1.5rem 2rem;   background-color: #eaeaea; }

Now we need to detect when our user is scrolling the page and the direction of their scrolling. A user is scrolling down if the value of their last scroll position is less than the value of their current scroll position. Breaking the logic down, we’ll need to:

  1. Define a variable to store the previous scroll position
  2. Assign a variable to detect the current scroll position set to the scroll offset of the page

If the current scroll position is greater than the previous scroll position, then the user is scrolling downwards. Let’s call our function isScrollingDown:

let previousScrollPosition = 0;  const isScrollingDown = () => {   let currentScrolledPosition = window.scrollY || window.pageYOffset;   let scrollingDown;    if (currentScrolledPosition > previousScrollPosition) {     scrollingDown = true;   } else {     scrollingDown = false;   }   previousScrollPosition = currentScrolledPosition;   return scrollingDown; };

Here’s a visual representation of how this function works:

With this logic, we’re able to detect when the page is scrolling down so we can use this to toggle our nav styling:

const nav = document.querySelector('nav');  const handleNavScroll = () => {   if (isScrollingDown()) {     nav.classList.add('scroll-down');     nav.classList.remove('scroll-up')   } else {     nav.classList.add('scroll-up');     nav.classList.remove('scroll-down')   } }

If the user is scrolling down, we’ll assign a .scroll-down class that contains our styling method for when the page is moving downward. We can update our <nav> CSS to this:

nav {   /* default styling */   transition: top 500ms ease-in-out; }  nav.scroll-up {   top: 0; }  nav.scroll-down {   top: -100%; }

With this styling, the top property value of <nav> is set to -100% of the page height so it slides out of view. We could also choose to handle our styling with translate or by fading it out — whatever animation works best.

Performance

Whenever we’re working with scroll event listeners, performance is something that should immediately come to mind. Right now, we’re calling our function every time the user scrolls the page, but we don’t need to detect each pixel movement.

For this case, we can implement a throttle function instead. A throttle function is a higher order function that acts as a timer for the function passed into it. If we throttle a scroll event with a timer of 250ms, the event will only be called every 250ms while the user scrolls. It’s a great way to limit the number of times we call the function, helping with the performance of the page.

David Corbacho goes deeper into throttle implementations in this article.

A simple throttle implementation in JavaScript looks like this:

// initialize a throttleWait variable var throttleWait;  const throttle = (callback, time) => {   // if the variable is true, don't run the function   if (throttleWait) return;    // set the wait variable to true to pause the function   throttleWait = true;    // use setTimeout to run the function within the specified time   setTimeout(() => {     callback();      // set throttleWait to false once the timer is up to restart the throttle function     throttleWait = false;   }, time); }

Then we can include our handleNavScroll function inside a throttle:

window.addEventListener("scroll", () => {   throttle(handleNavScroll, 250) });

With this implementation, the handleNavScroll function is only called once every 250ms.

Accessibility

Whenever implementing a custom feature in JavaScript, we must always take accessibility into concern. One such issue is ensuring that <nav> is visible when it’s in focus. Browsers tend to scroll to the part of the page that currently has focus by default, but there can be certain complications when working with scroll events.

A way to ensure that <nav> is always visible is to update the CSS to account for focus. Now our CSS looks like this:

nav.scroll-up, nav:focus-within {   top: 0; }

Unfortunately, the focus-within selector isn’t fully supported across all browsers. We can include a JavaScript fallback for it:

const handleNavScroll = () => {   if (isScrollingDown() && !nav.contains(document.activeElement))) {     nav.classList.add('scroll-down');     nav.classList.remove('scroll-up')   } else {     nav.classList.add('scroll-up');     nav.classList.remove('scroll-down')   } }

In this updated function, we only apply the scroll-down class if the user is scrolling down the page and the <nav> doesn’t currently have any element with focus in it.

Another aspect of accessibility is the consideration that some users may not want to have any animation on the page. That’s something we can detect and respect with the prefers-reduced-motion CSS media query. We can update this method in JavaScript and prevent our function from running at all if a user prefers reduced motion:

const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");  window.addEventListener("scroll", () => {   if (mediaQuery && !mediaQuery.matches) {     throttle(handleNavScroll, 250)   } });

Wrapping up

So, there we have it: a smart navigation implementation with plain CSS and vanilla JavaScript. Now users have persistent access to navigate the site without losing real estate in a way that blocks content.

Plus, the benefit of a custom implementation like this is that we get a delightful user experience that isn’t over-engineered or sacrifices open performance or accessibility.


The post Creating a Smart Navbar With Vanilla JavaScript appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , ,

Import Non-ESM libraries in ES Modules, with Client-Side Vanilla JS

We’re living through a weird era where there are tons of JavaScript libraries that were meant to be used as <script> tags that expose available globals. AND there are tons of JavaScript libraries that are meant to be used through module loaders. AND there are tons of JavaScript libraries that assume you will use them via npm. AND there are tons of libraries built for ES6 imports. If you write a JavaScript library and are shooting for maximum usage, you’d make it work in all those ways, even though that’s obnoxious legwork.

I love Lea’s ideas here on taking libraries that were never really meant to be ES6 import-ed, but doing it anyway.

For example:

window.module = {}; import("https://cdn.jsdelivr.net/gh/reworkcss/css@latest/lib/parse/index.js").then(_ => {   console.log(module.exports); });

And a function if you needed to be safer about that, like a little abstraction:

Check out the article for another clever little trick.

Direct Link to ArticlePermalink


The post Import Non-ESM libraries in ES Modules, with Client-Side Vanilla JS appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , , ,
[Top]

The Anatomy of a Tablist Component in Vanilla JavaScript Versus React

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.

Console warnings from eslint-jsx-a11y-plugin
ESLint warnings about empty <a> tags

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.

For reference, you can tinker with our live examples:

Reaktiv Studios UI components
Reaktiv Studios UI components

Flat HTML examples

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.

<div class="tabs" id="TABS_ID">   <ul class="tabs__list">     <li class="tabs__item">       Tab 1     </li>     <!-- .tabs__item -->      <li class="tabs__item">       Tab 2     </li>     <!-- .tabs__item -->      <li class="tabs__item" disabled>       Tab 3 (disabled)     </li>     <!-- .tabs__item -->   </ul>   <!-- .tabs__list -->    <div class="tabs__panel">     <p>       Tab 1 content     </p>   </div>   <!-- .tabs__panel -->    <div class="tabs__panel">     <p>       Tab 2 content     </p>   </div>   <!-- .tabs__panel -->    <div class="tabs__panel">     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .tabs__panel --> </div> <!-- .tabs -->

Tabs (with ARIA)

<div class="tabs" id="TABS_ID">   <ul class="tabs__list" role="tablist">     <li       aria-controls="tabpanel_TABS_ID_0"       aria-selected="false"       class="tabs__item"       id="tab_TABS_ID_0"       role="tab"       tabindex="0"     >       Tab 1     </li>     <!-- .tabs__item -->      <li       aria-controls="tabpanel_TABS_ID_1"       aria-selected="true"       class="tabs__item"       id="tab_TABS_ID_1"       role="tab"       tabindex="0"     >       Tab 2     </li>     <!-- .tabs__item -->      <li       aria-controls="tabpanel_TABS_ID_2"       aria-disabled="true"       aria-selected="false"       class="tabs__item"       disabled       id="tab_TABS_ID_2"       role="tab"     >       Tab 3 (disabled)     </li>     <!-- .tabs__item -->   </ul>   <!-- .tabs__list -->    <div     aria-hidden="true"     aria-labelledby="tab_TABS_ID_0"     class="tabs__panel"     id="tabpanel_TABS_ID_0"     role="tabpanel"   >     <p>       Tab 1 content     </p>   </div>   <!-- .tabs__panel -->    <div     aria-hidden="false"     aria-labelledby="tab_TABS_ID_1"     class="tabs__panel"     id="tabpanel_TABS_ID_1"     role="tabpanel"   >     <p>       Tab 2 content     </p>   </div>   <!-- .tabs__panel -->    <div     aria-hidden="true"     aria-labelledby="tab_TABS_ID_2"     class="tabs__panel"     id="tabpanel_TABS_ID_2"     role="tabpanel"   >     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .tabs__panel --> </div> <!-- .tabs -->

Accordion (without ARIA)

<div class="accordion" id="ACCORDION_ID">   <div class="accordion__item">     Tab 1   </div>   <!-- .accordion__item -->    <div class="accordion__panel">     <p>       Tab 1 content     </p>   </div>   <!-- .accordion__panel -->    <div class="accordion__item">     Tab 2   </div>   <!-- .accordion__item -->    <div class="accordion__panel">     <p>       Tab 2 content     </p>   </div>   <!-- .accordion__panel -->    <div class="accordion__item" disabled>     Tab 3 (disabled)   </div>   <!-- .accordion__item -->    <div class="accordion__panel">     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .accordion__panel --> </div> <!-- .accordion -->

Accordion (with ARIA)

<div   aria-multiselectable="true"   class="accordion"   id="ACCORDION_ID"   role="tablist" >   <div     aria-controls="tabpanel_ACCORDION_ID_0"     aria-selected="true"     class="accordion__item"     id="tab_ACCORDION_ID_0"     role="tab"     tabindex="0"   >     <i aria-hidden="true" class="accordion__item__icon"></i>     Tab 1   </div>   <!-- .accordion__item -->    <div     aria-hidden="false"     aria-labelledby="tab_ACCORDION_ID_0"     class="accordion__panel"     id="tabpanel_ACCORDION_ID_0"     role="tabpanel"   >     <p>       Tab 1 content     </p>   </div>   <!-- .accordion__panel -->    <div     aria-controls="tabpanel_ACCORDION_ID_1"     aria-selected="false"     class="accordion__item"     id="tab_ACCORDION_ID_1"     role="tab"     tabindex="0"   >     <i aria-hidden="true" class="accordion__item__icon"></i>     Tab 2   </div>   <!-- .accordion__item -->    <div     aria-hidden="true"     aria-labelledby="tab_ACCORDION_ID_1"     class="accordion__panel"     id="tabpanel_ACCORDION_ID_1"     role="tabpanel"   >     <p>       Tab 2 content     </p>   </div>   <!-- .accordion__panel -->    <div     aria-controls="tabpanel_ACCORDION_ID_2"     aria-disabled="true"     aria-selected="false"     class="accordion__item"     disabled     id="tab_ACCORDION_ID_2"     role="tab"   >     <i aria-hidden="true" class="accordion__item__icon"></i>     Tab 3 (disabled)   </div>   <!-- .accordion__item -->    <div     aria-hidden="true"     aria-labelledby="tab_ACCORDION_ID_2"     class="accordion__panel"     id="tabpanel_ACCORDION_ID_2"     role="tabpanel"   >     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .accordion__panel --> </div> <!-- .accordion -->

Vanilla JavaScript examples

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.

/*   Helper to mock DOM methods, for   when an element might not exist. */ const getDomFallback = () => {   return {     // Props.     children: [],     className: '',     classList: {       contains: () => false,     },     id: '',     innerHTML: '',     name: '',     nextSibling: null,     previousSibling: null,     outerHTML: '',     tagName: '',     textContent: '',      // Methods.     appendChild: () => Object.create(null),     cloneNode: () => Object.create(null),     closest: () => null,     createElement: () => Object.create(null),     getAttribute: () => null,     hasAttribute: () => false,     insertAdjacentElement: () => Object.create(null),     insertBefore: () => Object.create(null),     querySelector: () => null,     querySelectorAll: () => [],     removeAttribute: () => undefined,     removeChild: () => Object.create(null),     replaceChild: () => Object.create(null),     setAttribute: () => undefined,   }; };  // Export. export { getDomFallback };

File: unique.js

This function is a poor man’s UUID equivalent.

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.

// 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; 

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.

// Get elements. const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);  // Loop through. allWrappers.forEach((wrapper = getDomFallback()) => {   /*     NOTE: Cut, for brevity.   */ }); 

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.

// ===================== // 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 };

React examples

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.”

Morgan Freeman and Christian Bale, sitting inside the Batmobile
“How do I configure Webpack?”

↑ 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.

// ============= // Used like so… // =============  <Tabs>   <div label="Tab 1">     <p>       Tab 1 content     </p>   </div>   <div label="Tab 2">     <p>       Tab 2 content     </p>   </div> </Tabs>

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.

import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { v4 as uuid } from 'uuid'; import cx from 'classnames';  // UI. import Render from './Render';  // =========== // Get tab ID. // ===========  const getTabId = (id = '', index = 0) => {   return `tab_$ {id}_$ {index}`; };  // ============= // Get panel ID. // =============  const getPanelId = (id = '', index = 0) => {   return `tabpanel_$ {id}_$ {index}`; };  // ========== // Is active? // ==========  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; };  getIsActive.propTypes = {   activeIndex: PropTypes.number,   index: PropTypes.number,   list: PropTypes.array, };  // ================ // Get `<ul>` list. // ================  const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { disabled = null, label = '' } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =======     // Events.     // =======      const handleClick = (event = {}) => {       const { key = '' } = event;        if (!disabled) {         // Early exit.         if (key && key.toLowerCase() !== 'enter') {           return;         }          setActiveIndex(index);       }     };      // ============     // Add to list.     // ============      return (       <li         aria-controls={idPanel}         aria-disabled={disabled}         aria-selected={isActive}         className="tabs__item"         disabled={disabled}         id={idTab}         key={idTab}         role="tab"         tabIndex={disabled ? null : 0}         // Events.         onClick={handleClick}         onKeyDown={handleClick}       >         {label || `$ {index + 1}`}       </li>     );   });    // ==========   // Expose UI.   // ==========    return (     <Render if={newList.length}>       <ul className="tabs__list" role="tablist">         {newList}       </ul>     </Render>   ); };  getTabsList.propTypes = {   activeIndex: PropTypes.number,   id: PropTypes.string,   list: PropTypes.array,   setActiveIndex: PropTypes.func, };  // ================= // Get `<div>` list. // =================  const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { children = '', className = null, style = null } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =============     // Get children.     // =============      let content = children || item;      if (typeof content === 'string') {       content = <p>{content}</p>;     }      // =================     // Build class list.     // =================      const classList = cx({       tabs__panel: true,       [String(className)]: className,     });      // ==========     // Expose UI.     // ==========      return (       <div         aria-hidden={!isActive}         aria-labelledby={idTab}         className={classList}         id={idPanel}         key={idPanel}         role="tabpanel"         style={style}       >         {content}       </div>     );   });    // ==========   // Expose UI.   // ==========    return newList; };  getPanelsList.propTypes = {   activeIndex: PropTypes.number,   id: PropTypes.string,   list: PropTypes.array, };  // ========== // Component. // ==========  const Tabs = ({   children = '',   className = null,   selected = 0,   style = null,   id: propsId = uuid(), }) => {   // ===============   // Internal state.   // ===============    const [id] = useState(propsId);   const [activeIndex, setActiveIndex] = useState(selected);    // =================   // Build class list.   // =================    const classList = cx({     tabs: true,     [String(className)]: className,   });    // ===============   // Build UI lists.   // ===============    const list = Array.isArray(children) ? children : [children];    const tabsList = getTabsList({     activeIndex,     id,     list,     setActiveIndex,   });    const panelsList = getPanelsList({     activeIndex,     id,     list,   });    // ==========   // Expose UI.   // ==========    return (     <Render if={list[0]}>       <div className={classList} id={id} style={style}>         {tabsList}         {panelsList}       </div>     </Render>   ); };  Tabs.propTypes = {   children: PropTypes.node,   className: PropTypes.string,   id: PropTypes.string,   selected: PropTypes.number,   style: PropTypes.object, };  export default Tabs;

Function: getIsActive

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.

const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { disabled = null, label = '' } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =======     // Events.     // =======      const handleClick = (event = {}) => {       const { key = '' } = event;        if (!disabled) {         // Early exit.         if (key && key.toLowerCase() !== 'enter') {           return;         }          setActiveIndex(index);       }     };      // ============     // Add to list.     // ============      return (       <li         aria-controls={idPanel}         aria-disabled={disabled}         aria-selected={isActive}         className="tabs__item"         disabled={disabled}         id={idTab}         key={idTab}         role="tab"         tabIndex={disabled ? null : 0}         // Events.         onClick={handleClick}         onKeyDown={handleClick}       >         {label || `$ {index + 1}`}       </li>     );   });    // ==========   // Expose UI.   // ==========    return (     <Render if={newList.length}>       <ul className="tabs__list" role="tablist">         {newList}       </ul>     </Render>   ); };

Function: getPanelsList

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.

const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { children = '', className = null, style = null } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =============     // Get children.     // =============      let content = children || item;      if (typeof content === 'string') {       content = <p>{content}</p>;     }      // =================     // Build class list.     // =================      const classList = cx({       tabs__panel: true,       [String(className)]: className,     });      // ==========     // Expose UI.     // ==========      return (       <div         aria-hidden={!isActive}         aria-labelledby={idTab}         className={classList}         id={idPanel}         key={idPanel}         role="tabpanel"         style={style}       >         {content}       </div>     );   });    // ==========   // Expose UI.   // ==========    return newList; };

Function: Tabs

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.

const Tabs = ({   children = '',   className = null,   selected = 0,   style = null,   id: propsId = uuid(), }) => {   // ===============   // Internal state.   // ===============    const [id] = useState(propsId);   const [activeIndex, setActiveIndex] = useState(selected);    // =================   // Build class list.   // =================    const classList = cx({     tabs: true,     [String(className)]: className,   });    // ===============   // Build UI lists.   // ===============    const list = Array.isArray(children) ? children : [children];    const tabsList = getTabsList({     activeIndex,     id,     list,     setActiveIndex,   });    const panelsList = getPanelsList({     activeIndex,     id,     list,   });    // ==========   // Expose UI.   // ==========    return (     <Render if={list[0]}>       <div className={classList} id={id} style={style}>         {tabsList}         {panelsList}       </div>     </Render>   ); };

If you are curious about the <Render> function, you can read more about that in this example.

File: Accordion.js

// ============= // Used like so… // =============  <Accordion>   <div label="Tab 1">     <p>       Tab 1 content     </p>   </div>   <div label="Tab 2">     <p>       Tab 2 content     </p>   </div> </Accordion>

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.

import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { v4 as uuid } from 'uuid'; import cx from 'classnames';  // UI. import Render from './Render';  // =========== // Get tab ID. // ===========  const getTabId = (id = '', index = 0) => {   return `tab_$ {id}_$ {index}`; };  // ============= // Get panel ID. // =============  const getPanelId = (id = '', index = 0) => {   return `tabpanel_$ {id}_$ {index}`; };  // ============================== // Get `tab` and `tabpanel` list. // ==============================  const getTabsAndPanelsList = ({   activeItems = {},   id = '',   isMulti = true,   list = [],   setActiveItems = () => {}, }) => {   // Build new list.   const newList = [];    // Loop through.   list.forEach((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;      const {       children = '',       className = null,       disabled = null,       label = '',       style = null,     } = itemProps;      const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = !!activeItems[index];      // =======     // Events.     // =======      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);       }     };      // =============     // Get children.     // =============      let content = children || item;      if (typeof content === 'string') {       content = <p>{content}</p>;     }      // =================     // Build class list.     // =================      const classList = cx({       accordion__panel: true,       [String(className)]: className,     });      // ========     // Add tab.     // ========      newList.push(       <div         aria-controls={idPanel}         aria-disabled={disabled}         aria-selected={isActive}         className="accordion__item"         disabled={disabled}         id={idTab}         key={idTab}         role="tab"         tabIndex={disabled ? null : 0}         // Events.         onClick={handleClick}         onKeyDown={handleClick}       >         <i aria-hidden="true" className="accordion__item__icon" />         {label || `$ {index + 1}`}       </div>     );      // ==========     // Add panel.     // ==========      newList.push(       <div         aria-hidden={!isActive}         aria-labelledby={idTab}         className={classList}         id={idPanel}         key={idPanel}         role="tabpanel"         style={style}       >         {content}       </div>     );   });    // ==========   // Expose UI.   // ==========    return newList; };  getTabsAndPanelsList.propTypes = {   activeItems: PropTypes.object,   id: PropTypes.string,   isMulti: PropTypes.bool,   list: PropTypes.array,   setActiveItems: PropTypes.func, };  // ========== // Component. // ==========  const Accordion = ({   children = '',   className = null,   isMulti = true,   selected = {},   style = null,   id: propsId = uuid(), }) => {   // ===============   // Internal state.   // ===============    const [id] = useState(propsId);   const [activeItems, setActiveItems] = useState(selected);    // =================   // Build class list.   // =================    const classList = cx({     accordion: true,     [String(className)]: className,   });    // ===============   // Build UI lists.   // ===============    const list = Array.isArray(children) ? children : [children];    const tabsAndPanelsList = getTabsAndPanelsList({     activeItems,     id,     isMulti,     list,     setActiveItems,   });    // ==========   // Expose UI.   // ==========    return (     <Render if={list[0]}>       <div         aria-multiselectable={isMulti}         className={classList}         id={id}         role="tablist"         style={style}       >         {tabsAndPanelsList}       </div>     </Render>   ); };  Accordion.propTypes = {   children: PropTypes.node,   className: PropTypes.string,   id: PropTypes.string,   isMulti: PropTypes.bool,   selected: PropTypes.object,   style: PropTypes.object, };  export default Accordion;

Function: handleClick

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.

/*   Internal representation   of `activeItems` state. */  {   0: true,   1: true, } 

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.

/*   Internal representation   of `activeItems` state. */  {   1: true, } 

Conclusion

Still here? Awesome.

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.

HTML

<div class="tabs">   <ul class="tabs__list">     <li class="tabs__item">       Tab 1     </li>     <li class="tabs__item">       Tab 2     </li>   </ul>   <div class="tabs__panel">     <p>       Tab 1 content     </p>   </div>   <div class="tabs__panel">     <p>       Tab 2 content     </p>   </div> </div>

JSX

<Tabs>   <div label="Tab 1">     Tab 1 content   </div>   <div label="Tab 2">     Tab 2 content   </div> </Tabs>

↑ 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.

The post The Anatomy of a Tablist Component in Vanilla JavaScript Versus React appeared first on CSS-Tricks.

CSS-Tricks

, , , , , ,
[Top]

Moving from Vanilla JavaScript to a Reusable Vue Component

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.

Template and styles

From the Vue docs:

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:

  1. 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
  2. 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.

  1. 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.
  2. 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.

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.
<script> // Same as before  computed: {     circleDasharray() {       return `$  {(this.timeFraction * FULL_DASH_ARRAY).toFixed(0)} 283`;     },      formattedTimeLeft() {       const timeLeft = this.timeLeft;       const minutes = Math.floor(timeLeft / 60);       let seconds = timeLeft % 60;       if (seconds < 10) {         seconds = `0$  {seconds}`;       }       return `$  {minutes}:$  {seconds}`;     },      timeLeft() {       return TIME_LIMIT - this.timePassed;     },      timeFraction() {       const rawTimeFraction = this.timeLeft / TIME_LIMIT;       return rawTimeFraction - (1 / TIME_LIMIT) * (1 - rawTimeFraction);     },      remainingPathColor() {       const { alert, warning, info } = COLOR_CODES;       if (this.timeLeft <= alert.threshold) {         return alert.color;       } else if (this.timeLeft <= warning.threshold) {         return warning.color;       } else {         return info.color;       }     }   } </script>

We now have all the values we need! But now we need to put them to use in our template.

Using data and computed properties in the template

Here’s where we left off with our template:

 <template>   <div class="base-timer">     <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>     <span       id="base-timer-label"       class="base-timer__label"     >         $  {formatTime(timeLeft)}     </span>   </div> </template>

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 {{ }}).

<span   id="base-timer-label"   class="base-timer__label" >   {{ formattedTimeLeft }}  </span>

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:

<path  v-bind:stroke-dasharray="circleDasharray"></path>

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.

<path  :class="remainingPathColor"></path>

Let’s have a look at our template after changes.

<template>   <div class="base-timer">     <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           :stroke-dasharray="circleDasharray"           class="base-timer__path-remaining"           :class="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>     <span class="base-timer__label">{{ formattedTimeLeft }}</span>   </div> </template>

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.

function startTimer() {   timerInterval = setInterval(() => {     timePassed = timePassed += 1;     timeLeft = TIME_LIMIT - timePassed;     document.getElementById("base-timer-label").innerHTML = formatTime(       timeLeft     );     setCircleDasharray();     setRemainingPathColor(timeLeft);     if (timeLeft === 0) {       onTimesUp();     }   }, 1000); }

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:

  1. First, we import the component.
  2. Next, we register the component.
  3. Finally, we instantiate the component in the template.
// App.vue  import BaseTimer from "./components/BaseTimer"  export default {   components: {     BaseTimer   } }; 

That’s a wrap!

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 post Moving from Vanilla JavaScript to a Reusable Vue Component appeared first on CSS-Tricks.

CSS-Tricks

, , , , ,
[Top]

Making an Audio Waveform Visualizer with Vanilla JavaScript

As a UI designer, I’m constantly reminded of the value of knowing how to code. I pride myself on thinking of the developers on my team while designing user interfaces. But sometimes, I step on a technical landmine.

A few years ago, as the design director of wsj.com, I was helping to re-design the Wall Street Journal’s podcast directory. One of the designers on the project was working on the podcast player, and I came upon Megaphone’s embedded player.

I previously worked at SoundCloud and knew that these kinds of visualizations were useful for users who skip through audio. I wondered if we could achieve a similar look for the player on the Wall Street Journal’s site.

The answer from engineering: definitely not. Given timelines and restraints, it wasn’t a possibility for that project. We eventually shipped the redesigned pages with a much simpler podcast player.

But I was hooked on the problem. Over nights and weekends, I hacked away trying to achieve this
effect. I learned a lot about how audio works on the web, and ultimately was able to achieve the look with less than 100 lines of JavaScript!

It turns out that this example is a perfect way to get acquainted with the Web Audio API, and how to visualize audio data using the Canvas API.

But first, a lesson in how digital audio works

In the real, analog world, sound is a wave. As sound travels from a source (like a speaker) to your ears, it compresses and decompresses air in a pattern that your ears and brain hear as music, or speech, or a dog’s bark, etc. etc.

An analog sound wave is a smooth, continuous function.

But in a computer’s world of electronic signals, sound isn’t a wave. To turn a smooth, continuous wave into data that it can store, computers do something called sampling. Sampling means measuring the sound waves hitting a microphone thousands of times every second, then storing those data points. When playing back audio, your computer reverses the process: it recreates the sound, one tiny split-second of audio at a time.

A digital sound file is made up of tiny slices of the original audio, roughly re-creating the smooth continuous wave.

The number of data points in a sound file depends on its sample rate. You might have seen this number before; the typical sample rate for mp3 files is 44.1 kHz. This means that, for every second of audio, there are 44,100 individual data points. For stereo files, there are 88,200 every second — 44,100 for the left channel, and 44,100 for the right. That means a 30-minute podcast has 158,760,000 individual data points describing the audio!

How can a web page read an mp3?

Over the past nine years, the W3C (the folks who help maintain web standards) have developed the Web Audio API to help web developers work with audio. The Web Audio API is a very deep topic; we’ll hardly crack the surface in this essay. But it all starts with something called the AudioContext.

Think of the AudioContext like a sandbox for working with audio. We can initialize it with a few lines of JavaScript:

// Set up audio context window.AudioContext = window.AudioContext || window.webkitAudioContext; const audioContext = new AudioContext(); let currentBuffer = null;

The first line after the comment is a necessary because Safari has implemented AudioContext as webkitAudioContext.

Next, we need to give our new audioContext the mp3 file we’d like to visualize. Let’s fetch it using… fetch()!

const visualizeAudio = url => {   fetch(url)     .then(response => response.arrayBuffer())     .then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))     .then(audioBuffer => visualize(audioBuffer)); };

This function takes a URL, fetches it, then transforms the Response object a few times.

  • First, it calls the arrayBuffer() method, which returns — you guessed it — an ArrayBuffer! An ArrayBuffer is just a container for binary data; it’s an efficient way to move lots of data around in JavaScript.
  • We then send the ArrayBuffer to our audioContext via the decodeAudioData() method. decodeAudioData() takes an ArrayBuffer and returns an AudioBuffer, which is a specialized ArrayBuffer for reading audio data. Did you know that browsers came with all these convenient objects? I definitely did not when I started this project.
  • Finally, we send our AudioBuffer off to be visualized.

Filtering the data

To visualize our AudioBuffer, we need to reduce the amount of data we’re working with. Like I mentioned before, we started off with millions of data points, but we’ll have far fewer in our final visualization.

First, let’s limit the channels we are working with. A channel represents the audio sent to an individual speaker. In stereo sound, there are two channels; in 5.1 surround sound, there are six. AudioBuffer has a built-in method to do this: getChannelData(). Call audioBuffer.getChannelData(0), and we’ll be left with one channel’s worth of data.

Next, the hard part: loop through the channel’s data, and select a smaller set of data points. There are a few ways we could go about this. Let’s say I want my final visualization to have 70 bars; I can divide up the audio data into 70 equal parts, and look at a data point from each one.

const filterData = audioBuffer => {   const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data   const samples = 70; // Number of samples we want to have in our final data set   const blockSize = Math.floor(rawData.length / samples); // Number of samples in each subdivision   const filteredData = [];   for (let i = 0; i < samples; i++) {     filteredData.push(rawData[i * blockSize]);    }   return filteredData; }
This was the first approach I took. To get an idea of what the filtered data looks like, I put the result into a spreadsheet and charted it.

The output caught me off guard! It doesn’t look like the visualization we’re emulating at all. There are lots of data points that are close to, or at zero. But that makes a lot of sense: in a podcast, there is a lot of silence between words and sentences. By only looking at the first sample in each of our blocks, it’s highly likely that we’ll catch a very quiet moment.

Let’s modify the algorithm to find the average of the samples. And while we’re at it, we should take the absolute value of our data, so that it’s all positive.

const filterData = audioBuffer => {   const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data   const samples = 70; // Number of samples we want to have in our final data set   const blockSize = Math.floor(rawData.length / samples); // the number of samples in each subdivision   const filteredData = [];   for (let i = 0; i < samples; i++) {     let blockStart = blockSize * i; // the location of the first sample in the block     let sum = 0;     for (let j = 0; j < blockSize; j++) {       sum = sum + Math.abs(rawData[blockStart + j]) // find the sum of all the samples in the block     }     filteredData.push(sum / blockSize); // divide the sum by the block size to get the average   }   return filteredData; }

Let’s see what that data looks like.

This is great. There’s only one thing left to do: because we have so much silence in the audio file, the resulting averages of the data points are very small. To make sure this visualization works for all audio files, we need to normalize the data; that is, change the scale of the data so that the loudest samples measure as 1.

const normalizeData = filteredData => {   const multiplier = Math.pow(Math.max(...filteredData), -1);   return filteredData.map(n => n * multiplier); }

This function finds the largest data point in the array with Math.max(), takes its inverse with Math.pow(n, -1), and multiplies each value in the array by that number. This guarantees that the largest data point will be set to 1, and the rest of the data will scale proportionally.

Now that we have the right data, let’s write the function that will visualize it.

Visualizing the data

To create the visualization, we’ll be using the JavaScript Canvas API. This API draws graphics into an HTML <canvas> element. The first step to using the Canvas API is similar to the Web Audio API.

const draw = normalizedData => {   // Set up the canvas   const canvas = document.querySelector("canvas");   const dpr = window.devicePixelRatio || 1;   const padding = 20;   canvas.width = canvas.offsetWidth * dpr;   canvas.height = (canvas.offsetHeight + padding * 2) * dpr;   const ctx = canvas.getContext("2d");   ctx.scale(dpr, dpr);   ctx.translate(0, canvas.offsetHeight / 2 + padding); // Set Y = 0 to be in the middle of the canvas };

This code finds the <canvas> element on the page, and checks the browser’s pixel ratio (essentially the screen’s resolution) to make sure our graphic will be drawn at the right size. We then get the context of the canvas (its individual set of methods and values). We calculate the pixel dimensions pf the canvas, factoring in the pixel ratio and adding in some padding. Lastly, we change the coordinates system of the <canvas>; by default, (0,0) is in the top-left of the box, but we can save ourselves a lot of math by setting (0, 0) to be in the middle of the left edge.

Now let’s draw some lines! First, we’ll create a function that will draw an individual segment.

const drawLineSegment = (ctx, x, y, width, isEven) => {   ctx.lineWidth = 1; // how thick the line is   ctx.strokeStyle = "#fff"; // what color our line is   ctx.beginPath();   y = isEven ? y : -y;   ctx.moveTo(x, 0);   ctx.lineTo(x, y);   ctx.arc(x + width / 2, y, width / 2, Math.PI, 0, isEven);   ctx.lineTo(x + width, 0);   ctx.stroke(); };

The Canvas API uses an concept called “turtle graphics.” Imagine that the code is a set of instructions being given to a turtle with a marker. In basic terms, the drawLineSegment() function works as follows:

  1. Start at the center line, x = 0.
  2. Draw a vertical line. Make the height of the line relative to the data.
  3. Draw a half-circle the width of the segment.
  4. Draw a vertical line back to the center line.

Most of the commands are straightforward: ctx.moveTo() and ctx.lineTo() move the turtle to the specified coordinate, without drawing or while drawing, respectively.

Line 5, y = isEven ? -y : y, tells our turtle whether to draw down or up from the center line. The segments alternate between being above and below the center line so that they form a smooth wave. In the world of the Canvas API, negative y values are further up than positive ones. This is a bit counter-intuitive, so keep it in mind as a possible source of bugs.

On line 8, we draw a half-circle. ctx.arc() takes six parameters:

  • The x and y coordinates of the center of the circle
  • The radius of the circle
  • The place in the circle to start drawing (Math.PI or π is the location, in radians, of 9 o’clock)
  • The place in the circle to finish drawing (0 in radians represents 3 o’clock)
  • A boolean value telling our turtle to draw either counterclockwise (if true) or clockwise (if false). Using isEven in this last argument means that we’ll draw the top half of a circle — clockwise from 9 o’clock to 3 clock — for even-numbered segments, and the bottom half for odd-numbered segments.

OK, back to the draw() function.

const draw = normalizedData => {   // Set up the canvas   const canvas = document.querySelector("canvas");   const dpr = window.devicePixelRatio || 1;   const padding = 20;   canvas.width = canvas.offsetWidth * dpr;   canvas.height = (canvas.offsetHeight + padding * 2) * dpr;   const ctx = canvas.getContext("2d");   ctx.scale(dpr, dpr);   ctx.translate(0, canvas.offsetHeight / 2 + padding); // Set Y = 0 to be in the middle of the canvas    // draw the line segments   const width = canvas.offsetWidth / normalizedData.length;   for (let i = 0; i < normalizedData.length; i++) {     const x = width * i;     let height = normalizedData[i] * canvas.offsetHeight - padding;     if (height < 0) {         height = 0;     } else if (height > canvas.offsetHeight / 2) {         height = height > canvas.offsetHeight / 2;     }     drawLineSegment(ctx, x, height, width, (i + 1) % 2);   } };

After our previous setup code, we need to calculate the pixel width of each line segment. This is the canvas’s on-screen width, divided by the number of segments we’d like to display.

Then, a for-loop goes through each entry in the array, and draws a line segment using the function we defined earlier. We set the x value to the current iteration’s index, times the segment width. height, the desired height of the segment, comes from multiplying our normalized data by the canvas’s height, minus the padding we set earlier. We check a few cases: subtracting the padding might have pushed height into the negative, so we re-set that to zero. If the height of the segment will result in a line being drawn off the top of the canvas, we re-set the height to a maximum value.

We pass in the segment width, and for the isEven value, we use a neat trick: (i + 1) % 2 means “find the reminder of i + 1 divided by 2.” We check i + 1 because our counter starts at 0. If i + 1 is even, its remainder will be zero (or false). If i is odd, its remainder will be 1 or true.

And that’s all she wrote. Let’s put it all together. Here’s the whole script, in all its glory.

See the Pen
Audio waveform visualizer
by Matthew Ström (@matthewstrom)
on CodePen.

In the drawAudio() function, we’ve added a few functions to the final call: draw(normalizeData(filterData(audioBuffer))). This chain filters, normalizes, and finally draws the audio we get back from the server.

If everything has gone according to plan, your page should look like this:

Notes on performance

Even with optimizations, this script is still likely running hundreds of thousands of operations in the browser. Depending on the browser’s implementation, this can take many seconds to finish, and will have a negative impact on other computations happening on the page. It also downloads the whole audio file before drawing the visualization, which consumes a lot of data. There are a few ways that we could improve the script to resolve these issues:

  1. Analyze the audio on the server side. Since audio files don’t change often, we can take advantage of server-side computing resources to filter and normalize the data. Then, we only have to transmit the smaller data set; no need to download the mp3 to draw the visualization!
  2. Only draw the visualization when a user needs it. No matter how we analyze the audio, it makes sense to defer the process until well after page load. We could either wait until the element is in view using an intersection observer, or delay even longer until a user interacts with the podcast player.
  3. Progressive enhancement. While exploring Megaphone’s podcast player, I discovered that their visualization is just a facade — it’s the same waveform for every podcast. This could serve as a great default to our (vastly superior) design. Using the principles of progressive enhancement, we could load a default image as a placeholder. Then, we can check to see if it makes sense to load the real waveform before initiating our script. If the user has JavaScript disabled, their browser doesn’t support the Web Audio API, or they have the save-data header set, nothing is broken.

I’d love to hear any thoughts y’all have on optimization, too.

Some closing thoughts

This is a very, very impractical way of visualizing audio. It runs on the client side, processing millions of data points into a fairly straightforward visualization.

But it’s cool! I learned a lot in writing this code, and even more in writing this article. I refactored a lot of the original project and trimmed the whole thing in half. Projects like this might not ever go on to see a production codebase, but they are unique opportunities to develop new skills and a deeper understanding of some of the neat APIs modern browsers support.

I hope this was a helpful tutorial. If you have ideas of how to improve it, or any cool variations on the theme, please reach out! I’m @ilikescience on Twitter.

The post Making an Audio Waveform Visualizer with Vanilla JavaScript appeared first on CSS-Tricks.

CSS-Tricks

, , , , ,
[Top]