CTA Modal: How To Build A Web Component

I have a confession to make — I am not overly fond of modal dialogs (or just “modals” for short). “Hate” would be too strong a word to use, but let’s say that nothing is more of a turnoff when starting to read an article than being “slapped in the face” with a modal window before I have even begun to comprehend what I am looking at.

Or, if I could quote Andy Budd:

A typical website visit in 2022

1. Figure out how to decline all but essential cookies
2. Close the support widget asking if I need help
3. Stop the auto-playing video
4. Close the “subscribe to our newsletter” pop-up
5. Try and remember why I came here in the first place

— Andy Budd (@andybudd) January 2, 2022

That said, modals are everywhere among us. They are a user interface paradigm that we cannot simply disinvent. When used tastefully and wisely, I dare say they can even help add more context to a document or to an app.

Throughout my career, I have written my fair share of modals. I have built bespoke implementations using vanilla JavaScript, jQuery, and more recently — React. If you have ever struggled to build a modal, then you will know what I mean when I say: It is easy to get them wrong. Not only from a visual standpoint but there are plenty of tricky user interactions that need to be accounted for as well.

I am the type of person who likes to “go deep” on topics that vex me — especially if I find the topic resurfacing — hopefully in an effort to avoid revisiting them ever again. When I started to get more into Web Components, I had an “a-ha!” moment. Now that Web Components are widely supported by every major browser (RIP, IE11), this opens up a whole new door of opportunity. I thought to myself:

“What if it were possible to build a modal that, as a developer authoring a page or app, I would not have to fuss with any additional JavaScript config?”

Write once and run everywhere, so to speak, or at least that was my lofty aspiration. Good news. It is indeed possible to build a modal with rich interaction that only requires authoring HTML to use.

Note: In order to benefit from this article and code examples you will need some basic familiarity with HTML, CSS, and JavaScript.

Before We Even Begin

If you are tight on time and just want to see the finished product, check it out here:

Use The Platform

Now that we have covered the “why” of scratching this particular itch, throughout the rest of this article I will explain the “how” of building it.

First, a quick crash course on Web Components. They are bundled snippets of HTML, CSS, and JavaScript that encapsulate scope. Meaning, no styles from outside of a component will affect within, nor vice versa. Think of it like a hermetically sealed “clean room” of UI design.

At first blush, this may seem nonsensical. Why would we want a chunk of UI that we cannot control externally via CSS? Hang onto that thought, because we will come back to it soon.

The best explanation is reusability. Building a component in this manner means we are not beholden to any particular JS framework du jour. One common phrase that gets bandied about in conversations around web standards is “use the platform.” Now more than ever, the platform itself has superb cross-browser support.

Deep Dive

For reference, I will be referring to this code example — cta-modal.ts.

Note: I am using TypeScript here, but you absolutely do not need any additional tooling to create a Web Component. In fact, I wrote my initial proof-of-concept in vanilla JS. I added TypeScript later, to bolster confidence in others using it as an NPM package.

The cta-modal.ts file is chunked apart into several sections:

  1. Conditional wrapper;
  2. Constants:
  3. CtaModal class:
  4. DOM loaded callback:
    • Waits for the page to be ready,
    • Registers the <cta-modal> tag.

Conditional Wrapper

There is a single, top level if that wraps the entirety of the file’s code:

// ===========================
// START: if "customElements".
// ===========================

if ('customElements' in window) {
  /* NOTE: LINES REMOVED, FOR BREVITY. */
}

// =========================
// END: if "customElements".
// =========================

The reason for this is twofold. We want to ensure that there is browser support for window.customElements. If so, this gives us a handy way to maintain variable scope. Meaning, that when declaring variables via const or let, they do not “leak” outside of the if {…} block. Whereas using an old school var would be problematic, inadvertently creating several global variables.

Reusable Variables

Note: A JavaScript class Foo {…} differs from an HTML or CSS class="foo".

Think of it simply as: “A group of functions, bundled together.”

This section of the file contains primitive values that I intend to reuse throughout my JS class declaration. I will call out a few of them as being particularly interesting.

// ==========
// Constants.
// ==========

/* NOTE: LINES REMOVED, FOR BREVITY. */

const ANIMATION_DURATION = 250;
const DATA_HIDE = 'data-cta-modal-hide';
const DATA_SHOW = 'data-cta-modal-show';
const PREFERS_REDUCED_MOTION = '(prefers-reduced-motion: reduce)';

const FOCUSABLE_SELECTORS = [
  '[contenteditable]',
  '[tabindex="0"]:not([disabled])',
  'a[href]',
  'audio[controls]',
  'button:not([disabled])',
  'iframe',
  "input:not([disabled]):not([type='hidden'])",
  'select:not([disabled])',
  'summary',
  'textarea:not([disabled])',
  'video[controls]',
].join(',');
  • ANIMATION_DURATION
    Specifies how long my CSS animations will take. I also reuse this later within a setTimeout to keep my CSS and JS in sync. It is set to 250 milliseconds, which is a quarter of a second.
    While CSS allows us to specify animation-duration in whole seconds (or milliseconds), JS uses increments of milliseconds. Going with this value allows me to use it for both.
  • DATA_SHOW and DATA_HIDE
    These are strings for the HTML data attributes 'data-cta-modal-show' and 'data-cta-modal-hide' that are used to control the show/hide of modal, as well as adjust animation timing in CSS. They are used later in conjunction with ANIMATION_DURATION.
  • PREFERS_REDUCED_MOTION
    A media query that determines whether or not a user has set their operating system’s preference to reduce for prefers-reduced-motion. I look at this value in both CSS and JS to determine whether to turn off animations.
  • FOCUSABLE_SELECTORS
    Contains CSS selectors for all elements that could be considered focusable within a modal. It is used later more than once, via querySelectorAll. I have declared it here to help with readability, rather than adding clutter to a function body.

It equates to this string:

[contenteditable], [tabindex="0"]:not([disabled]), a[href], audio[controls], button:not([disabled]), iframe, input:not([disabled]):not([type='hidden']), select:not([disabled]), summary, textarea:not([disabled]), video[controls]

Yuck, right!? You can see why I wanted to break that into multiple lines.

As an astute reader, you may have noticed type='hidden' and tabindex="0" are using different quotation marks. That is purposeful, and we will revisit the reasoning later on.

Component Styles

This section contains a multiline string with a <style> tag. As mentioned before, styles contained within a Web Component do not affect the rest of the page. It is worth noting how I am using embedded variables ${etc} via string interpolation.

  • We reference our variable PREFERS_REDUCED_MOTION to forcibly set animations to none for users who prefer reduced motion.
  • We reference DATA_SHOW and DATA_HIDE along with ANIMATION_DURATION to allow shared control over CSS animations. Note the use of the ms suffix for milliseconds, since that is the lingua franca of CSS and JS.
// ======
// Style.
// ======

const STYLE = `
  <style>
    /* NOTE: LINES REMOVED, FOR BREVITY. */

    @media ${PREFERS_REDUCED_MOTION} {
      *,
      *:after,
      *:before {
        animation: none !important;
        transition: none !important;
      }
    }

    [${DATA_SHOW}='true'] .cta-modal__overlay {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: SHOW-OVERLAY;
    }

    [${DATA_SHOW}='true'] .cta-modal__dialog {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: SHOW-DIALOG;
    }

    [${DATA_HIDE}='true'] .cta-modal__overlay {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: HIDE-OVERLAY;
      opacity: 0;
    }

    [${DATA_HIDE}='true'] .cta-modal__dialog {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: HIDE-DIALOG;
      transform: scale(0.95);
    }
  </style>
`;

Component Markup

The markup for the modal is the most straightforward part. These are the essential aspects that make up the modal:

  • slots,
  • scrollable area,
  • focus traps,
  • semi-transparent overlay,
  • dialog window,
  • close button.

When making use of a <cta-modal> tag in one’s page, there are two insertion points for content. Placing elements inside these areas cause them to appear as part of the modal:

  • <div slot="button"> maps to <slot name='button'>,
  • <div slot="modal"> maps to <slot name='modal'>.

You might be wondering what “focus traps” are, and why we need them. These exist to snag focus when a user attempts to tab forwards (or backwards) outside of the modal dialog. If either of these receives focus, they will place the browser’s focus back inside.

Additionally, we give these attributes to the div we want to serve as our modal dialog element. This tells the browser that the <div> is semantically significant. It also allows us to place focus on the element via JS:

  • aria-modal='true',
  • role='dialog',
  • tabindex'-1'.
// =========
// Template.
// =========

const FOCUS_TRAP = `
  <span
    aria-hidden='true'
    class='cta-modal__focus-trap'
    tabindex='0'
  ></span>
`;

const MODAL = `
  <slot name='button'></slot>

  <div class='cta-modal__scroll' style='display:none'>
    ${FOCUS_TRAP}

    <div class='cta-modal__overlay'>
      <div
        aria-modal='true'
        class='cta-modal__dialog'
        role='dialog'
        tabindex='-1'
      >
        <button
          class='cta-modal__close'
          type='button'
        >×</button>

        <slot name='modal'></slot>
      </div>
    </div>

    ${FOCUS_TRAP}
  </div>
`;

// Get markup.
const markup = [STYLE, MODAL].join(EMPTY_STRING).trim().replace(SPACE_REGEX, SPACE);

// Get template.
const template = document.createElement(TEMPLATE);
template.innerHTML = markup;

You may be wondering: “Why not use the dialog tag?” Good question. At the time of this writing, it still has some cross-browser quirks. For more on that, read this article by Scott O’hara. Also, according to the Mozilla documentation, dialog is not allowed to have a tabindex attribute, which we need to put focus on our modal.

Constructor

Whenever a JS class is instantiated, its constructor function is called. That is just a fancy term that means an instance of the CtaModal class is being created. In the case of our Web Component, this instantiation happens automatically whenever a <cta-modal> is encountered in a page’s HTML.

Within the constructor we call super which tells the HTMLElement class (which we are extend-ing) to call its own constructor. Think of it like glue code, to make sure we tap into some of the default lifecycle methods.

Next, we call this._bind() which we will cover a bit more later. Then we attach the “shadow DOM” to our class instance and add the markup that we created as a multiline string earlier.

After that, we get all the elements — from within the aforementioned component markup section — for use in later function calls. Lastly, we call a few helper methods that read attributes from the corresponding <cta-modal> tag.

// =======================
// Lifecycle: constructor.
// =======================

constructor() {
  // Parent constructor.
  super();

  // Bind context.
  this._bind();

  // Shadow DOM.
  this._shadow = this.attachShadow({ mode: 'closed' });

  // Add template.
  this._shadow.appendChild(
    // Clone node.
    template.content.cloneNode(true)
  );

  // Get slots.
  this._slotForButton = this.querySelector("[slot='button']");
  this._slotForModal = this.querySelector("[slot='modal']");

  // Get elements.
  this._heading = this.querySelector('h1, h2, h3, h4, h5, h6');

  // Get shadow elements.
  this._buttonClose = this._shadow.querySelector('.cta-modal__close') as HTMLElement;
  this._focusTrapList = this._shadow.querySelectorAll('.cta-modal__focus-trap');
  this._modal = this._shadow.querySelector('.cta-modal__dialog') as HTMLElement;
  this._modalOverlay = this._shadow.querySelector('.cta-modal__overlay') as HTMLElement;
  this._modalScroll = this._shadow.querySelector('.cta-modal__scroll') as HTMLElement;

  // Missing slot?
  if (!this._slotForModal) {
    window.console.error('Required [slot="modal"] not found inside cta-modal.');
  }

  // Set animation flag.
  this._setAnimationFlag();

  // Set close title.
  this._setCloseTitle();

  // Set modal label.
  this._setModalLabel();

  // Set static flag.
  this._setStaticFlag();

  /*
  =====
  NOTE:
  =====

    We set this flag last because the UI visuals within
    are contingent on some of the other flags being set.
  */

  // Set active flag.
  this._setActiveFlag();
}

Binding this Context

This is a bit of JS wizardry that saves us from having to type tedious code needlessly elsewhere. When working with DOM events the context of this can change, depending on what element is being interacted with within the page.

One way to ensure that this always means the instance of our class is to specifically call bind. Essentially, this function makes it, so that it is handled automatically. That means we do not have to type things like this everywhere.

/* NOTE: Just an example, we don't need this. */
this.someFunctionName1 = this.someFunctionName1.bind(this);
this.someFunctionName2 = this.someFunctionName2.bind(this);

Instead of typing that snippet above, every time we add a new function, a handy this._bind() call in the constructor takes care of any/all functions we might have. This loop grabs every class property that is a function and binds it automatically.

// ============================
// Helper: bind `this` context.
// ============================

_bind() {
  // Get property names.
  const propertyNames = Object.getOwnPropertyNames(
    // Get prototype.
    Object.getPrototypeOf(this)
  ) as (keyof CtaModal)[];

  // Loop through.
  propertyNames.forEach((name) => {
    // Bind functions.
    if (typeof this[name] === FUNCTION) {
      /*
      =====
      NOTE:
      =====

        Why use "@ts-expect-error" here?

        Calling `*.bind(this)` is a standard practice
        when using JavaScript classes. It is necessary
        for functions that might change context because
        they are interacting directly with DOM elements.

        Basically, I am telling TypeScript:

        "Let me live my life!"

        😎
      */

      // @ts-expect-error bind
      this[name] = this[name].bind(this);
    }
  });
}

Lifecycle Methods

By nature of this line, where we extend from HTMLElement, we get a few built-in function calls for “free.” As long as we name our functions by these names they will be called at the appropriate time within the lifecycle of our <cta-modal> component.

// ==========
// Component.
// ==========

class CtaModal extends HTMLElement {
  /* NOTE: LINES REMOVED, FOR BREVITY. */
}
  • observedAttributes
    This tells the browser which attributes we are watching for changes.
  • attributeChangedCallback
    If any of those attributes change, this callback will be invoked. Depending on which attribute changed, we call a function to read the attribute.
  • connectedCallback
    This is called when a <cta-modal> tag is registered with the page. We use this opportunity to add all our event handlers.
    If you are familiar with React, this is similar to the componentDidMount lifecycle event.
  • disconnectedCallback
    This is called when a <cta-modal> tag is removed from the page. Likewise, we remove all obsolete event handlers when/if this occurs.
    It is similar to the componentWillUnmount lifecycle event in React.

Note: It is worth pointing out that these are the only functions within our class that are not prefixed by an underscore (_). Though not strictly necessary, the reason for this is twofold. One, it makes it obvious which functions we have created for our new <cta-modal> and which are native lifecycle events of the HTMLElement class. Two, when we minify our code later the prefix denotes they can be mangled. Whereas the native lifecycle methods need to retain their names verbatim.

// ============================
// Lifecycle: watch attributes.
// ============================

static get observedAttributes() {
  return [ACTIVE, ANIMATED, CLOSE, STATIC];
}

// ==============================
// Lifecycle: attributes changed.
// ==============================

attributeChangedCallback(name: string, oldValue: string, newValue: string) {
  // Different old/new values?
  if (oldValue !== newValue) {
    // Changed [active="…"] value?
    if (name === ACTIVE) {
      this._setActiveFlag();
    }

    // Changed [animated="…"] value?
    if (name === ANIMATED) {
      this._setAnimationFlag();
    }

    // Changed [close="…"] value?
    if (name === CLOSE) {
      this._setCloseTitle();
    }

    // Changed [static="…"] value?
    if (name === STATIC) {
      this._setStaticFlag();
    }
  }
}

// ===========================
// Lifecycle: component mount.
// ===========================

connectedCallback() {
  this._addEvents();
}

// =============================
// Lifecycle: component unmount.
// =============================

disconnectedCallback() {
  this._removeEvents();
}

Adding And Removing Events

These functions register (and remove) callbacks for various element and page-level events:

  • buttons clicked,
  • elements focused,
  • keyboard pressed,
  • overlay clicked.
// ===================
// Helper: add events.
// ===================

_addEvents() {
  // Prevent doubles.
  this._removeEvents();

  document.addEventListener(FOCUSIN, this._handleFocusIn);
  document.addEventListener(KEYDOWN, this._handleKeyDown);

  this._buttonClose.addEventListener(CLICK, this._handleClickToggle);
  this._modalOverlay.addEventListener(CLICK, this._handleClickOverlay);

  if (this._slotForButton) {
    this._slotForButton.addEventListener(CLICK, this._handleClickToggle);
    this._slotForButton.addEventListener(KEYDOWN, this._handleClickToggle);
  }

  if (this._slotForModal) {
    this._slotForModal.addEventListener(CLICK, this._handleClickToggle);
    this._slotForModal.addEventListener(KEYDOWN, this._handleClickToggle);
  }
}

// ======================
// Helper: remove events.
// ======================

_removeEvents() {
  document.removeEventListener(FOCUSIN, this._handleFocusIn);
  document.removeEventListener(KEYDOWN, this._handleKeyDown);

  this._buttonClose.removeEventListener(CLICK, this._handleClickToggle);
  this._modalOverlay.removeEventListener(CLICK, this._handleClickOverlay);

  if (this._slotForButton) {
    this._slotForButton.removeEventListener(CLICK, this._handleClickToggle);
    this._slotForButton.removeEventListener(KEYDOWN, this._handleClickToggle);
  }

  if (this._slotForModal) {
    this._slotForModal.removeEventListener(CLICK, this._handleClickToggle);
    this._slotForModal.removeEventListener(KEYDOWN, this._handleClickToggle);
  }
}

Detecting Attribute Changes

These functions handle reading attributes from a <cta-modal> tag and setting various flags as a result:

  • Setting an _isAnimated boolean on our class instance.
  • Setting title and aria-label attributes on our close button.
  • Setting an aria-label for our modal dialog, based on heading text.
  • Setting an _isActive boolean on our class instance.
  • Setting an _isStatic boolean on our class instance.

You may be wondering why we are using aria-label to relate the modal to its heading text (if it exists). At the time of this writing, browsers are not currently able to correlate an aria-labelledby="…" attribute — within the shadow DOM — to an id="…" that is located in the standard (aka “light”) DOM.

I will not go into great detail about that, but you can read more here:

// ===========================
// Helper: set animation flag.
// ===========================

_setAnimationFlag() {
  this._isAnimated = this.getAttribute(ANIMATED) !== FALSE;
}

// =======================
// Helper: add close text.
// =======================

_setCloseTitle() {
  // Get title.
  const title = this.getAttribute(CLOSE) || CLOSE_TITLE;

  // Set title.
  this._buttonClose.title = title;
  this._buttonClose.setAttribute(ARIA_LABEL, title);
}

// ========================
// Helper: add modal label.
// ========================

_setModalLabel() {
  // Set later.
  let label = MODAL_LABEL_FALLBACK;

  // Heading exists?
  if (this._heading) {
    // Get text.
    label = this._heading.textContent || label;
    label = label.trim().replace(SPACE_REGEX, SPACE);
  }

  // Set label.
  this._modal.setAttribute(ARIA_LABEL, label);
}

// ========================
// Helper: set active flag.
// ========================

_setActiveFlag() {
  // Get flag.
  const isActive = this.getAttribute(ACTIVE) === TRUE;

  // Set flag.
  this._isActive = isActive;

  // Set display.
  this._toggleModalDisplay(() => {
    // Focus modal?
    if (this._isActive) {
      this._focusModal();
    }
  });
}

// ========================
// Helper: set static flag.
// ========================

_setStaticFlag() {
  this._isStatic = this.getAttribute(STATIC) === TRUE;
}

Focusing Specific Elements

The _focusElement function allows us to focus an element that may have been active before a modal became active. Whereas the _focusModal function will place focus on the modal dialog itself and will ensure that the modal backdrop is scrolled to the top.

// ======================
// Helper: focus element.
// ======================

_focusElement(element: HTMLElement) {
  window.requestAnimationFrame(() => {
    if (typeof element.focus === FUNCTION) {
      element.focus();
    }
  });
}

// ====================
// Helper: focus modal.
// ====================

_focusModal() {
  window.requestAnimationFrame(() => {
    this._modal.focus();
    this._modalScroll.scrollTo(0, 0);
  });
}

Detecting “Outside” Modal

This function is handy to know if an element resides outside the parent <cta-modal> tag. It returns a boolean, which we can use to take appropriate action. Namely, tab trapping navigation inside the modal while it is active.

// =============================
// Helper: detect outside modal.
// =============================

_isOutsideModal(element?: HTMLElement) {
  // Early exit.
  if (!this._isActive || !element) {
    return false;
  }

  // Has element?
  const hasElement = this.contains(element) || this._modal.contains(element);

  // Get boolean.
  const bool = !hasElement;

  // Expose boolean.
  return bool;
}

Detecting Motion Preference

Here, we reuse our variable from before (also used in our CSS) to detect if a user is okay with motion. That is, they have not explicitly set prefers-reduced-motion to reduce via their operating system preferences.

The returned boolean is a combination of that check, plus the animated="false" flag not being set on <cta-modal>.

// ===========================
// Helper: detect motion pref.
// ===========================

_isMotionOkay() {
  // Get pref.
  const { matches } = window.matchMedia(PREFERS_REDUCED_MOTION);

  // Expose boolean.
  return this._isAnimated && !matches;
}

Toggling Modal Show/Hide

There is quite a bit going on in this function, but in essence, it is pretty simple.

  • If the modal is not active, show it. If animation is allowed, animate it into place.
  • If the modal is active, hide it. If animation is allowed, animate it disappearing.

We also cache the currently active element, so that when the modal closes we can restore focus.

The variables used in our CSS earlier are also used here:

  • ANIMATION_DURATION,
  • DATA_SHOW,
  • DATA_HIDE.
// =====================
// Helper: toggle modal.
// =====================

_toggleModalDisplay(callback: () => void) {
  // @ts-expect-error boolean
  this.setAttribute(ACTIVE, this._isActive);

  // Get booleans.
  const isModalVisible = this._modalScroll.style.display === BLOCK;
  const isMotionOkay = this._isMotionOkay();

  // Get delay.
  const delay = isMotionOkay ? ANIMATION_DURATION : 0;

  // Get scrollbar width.
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

  // Get active element.
  const activeElement = document.activeElement as HTMLElement;

  // Cache active element?
  if (this._isActive && activeElement) {
    this._activeElement = activeElement;
  }

  // =============
  // Modal active?
  // =============

  if (this._isActive) {
    // Show modal.
    this._modalScroll.style.display = BLOCK;

    // Hide scrollbar.
    document.documentElement.style.overflow = HIDDEN;

    // Add placeholder?
    if (scrollbarWidth) {
      document.documentElement.style.paddingRight = ${scrollbarWidth}px;
    }

    // Set flag.
    if (isMotionOkay) {
      this._isHideShow = true;
      this._modalScroll.setAttribute(DATA_SHOW, TRUE);
    }

    // Fire callback.
    callback();

    // Await CSS animation.
    this._timerForShow = window.setTimeout(() => {
      // Clear.
      clearTimeout(this._timerForShow);

      // Remove flag.
      this._isHideShow = false;
      this._modalScroll.removeAttribute(DATA_SHOW);

      // Delay.
    }, delay);

    /*
    =====
    NOTE:
    =====

      We want to ensure that the modal is currently
      visible because we do not want to put scroll
      back on the &lt;html&gt; element unnecessarily.

      The reason is that another &lt;cta-modal&gt; in
      the page might have been pre-rendered with an
      [active="true"] attribute. If so, we want to
      leave the page's overflow value alone.
    */
  } else if (isModalVisible) {
    // Set flag.
    if (isMotionOkay) {
      this._isHideShow = true;
      this._modalScroll.setAttribute(DATA_HIDE, TRUE);
    }

    // Fire callback?
    callback();

    // Await CSS animation.
    this._timerForHide = window.setTimeout(() => {
      // Clear.
      clearTimeout(this._timerForHide);

      // Remove flag.
      this._isHideShow = false;
      this._modalScroll.removeAttribute(DATA_HIDE);

      // Hide modal.
      this._modalScroll.style.display = NONE;

      // Show scrollbar.
      document.documentElement.style.overflow = EMPTY_STRING;

      // Remove placeholder.
      document.documentElement.style.paddingRight = EMPTY_STRING;

      // Delay.
    }, delay);
  }
}

Handle Event: Click Overlay

When clicking on the semi-transparent overlay, assuming that static="true" is not set on the <cta-modal> tag, we close the modal.

// =====================
// Event: overlay click.
// =====================

_handleClickOverlay(event: MouseEvent) {
  // Early exit.
  if (this._isHideShow || this._isStatic) {
    return;
  }

  // Get layer.
  const target = event.target as HTMLElement;

  // Outside modal?
  if (target.classList.contains('cta-modal__overlay')) {
    this._handleClickToggle();
  }
}

Handle Event: Click Toggle

This function uses event delegation on the <div slot="button"> and <div slot="modal"> elements. Whenever a child element with the class cta-modal-toggle is triggered, it will cause the active state of the modal to change.

This includes listening for various events that are considered activating a button:

  • mouse clicks,
  • pressing the enter key,
  • pressing the spacebar key.
// ====================
// Event: toggle modal.
// ====================

_handleClickToggle(event?: MouseEvent | KeyboardEvent) {
  // Set later.
  let key = EMPTY_STRING;
  let target = null;

  // Event exists?
  if (event) {
    if (event.target) {
      target = event.target as HTMLElement;
    }

    // Get key.
    if ((event as KeyboardEvent).key) {
      key = (event as KeyboardEvent).key;
      key = key.toLowerCase();
    }
  }

  // Set later.
  let button;

  // Target exists?
  if (target) {
    // Direct click.
    if (target.classList.contains('cta-modal__close')) {
      button = target as HTMLButtonElement;

      // Delegated click.
    } else if (typeof target.closest === FUNCTION) {
      button = target.closest('.cta-modal-toggle') as HTMLButtonElement;
    }
  }

  // Get booleans.
  const isValidEvent = event && typeof event.preventDefault === FUNCTION;
  const isValidClick = button && isValidEvent && !key;
  const isValidKey = button && isValidEvent && [ENTER, SPACE].includes(key);

  const isButtonDisabled = button && button.disabled;
  const isButtonMissing = isValidEvent && !button;
  const isWrongKeyEvent = key && !isValidKey;

  // Early exit.
  if (isButtonDisabled || isButtonMissing || isWrongKeyEvent) {
    return;
  }

  // Prevent default?
  if (isValidKey || isValidClick) {
    event.preventDefault();
  }

  // Set flag.
  this._isActive = !this._isActive;

  // Set display.
  this._toggleModalDisplay(() => {
    // Focus modal?
    if (this._isActive) {
      this._focusModal();

      // Return focus?
    } else if (this._activeElement) {
      this._focusElement(this._activeElement);
    }
  });
}

Handle Event: Focus Element

This function is triggered whenever an element receives focus on the page. Depending on the state of the modal, and which element was focused, we can trap tab navigation within the modal dialog. This is where our FOCUSABLE_SELECTORS from early comes into play.

// =========================
// Event: focus in document.
// =========================

_handleFocusIn() {
  // Early exit.
  if (!this._isActive) {
    return;
  }

  // prettier-ignore
  const activeElement = (
    // Get active element.
    this._shadow.activeElement ||
    document.activeElement
  ) as HTMLElement;

  // Get booleans.
  const isFocusTrap1 = activeElement === this._focusTrapList[0];
  const isFocusTrap2 = activeElement === this._focusTrapList[1];

  // Set later.
  let focusListReal: HTMLElement[] = [];

  // Slot exists?
  if (this._slotForModal) {
    // Get "real" elements.
    focusListReal = Array.from(
      this._slotForModal.querySelectorAll(FOCUSABLE_SELECTORS)
    ) as HTMLElement[];
  }

  // Get "shadow" elements.
  const focusListShadow = Array.from(
    this._modal.querySelectorAll(FOCUSABLE_SELECTORS)
  ) as HTMLElement[];

  // Get "total" elements.
  const focusListTotal = focusListShadow.concat(focusListReal);

  // Get first & last items.
  const focusItemFirst = focusListTotal[0];
  const focusItemLast = focusListTotal[focusListTotal.length - 1];

  // Focus trap: above?
  if (isFocusTrap1 && focusItemLast) {
    this._focusElement(focusItemLast);

    // Focus trap: below?
  } else if (isFocusTrap2 && focusItemFirst) {
    this._focusElement(focusItemFirst);

    // Outside modal?
  } else if (this._isOutsideModal(activeElement)) {
    this._focusModal();
  }
}

Handle Event: Keyboard

If a modal is active when the escape key is pressed, it will be closed. If the tab key is pressed, we evaluate whether or not we need to adjust which element is focused.

// =================
// Event: key press.
// =================

_handleKeyDown({ key }: KeyboardEvent) {
  // Early exit.
  if (!this._isActive) {
    return;
  }

  // Get key.
  key = key.toLowerCase();

  // Escape key?
  if (key === ESCAPE && !this._isHideShow && !this._isStatic) {
    this._handleClickToggle();
  }

  // Tab key?
  if (key === TAB) {
    this._handleFocusIn();
  }
}

DOM Loaded Callback

This event listener tells the window to wait for the DOM (HTML page) to be loaded, and then parses it for any instances of <cta-modal> and attaches our JS interactivity to it. Essentially, we have created a new HTML tag and now the browser knows how to use it.

// ===============
// Define element.
// ===============

window.addEventListener('DOMContentLoaded', () => {
  window.customElements.define('cta-modal', CtaModal);
});
Build Time Optimization

I will not go into great detail about this aspect, but I think it is worth calling out.

After transpiling from TypeScript to JavaScript, I run Terser against the JS output. All the aforementioned functions that begin with an underscore (_) are marked as safe to mangle. That is, they go from being named _bind and _addEvents to single letters instead.

That step brings the file size down considerably. Then I run the minified output through a minifyWebComponent.js process that I created, which compresses the embedded <style> and markup even further.

For example, class names and other attributes (and selectors) are minified. This happens in the CSS and HTML.

  • class='cta-modal__overlay' becomes class=o. The quotes are removed as well because the browser does not technically need them to understand the intent.
  • The one CSS selector that is left untouched is [tabindex="0"], because removing the quotes from around the 0 seemingly makes it invalid when parsed by querySelectorAll. However, it is safe to minify within HTML from tabindex='0' to tabindex=0.

When it is all said and done, the file size reduction looks like this (in bytes):

  • un-minified: 16,849,
  • terser minify: 10,230,
  • and my script: 7,689.

To put that into perspective, the favicon.ico file on Smashing Magazine is 4,286 bytes. So, we are not really adding much overhead at all, for a lot of functionality that only requires writing HTML to use.

Conclusion

If you have read this far, thanks for sticking with me. I hope that I have at least piqued your interest in Web Components!

I know we covered quite a bit, but the good news is: That is all there is to it. There are no frameworks to learn unless you want to. Realistically, you can get started writing your own Web Components using vanilla JS without a build process.

There really has never been a better time to #UseThePlatform. I look forward to seeing what you imagine.

Further Reading

I would be remiss if I did not mention that there are a myriad of other modal options out there.

While I am biased and feel my approach brings something unique to the table — otherwise I would not have tried to “reinvent the wheel” — you may find that one of these will better suit your needs.

The following examples differ from CTA Modal in that they all require at least some additional JavaScript to be written by the end-user developer. Whereas with CTA Modal, all you have to author is the HTML code.

Flat HTML & JS:

Web Components:

jQuery:

React:

Vue:

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.

Introducing Trashy.css

It began, as many things do, with a silly conversation. In this case, I was talking with our Front End Technology Competency Director (aka "boss man") Mundi Morgado.

It went something like this…

Mundi Morgado I want you to build a visual screen reader.

Nathan Smith Who what now?

Mundi Morgado I want a CSS library that allows you to see the structure of a document. It shouldn't use class so that you're forced to focus on semantics. Also, make it theme-able.

Nathan Smith Sure, let me see what I can come up with.

Fast-forward a week, and we've got what we are now calling:

Trashy.css: The throwaway CSS library with no class

Why throwaway? Well, it's not really meant to be a fully fledged, production-ready style framework. Rather, it's like training wheels for document semantics, with some bumper lanes (think: bowling) to keep you on the right track.

It's part of our ongoing effort at TandemSeven to promote code literacy throughout our organization. As more of our UX designers are beginning to share responsibility for the accessibility and semantics of a project, it makes sense that we would build in such a way that allows UX and development to be more collaborative.

There are four main aspects to Trashy.

Trashy: "Basic"

First is the base trashy.css file, which applies a passably basic look and feel to the majority of common HTML elements. Check out this example of a basic page.

Trashy: "Boxes"

Second, there is trashy.boxes.css. This visually distinguishes otherwise invisible semantic elements, such as: header, main, nav, etc. That way, one can see where those elements are (or aren't) as a page is being authored. Here’s a page with semantic boxes outlined.

Trashy: "Theme"

Thirdly, theming is possible via trashy.theme.css. This is mostly just an example — inspired by PaperCSS) — of how to make UI look "hand drawn," to prevent observers from being too fixated on the look and feel, rather than the semantics of a document. Here’s the example theme in action.

Trashy: "Debug"

Lastly, there is a trashy.debug.css file that highlights and calls out invalid and/or problematic markup. Here’s a gnarly example of "everything" going wrong on a single HTML page. This was inspired by a11y.css, though it diverges in what is considered noteworthy.

For instance, we call out "div-itis" when multiple div:only-child have been nested within one another. We're also opinionated about type="checkbox" being contained inside of a <label>. This is for maximum click-ability within an otherwise dead whitespace gap, between a checkbox (or radio) and its textual label.

Any or all of these CSS files can be applied individually or in conjunction with one another. There is also a bookmarklet (labeled "GET TRASHY!") on the Trashy home page you can use to get results for any webpage.

The bookmarklet will remotely pull in:

  • sanitize.css
  • trashy.css
  • trashy.boxes.css
  • trashy.debug.css

Our "reset" - Sanitize.css

A quick word on Santitize.css: It was created by my coworker Jonathan Neal (author of Normalize.css), as a way to have a semi-opinionated CSS "reset." Meaning, it puts in a lot of defaults that we find ourselves writing as developers anyway, so it makes for a good starting point.

Technically speaking

Okay, so now that we’ve covered the "why," let’s dig into the "how."

Basically, it all revolves around using (abusing?) the ::before and ::after pseudo elements. For instance, to show the name of a block level tag, we’re using this CSS.

Semantic tags (using ::before)

Here is an abbreviated version of the trashy.boxes.css file, for succinctness. It causes the <section> tag to have a dashed border, and to have its tag named displayed the top left corner.

section {
  border: 1px dashed #f0f;
  display: block;
  margin-bottom: 2rem; /* 20px. */
  padding: 2rem; /* 20px. */
  position: relative;
}

section > :last-child {
  margin-bottom: 0;
}

section::before {
  background: #fff;
  color: #f0f;
  content: "section";
  font-family: "Courier", monospace;
  font-size: 1rem; /* 10px. */
  letter-spacing: 0.1rem; /* 1px. */
  line-height: 1.3;
  text-transform: uppercase;
  position: absolute;
  top: 0;
  left: 0.3rem; /* 3px. */
}

Warning messages (using ::after)

Likewise, here’s a snippet of code from the trashy.debug.css file that drives the markup warning messages. This particular example causes <script> tags to be displayed, which may be further optimized or need the developer’s attention: inline JavaScript and/or external src without async.

In the case of inline JS, we set the font-size and line-height to 0 because we’re making the element visible. We yank the text off the page via text-indent: -99999px just to make sure it doesn’t show up. We wouldn’t want that random JS code displayed alongside legit page content.

(Please don’t ever do this for "real" content. It’s just a hack, mkay?)

To get our error message to show up though, we have to set the font-size and line-height back to non-zero values, and remove our text-indent. Then, with a bit more absolute positioning, we ensure the message is visible. This lets us see, amidst the rest of the page content, whereabouts to check (via DOM inspector) for the <script> insertion point.

script:not([src]),
script[src]:not([async]),
script[src^="http:"] {
  color: transparent;
  display: block;
  font-size: 0;
  height: 2rem; /* 20px. */
  line-height: 0;
  margin-bottom: 2rem; /* 20px. */
  position: relative;
  text-indent: -99999px;
  width: 100%;
}

script:not([src])::after,
script[src]:not([async])::after,
script[src^="http:"]::after {
  background: #f00;
  color: #fff;
  display: inline-block;
  font-family: Verdana, sans-serif;
  font-size: 1.1rem; /* 11px. */
  font-weight: normal;
  left: 0;
  line-height: 1.5;
  padding-left: 0.5rem; /* 5px. */
  padding-right: 0.5rem; /* 5px. */
  pointer-events: none;
  position: absolute;
  text-decoration: none;
  text-indent: 0;
  top: 100%;
  white-space: nowrap;
  z-index: 1;
}

body script:not([src])::after,
body script[src]:not([async])::after,
body script[src^="http:"]::after {
  top: 0;
}

script:not([src])::after {
  content:
    'Move inline <script> to external *.js file'
  ;
}

script[src]:not([async])::after {
  content:
    'Consider [async] for <script> with [src="…"]'
  ;
}

script[src]:not([src=""]):not([async])::after {
  content:
    'Consider [async] for <script> with [src="' attr(src) '"]'
  ;
}

script[src^="http:"]::after {
  content:
    'Consider "https:" for <script> with [src="' attr(src) '"]'
  ;
}

Note: Some scripts do need to be loaded synchronously. You wouldn’t want your "app" code to load before your "framework" code. Heck, some inline JS is probably fine too, especially if you’re doing some super speed optimization for the critical rendering path. These warnings are "dumb" in the sense that they match only via CSS selectors. Take ‘em with a grain of salt, your mileage may vary, etc.

JS bookmarklet

The aforementioned JS bookmarklet finds all <link rel="stylesheet"> and <style> tags in the page, and causes them to be ineffective. This allows us to add in our own CSS to take over, so that the only styles applied are those provided directly via Trashy.

The magic is accomplished by setting rel="stylesheet" to rel="". If an external stylesheet is not identified as such, even if it’s cached, the browser will not apply its styles to the page. Similarly, we destroy the contents of any inline <style> tags by setting the innerHTML to an empty string.

This leaves us with the tags themselves still intact, because we still want to validate that no <link> or <style> tags are present within <body>.

We also check that there aren’t too many <style> tags being used in the <head>. We allow for one, since it could be used for the critical rendering path. If there’s a build process to generate a page with consolidated inline styles, then it’s likely they’re being emitted through a single tag.

<a href="javascript:(
  function (d) {
    var f = Array.prototype.forEach;
    var linkTags = d.querySelectorAll('[rel=\'stylesheet\']');
    var styleTags = d.querySelectorAll('style');

    f.call(linkTags, function (x) {
      x.rel = '';
    });

    f.call(styleTags, function (x) {
      x.innerHTML = '';
    });

    var newLink = d.createElement('link');

    newLink.rel = 'stylesheet';

    newLink.href =
      'https://t7.github.io/trashy.css/css/bookmarklet.css';

    d.head.appendChild(newLink);
  }
)(document);">
  GET TRASHY!
</a>

Go forth, get Trashy!

Get Trashy

That pretty much covers it. It's a simple drop-in to help you visualize your document semantics and debug possibly problematic markup.

I have also found it to be super fun to use. A quick click of the bookmarklet on any given site usually yields lots of things that could be improved upon.

For instance, here's what CNN's site looks like with Trashy applied…

P.S. Call security!

If you try this with Twitter’s site, you might wonder why it doesn’t work. We were initially perplexed by this, too. It has to do with the site's Content Security Policy (CSP). This can be used to disallow CSS and/or JavaScript from being loaded externally, and can whitelist safe domains.

This can be set either at the server level, or by a <meta> tag in the <head>.

<meta http-equiv="Content-Security-Policy" content="…" />

Read more about CSP here.

I know what you’re thinking...

Can’t you just destroy that meta tag with JS?

In the case of Twitter, it’s emitted from the server. Even if it were in the HTML, destroying it has no effect on the actual browser behavior. It’s locked in.

Okay, so...

Can’t you insert your own security meta tag and override it?

You’d think so. But, thankfully, no. Once a CSP been accepted by the browser for that page, it cannot be overridden. That’s probably a good thing, even though it ruins our fun.

I suppose that’d be like wishing for more wishes. It’s against the genie rules!

The post Introducing Trashy.css appeared first on CSS-Tricks.