This codelab shows the common challenges in building an overlay system that is accessible and correctly renders on the top layer's stacking context.

Overlays

Native elements like <dialog> or <select> are overlays. Their content is rendered on the top layer of the document (that is to say they're rendered closest to the user within a viewport), and they provide accessibility features like focus wrapping (e.g. modal dialogs) or keyboard shortcuts (e.g. Escape to close).

What are we building?

We have a list of items, and in each item we have buttons to open a native <dialog> and our custom dialog. The goal is to make our custom dialog look and behave as the native <dialog>. It should wrap and trap the focus, close on Escape, and always render on the top layer.

What you'll learn

What you'll need

How would you describe your level of experience with web development?

Novice Intermediate Proficient

How will you use this tutorial?

Only read through it Read it and complete the exercises

Download the Code

Clone the repository from GitHub:

git clone https://github.com/PolymerLabs/building-custom-overlays.git

This will make a directory called building-custom-overlays in your current directory.

Alternatively, click the following button to download all the code for this codelab:

Download source code

The repository contains one file for each step of this codelab, along with all of the resources you will need. The stepN.html file contains the desired end state of each step of this codelab. They are there for reference.

Install Tools

  1. Install Git
  2. Install the current LTS version (4.x) of Node.js or newer (check your installed version with node -v)
  3. Install the latest version of Bower
npm install -g bower
  1. Install Polymer CLI
npm install -g polymer-cli

Install Bower Dependencies

Run:

bower install

This will install the latest version of Polymer and the needed polyfills into bower_components/ folder.

Use polymer-cli to Serve Your Element

Run the following command to start a web server for your element. The --open flag will open a new browser window and load your element at http://localhost:8080/components/building-custom-overlays/. By default, the CLI uses port 8080. If it's already in use, you can change it by specifying the -p flag followed by the port number.

polymer serve --open

Open the native <dialog> and experience its behavior:

Let's implement these as well! Open index.html in your editor and let's start coding!

Our custom dialog is invisible by default, so let's toggle its display by updating the opened attribute:

<script>
myDialog.showModal = function() {
  this.setAttribute('opened', '');
};

myDialog.close = function() {
  this.removeAttribute('opened');
};
</script>

Now, we can style it according to the opened attribute:

<style>
.custom-dialog {
  /* invisible by default */
  display: none;
}

.custom-dialog[opened] {
  display: block;
}
</style>

Now the custom dialog appears on screen when opened, but is not overlaying the list, rather pushing the items down. Also, it doesn't have a backdrop, and scrolls with the page!

Let's fix this.

Position & sizing

We use position: fixed to position the custom dialog relative to the screen's viewport and prevent it from moving when scrolled; we can use display: flex to center it on screen:

.custom-dialog {
  /* invisible by default */
  display: none;
  position: fixed;
  /* take full screen */
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
}

.custom-dialog[opened] {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

Let's wrap the content in a <div> to make styling of the dialog content and backdrop easier:

<div id="myDialog" class="custom-dialog">
  <div class="content-wrapper">
    <h2>dialog</h2>
    <p>Oh, hi!</p>
    <button onclick="myDialog.close()">Close</button>
  </div>
</div>
.content-wrapper {
  border: 3px solid black;
  padding: 16px;
  background-color: white;
  /* backdrop */
  outline: 100vmax solid rgba(0, 0, 0, 0.1);
}

Great! Our custom overlay looks exactly as the native dialog 💅

If yours doesn't look like this, have a look at step3.html file for reference 👍

The native dialog has lots of cool accessibility features:

Let's implement these features for our dialog as well!

But first, let's make sure our custom dialog is recognized as "an application window that is designed to interrupt the current processing of an application in order to prompt the user to enter information or require a response" by setting the role="dialog" attribute.

<div id="myDialog" class="custom-dialog" role="dialog">

Close on Escape

Let's add a keydown event listener on the document, and check if the Escape key was hit. We'll need to remove the listener on close.

myDialog.showModal = function() {
  this.setAttribute('opened', '');
  document.addEventListener('keydown', this._onKeydown);
};

myDialog.close = function() {
  this.removeAttribute('opened');
  document.removeEventListener('keydown', this._onKeydown);
};

myDialog._onKeydown = function(event) {
  // Support browsers that don't implement `key` yet.
  if (event.key === 'Escape' || event.keyCode === 27) {
    this.close();
  }
}.bind(myDialog);

Focus on opened

Let's add the autofocus attribute to the button in our custom dialog, so that it will automatically be focused the first time it is displayed.

<button autofocus onclick="myDialog.close()">Close</button>

This will not work the second time we open the dialog, so we should take care of that as well. Also, notice that if there are no focusable elements inside a native <dialog>, it will still receive focus (i.e., document.activeElement will be updated). To achieve the same behavior, we can set tabindex="-1" in our custom dialog, making it focusable but not tabbable:

<div id="myDialog" class="custom-dialog" tabindex="-1" role="dialog">
  <div class="content-wrapper">
    <h2>dialog</h2>
    <p>Oh, hi!</p>
    <button autofocus onclick="myDialog.close()">Close</button>
  </div>
</div>

Now we can safely invoke the focus method on either the focusable child or the dialog itself when it gets opened:

myDialog.showModal = function() {
  this.setAttribute('opened', '');
  document.addEventListener('keydown', this._onKeydown);
  var focusableNode = this.querySelector('[autofocus]') || this;
  focusableNode.focus();
};

Great, our dialog now correctly moves the focus when opened! But focus navigates outside when hitting the Tab key.

Blocking Elements and inert

We need to disable interactions of all nodes except the dialog and its contents. To do that we'll need the blockingElements stack API and the inert attribute.

The blockingElements stack API

The blockingElements proposal consists in having a stack of elements that limit the interaction within the element; the top blocking element is the DOM subtree currently active.

The blockingElements polyfill consists in tagging all the nodes not in the blocking element subtree as inert. It walks the tree of parents of the blocking element, and tags each parent's siblings as inert.

The inert attribute

The inert spec defines an inert element "as if the node was absent for the purposes of targeting user interaction events, may ignore the node for the purposes of text search user interfaces (commonly known as "find in page"), and may prevent the user from selecting text in that node".
When an element has an inert attribute, the user agent must mark that element as inert.

The inert polyfill consists in adding the inert property to the HTMLElement prototype. It disables interactions and text selection with CSS:

[inert], [inert] * {
  pointer-events: none;
  cursor: default;
  user-select: none;
}

It also ensures focusable children within an inert node cannot be focused and are invisible to screen readers by setting tabindex=-1 and aria-hidden="true".

Using the polyfills

First, let's import the two polyfills in <head>:

<script src="bower_components/blockingElements/blocking-elements.js"></script>
<script src="bower_components/inert/inert.js"></script>

Then, let's make the dialog a blocking element while it's opened:

myDialog.showModal = function() {
  this.setAttribute('opened', '');
  document.addEventListener('keydown', this._onKeydown);
  var focusableNode = this.querySelector('[autofocus]') || this;
  focusableNode.focus();
  document.$blockingElements.push(this);
};

myDialog.close = function() {
  this.removeAttribute('opened');
  document.removeEventListener('keydown', this._onKeydown);
  document.$blockingElements.remove(this);
};

And that's it! Our dialog now behaves exactly as the native <dialog>! 🎉

Accessibility recap

Our dialog can:

If your dialog doesn't behave as it should, check step4.html to see if you missed something.

Next, let's see the implications of stacking context constraints on our dialog. To do that, we'll add some fancy animation.

Let's add the "pulse" class to our <ul> list (style is already defined in list.css):

<ul class="pulse">

Now our list has a fancy pulse animation 💗 ...Hey! What is happening when we open our dialog?! It is trapped inside the list, it scrolls with it, it pulses with it! 😱

This happens because of how stacking context works. Children of a node that creates a new stacking context will be "trapped" in it.

In our animation, both transform and opacity will cause a new stacking context to be created for <ul>. In particular, transform will cause the node to act as a containing block for fixed positioned descendants (see spec), while opacity will affect the dialog opacity as well.

<dialog> doesn't suffer from this problem (nor <select> or tooltips generated from the title attribute).

In general, the best approach is to avoid the problem in the first place by declaring overlays in <body> or any other stacking-context safe node.

<ul class="pulse">
  <!-- ... -->
</ul>
<div id="myDialog" class="custom-dialog" tabindex="-1" role="dialog">
  <div class="content-wrapper">
    <h2>dialog</h2>
    <p>Oh, hi!</p>
    <button autofocus onclick="myDialog.close()">Close</button>
  </div>
</div>

There are cases where that's not possible/viable, e.g. you're building a custom dropdown and want to declare it inside a form. For these cases, we'll need a different approach.

Compare your progress with step5.html 😉

A good approach is to leverage the <template> element for the overlay content, and stamp it in a stacking-context-safe node when the dialog is opened.

Let's put our dialog back inside <ul> and wrap its content in a <template>:

<ul class="pulse">
  <!-- ... -->
  <div id="myDialog">
    <template>
      <div class="content-wrapper">
        <h2>dialog</h2>
        <p>Oh, hi!</p>
        <button autofocus onclick="myDialog.close()">Close</button>
      </div>
    </template>
  </div>
  <!-- ... -->
</ul>

myDialog will stamp the content into a renderer element and ensure it is hosted in a stacking-context-safe node. Let's add a method to create a new renderer:

myDialog._createRenderer = function() {
  var renderer = document.createElement('div');
  renderer.setAttribute('tabindex', '-1');
  renderer.setAttribute('role', 'dialog');
  renderer.classList.add('custom-dialog');
  return renderer;
};

We want to lazily create the renderer at the first open and stamp the content in it. At each open we append the renderer to <body>, and we remove it when the dialog gets closed.

myDialog.showModal = function() {
  this.setAttribute('opened', '');
  document.addEventListener('keydown', this._onKeydown);
  // Lazy stamping.
  if (!this._renderer) {
    this._renderer = this._createRenderer();
    var contentTemplate = this.querySelector('template');
    var content = document.importNode(contentTemplate.content, true);
    this._renderer.appendChild(content);
  }
  document.body.appendChild(this._renderer);
  var focusableNode = this._renderer.querySelector('[autofocus]') || this._renderer;
  focusableNode.focus();
  document.$blockingElements.push(this._renderer);
};

myDialog.close = function() {
  this.removeAttribute('opened');
  document.removeEventListener('keydown', this._onKeydown);
  if (!this._renderer) {
    return;
  }
  document.$blockingElements.remove(this._renderer);
  this._renderer.parentNode.removeChild(this._renderer);
};

Finally, we can simplify the style (as the renderer is always visible when present in the DOM):

.custom-dialog {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

Yay! Our dialog is not affected by the animation stacking context!

Doesn't look like this? Compare your progress with step6.html.

The iron-overlay element adopts the technique described previously: it creates a renderer in which to stamp the overlay content, and ensure it is hosted in a stacking-context-safe node.

This custom element also exposes properties and events to customize the desired behavior of the overlay, and handles multiple overlays opened at the same time.

To use it, we'll need to include the webcomponents polyfills (which include HTMLImports, ShadowDOM v1, CustomElements v1) and import the iron-overlay in <head>:

<script src="bower_components/webcomponentsjs/webcomponents-lite.js"></script>
<link rel="import" href="bower_components/iron-overlay/iron-overlay.html">

Now we're ready to replace our <div id="myDialog"> with:

<iron-overlay id="myDialog">
  <template>
    <div class="content-wrapper">
      <h2>dialog</h2>
      <p>Oh, hi!</p>
      <button autofocus onclick="myDialog.close()">Close</button>
    </div>
  </template>
</iron-overlay>

iron-overlay can be opened or closed by setting the opened property or through the open() and close() methods; moreover it handles the Escape key too, so we can remove all our javascript implementation and update the buttons in the list items:

<div class="buttons">
  <button onclick="dialog.showModal()">Open dialog</button>
  <button onclick="myDialog.open()">Open my dialog</button>
</div>

<!-- ... -->

<script>
// Deleted previous implementation
</script>

Set the role="dialog"

iron-overlay lazily creates the renderer, and triggers the iron-overlay-attach event to notify its renderer is about to be attached. We can use it to update the role of the renderer element (accessible through the read-only property renderer).

Since this event is bubbling, we can have one listener on document and find the triggering overlay through event.target.

document.addEventListener('iron-overlay-attach', function(event) {
  var overlay = event.target;
  overlay.renderer.setAttribute('role', 'dialog');
});

Update blockingElements

We still have to update the blockingElements stack when the dialog is opened/closed.

iron-overlay conveniently triggers the iron-overlay-opened event when the opening is completed, and iron-overlay-closed event when closing is completed.

Since these 2 events are bubbling, we can have one listener on document and find the triggering overlay through event.target.

document.addEventListener('iron-overlay-opened', function(event) {
  var overlay = event.target;
  document.$blockingElements.push(overlay.renderer);
});

document.addEventListener('iron-overlay-closed', function(event) {
  var overlay = event.target;
  document.$blockingElements.remove(overlay.renderer);
  overlay.applyFocus();
});

This allows us to have multiple overlays at the same time! Try adding another dialog, we'll open it from the first dialog:

<iron-overlay id="myDialog">
  <template>
    <div class="content-wrapper">
      <h2>dialog</h2>
      <p>Oh, hi!</p>
      <button autofocus onclick="myDialog.close()">Close</button>
      <button onclick="myDialog2.open()">open second dialog</button>
    </div>
  </template>
</iron-overlay>

<iron-overlay id="myDialog2">
  <template>
    <div class="content-wrapper">
      <h2>dialog 2</h2>
      <button autofocus onclick="myDialog2.close()">Close</button>
    </div>
  </template>
</iron-overlay>

The blocking elements stack gets correctly updated when dialogs are opened/closed, and focus travels between overlays properly.

Styling

Finally, let's update the style, which consists in just deleting the obsolete custom-dialog class.

Check step7.html for reference.

If you haven't noticed yet, iron-overlay closes when we click outside 😓

Fortunately, there is a property for that, no-cancel-on-outside-click! While we're at it, let's leverage the properties with-backdrop (simplifies our styles) and animated (because it's cool).

Also, we can remove the <div class="content-wrapper">, as the iron-overlay-renderer has already an internal wrapper for our content. It also exposes handy custom properties and mixins that allow us to style our content and backdrop, as well as customize animations.

<custom-style>
  <style>
    html {
      --iron-backdrop-background-color: black;
      --iron-backdrop-opacity: 0.1;
      --iron-overlay: {
        border: 3px solid black;
        padding: 16px;
        /* start invisible, 0x0 */
        opacity: 0;
        transform: scale(0);
        transition: transform .4s, opacity .4s;
      };
      --iron-overlay-opened: {
        /* visible, normal size */
        opacity: 1;
        transform: none;
      };
    }
  </style>
</custom-style>

<!-- ... -->

<iron-overlay id="myDialog" animated no-cancel-on-outside-click with-backdrop>
  <!-- removed content-wrapper div -->
  <h2>dialog</h2>
  <!-- ... -->

Now our dialog behaves as the native one (and animates too!) 🎉

See step8.html for reference.