There Is No Such Thing As A CSS Absolute Unit

When we start learning CSS, we find that CSS units of measurement are categorized as relative or absolute. Absolute units are rooted in physical units, such as pixels, centimeters, and inches. But over the years, all absolute units in CSS have lost their connection to the physical world and have become different kinds of relative units, at least from the perspective of the web.

It’s important to note that there are still significant differences between relative and absolute units. CSS relative units are sized according to other style definitions defined by parent elements or are affected by the size of a parent container. As for absolute units, we will dive in and see how they are affected by other things, such as the screen and the device’s operating system.

Relative units include units such as %, em, rem, viewport units (vw and vh), and more. The most common absolute unit is the pixel (px). Besides that, we have the centimeter unit (cm) and the inches unit (in).

Now, let’s explore why CSS absolute units aren’t so absolute.

CSS Pixels

Pixels have been the most common unit of CSS, dating to the beginning of the web. In the old world of desktop screens, before we had any smartphones, the screen’s pixels were always equivalent to CSS pixels.

In 2007, for example, the most common desktop resolution was 1024×768 pixels. Back then, we would normally give our web pages a fixed width of 1000 pixels to fit the entire page, and the leftover pixels would be saved for the browser’s scrollbar.

Smartphone Screens

Smartphones brought another quiet evolution, starting the era of high-density screens. If we consider an iPhone 12 Pro, whose screen is 1170 pixels wide, we would count every 3 pixels on the device as 1 pixel in the CSS.

When we size in mobile, we measure according to CSS pixels, not according to device pixels. To sum up:

  • CSS pixel are logical pixels.
  • Device pixels are real physical pixels.

Okay, but what about desktop devices? Do they still work with the same old pixel calculation? Let’s talk about that.

Desktop Screens In 2021

High-density screens came to laptops several years later. The 2014 MacBooks got the first “retina” screens (retina being synonymous with high density).

These days, most laptops have a high-density screen.

Let’s consider MacBooks:

  • The 13.3-inch MacBook Pro has a screen that is 2560 pixels wide but that behaves like 1440 pixels. This means that every 1.778 physical pixels act like 1 logical pixel.
  • The 16-inch MacBook Pro has a screen that is 3072 pixels wide but that behaves like 1792 pixels. This means that every 1.714 physical pixels act like 1 logical pixel.

Among PC laptops, I tested two 15.6-inch screens, one with full HD resolution and the other with 4K resolution. The results were interesting:

  • The 15.6-inch full-HD screen is 1920 pixels wide but behaves like 1536 pixels. This means that every 1.25 physical pixels act like 1 logical pixel.
  • The 15.6-inch 4K screen is 3840 pixels wide but behaves, again, like 1536 pixels. This means that every 2.5 physical pixels act like 1 logical pixel.

As you can see, the connection between the real physical (i.e. device) pixels and the CSS (i.e. logical) pixels has almost vanished.

Screens Have Become Denser Over The Years

In the past, if you looked closely at a screen, you could actually see its pixels. When the technology of screens improved, manufacturers started to create higher-density screens.

Recommended reading: What Does A Foldable Web Actually Mean?

Why Do We Calculate Logical Pixels Differently?

Over the years, as screens became denser, we couldn’t fit more content in the same screen size merely because the screen has more pixels.

Think about it for a moment. Consider the Samsung Galaxy S21 Ultra. Its narrower dimension is 1440 physical pixels. We could easily fit it in a regular desktop screen. But if we did, the text would be small to the point of being unreadable. Because of this, we separate physical pixels from logical pixels.

Sizes in CSS (i.e. width and height), then, are calculated according to CSS logical pixels. Of course, we can use physical pixels to load high-density content, such as images and videos, like so:

<img src="image-size-1200px.jpg" width="300" >

OK, CSS pixels aren’t equal to a device’s physical pixels. But we have centimeters and inches. Those are physical units connected to the physical world, right? Let’s check them out.

CSS Inches And CSS Centimeters

Wherever we use physical units like inches and centimeters, we know these are absolute units.

I had a thought that if CSS pixels aren’t equal to device pixels, then maybe it would be a good idea to use physical units such as inches and centimeters on the web. They are absolute units, right?

To be sure, I tested it. I created a box with a width and height of 1 centimeter and gave it a background color of red. I grabbed a real tape measure and got a surprise:

A CSS centimeter isn’t equal to a physical centimeter.

Here I am testing a CSS centimeter unit with a tape measure on a mid-2019 13-inch MacBook:

The result is the same for CSS inches:

A CSS inch isn’t equal to a physical inch.

The same holds true for pica (pc) and millimeter (mm) units. These correspond to a part of either a CSS inch or a CSS centimeter, neither of which is connected to a real inch or a real centimeter.

Why CSS Inches And Centimeters Aren’t Real Inches And Centimeters

Since the 1980s, the PC market has determined a CSS inch to be equivalent to 96 pixels. This calculation of pixels was directly tied to the DPI/PPI (pixels per inch) standard of Microsoft’s Windows operating system for monitors at the time, the most common of which had a standard of 96 DPI.

This meant that 1 CSS inch would always be equivalent to 96 CSS pixels.

As for CSS centimeters, every centimeter is directly calculated from inches, which means that 1 inch is equivalent to 2.54 centimeters. This means that every 1 CSS centimeter will always be equal to 37.7952756 CSS pixels.

In other words: 1cm = 37.7952756px (96px / 2.54).

Because a CSS inch and a CSS centimeter are directly converted from CSS pixels, and because screens have gotten more DPIs over the years, we’ve gotten to the point where these units don’t represent what they’re supposed to represent on screens.

CSS Point Unit

The point (pt) unit is one of the less-recognized units of CSS. As Wikipedia states:

“In typography, the point is the smallest unit of measure. It is used for measuring font size, leading, and other items on a printed page.”

The Wikipedia page shows a ruler with the point scale on the bottom and the inch scale on the top:

Before we get into why this unit isn’t really an absolute unit for the web, let’s go over the basic units of screens and printers.

PPI And DPI

We’ve already mentioned DPI, and you might have heard those terms in the past, but if you’ve never understood what exactly they’re all about, here is quick primer:

  • PPI
    Screens are built from a lot of small light dots, called pixels. To measure the density of pixels, we count the number of pixels that fit 1 inch, called pixels per inch (PPI).
  • DPI
    Printers print color dots. To represent the density of printer dots, we count the number of dots that fit 1 inch of paper, called dots per inch (DPI).

In short, these are two ways to measure the density of visual information that we can fit in 1 inch.

  • PPI: pixels per inch (for screens)
  • DPI: dots per inch (for printers)

It is important to mention that the count of CSS pixels and dots in 1 inch are for both the width and the height. This means that on a screen of 96 PPI, a box with a height and width of 1 inch will have a total size of 9216 pixels (96×96 px = 9216 px).

Here is a visual demonstration of 1 inch with a screen of 10 PPI:

Here are some examples of real calculations of CSS PPI:

CSS Resolution
(Pixels)
CSS PPICSS Inches
(width and height)
96×96961×1
141×1411411×1

“DPI” For Screens

Manufacturers of mobile and desktop devices prefer to express their screen measurements in DPI, not PPI. But don’t let that confuse you: It is always PPI for screens and DPI for printers.

DPI/PPI Standards

To represent all those dots and pixels, we have the point (pt) unit.

But the point unit of CSS derives from the default printer DPI, which, again, was decided in the 1980s and is equal to 72 DPI. This means that 1 inch of CSS always equals 72 points.

  • 1 inch = 72 points
  • 1 point = 1/72nd of 1 inch

Pixels For Web, Dots For Printers

For the web, the DPI unit has no meaning. The web DPI is defined according to a different standard (96 DPI), which we already talked about when we calculated a CSS inch and CSS centimeter. Because of this, there is no reason to use the point unit on the web.

Note: 1 point isn’t equal to (CSS) pixels.

  • 1 point = 1.333 pixels
  • 72 points = 1 inch
  • 72 points = 96 pixels

Printers

In this article, I mainly wanted to demonstrate why there aren’t any absolute units for the web. But what about using them for printers? Is there a reason to use CSS inches or centimeters or point units for printers?

My Printing Test

I ran a small test to check whether the 1980s standard of DPI works correctly on printers. I created two boxes: one with a width and height of 72 points, and the second one with a width and height of 1 inch.

I printed these two boxes on a laser printer that I have in my office. Here is my Codepen for testing points and inches for printers:

Printers are able to print more DPIs, but if we are working at 100% zoom on the printer, then 72 points (or 1 inch) of CSS will equal a real physical inch.

Reminder: This article is more about the connection of absolute units to the web rather than to printers. Of course, the results might change on different types of printers.

Recommended reading: Using HSL Colors In CSS

Trying To Create Accurate Sizes On The Web

If we look at the 16-inch MacBook Pro, which has a ratio of 1.714 physical pixels to every 1 CSS pixel, we can’t accurately predict sizes on the web.

If we try to guess the real device pixel ratio on the 16-inch MacBook Pro using JavaScript’s window.devicePixelRatio, it will return an incorrect ratio of 2, instead of 1.714. (And this is without taking into account the zoom state of the web browser and operating system.)

Why We Need Real Absolute CSS Units

When we want to define a fixed size for a sidebar element, we would use CSS pixels. But if you think about it, CSS pixels have no meaning these days. As we saw above, on most smartphones and desktops, CSS pixels don’t describe device pixels anymore.

Based on this, I believe we need actual physical units for CSS (like a real centimeter or inch unit) because CSS pixels no longer have any true meaning on the web.

It’s worth mentioning that Firefox had implemented an actual physical millimeter unit (mozmm), but removed it in version 59. I don’t know why they removed it. Perhaps it’s because so many things already depend on CSS pixels, such as responsive images and the em and rem units. If we tried to add a new physical measurement, maybe it would cause more problems than it solves.

It seems that web folk have gotten so used to thinking in pixels that, even though the CSS pixel unit has lost its connection to device pixels, we’ll keep using the unit.

And in case you still think that CSS pixels are an excellent unit of measurement, try to explain to a new web developer what this unit is actually measuring.

For now, we don’t have any real way to describe physical sizes in CSS.

So, the CSS pixel is the worst kind of absolute unit — except for all the others.

To Summarize

At the beginning of this article, I said that the absolute CSS units have become like new kinds of relative units. We started with CSS pixels, and we saw the difference between CSS pixels and device pixels.

Then, we found that CSS inches and CSS centimeters are directly converted from CSS pixels and aren’t connected to real inches and centimeters. In the end, we talked about the point unit and, again, about how this unit has no absolute meaning for the web.

Final Words

That’s all. I hope you’ve enjoyed this article and learned from my experience. If you like this post, I would appreciate hearing about it and sharing it.

References

Getting Investors to Buy Your Inventory

When it comes to funding inventory, retail businesses can be creative, using loans, credit cards, supplier terms, and even advances from family. Whatever the method, raising the money can be a challenge.

“Every business needs to fund inventory before it gets the opportunity to earn revenue by selling it, and there is no single [funding] solution for all companies,” said Sean De Clercq, CEO of Kickfurther, a platform that connects investors with emerging brands.

Kickfurther describes these connections as consignment opportunities (co-ops) and separates what it does from other funding or crowdfunding options. Before describing these co-ops, it is worth mentioning some of the ways retailers, direct-to-consumer brands, and even consumer packaged goods companies acquire inventory.

Funding Inventory

Outside of savings, bank loans, credit cards, and individual investors, businesses have alternatives for funding inventory.

For example, very small companies based in the United States can apply to Kiva for interest-free loans of up to $15,000 and take as much as three years to pay.

Indosole, a brand that sells shoes made from recycled tires, is one of many companies that has used Kiva for funding. Kiva partners with institutions to provide interest-free, small business loans. Kiva and its partners also provide non-financial support and resources to help businesses succeed.

Screenshot from Kiva's home page showing IndosoleScreenshot from Kiva's home page showing Indosole

Indosole is a featured business on the Kiva website. Kiva partners with institutions to provide interest-free, small business loans.

Although not-for-profit lenders such as Kiva are rare, they do exist. The Accion Opportunity Fund is another example of affordable financing.

As a retailer grows and its inventory levels increase, other forms of financing become available from services such as Kabbage, BlueVine, Clearco, OnDeck, or similar. These companies offer various forms of term loans and lines of credit that established businesses can access. For the most part, lenders in this category will evaluate a business based on credit history and historical and projected revenue and cash flow.

Crowdfunding Inventory

Another alternative for some businesses is crowdfunding. Crowdfunding platforms are, in a way, the Airbnb or Uber of retail or direct-to-consumer inventory.

Airbnb, for example, doesn’t own rental properties; it simply connects folks who do with others who want a short-term rental. And Uber doesn’t own cars necessarily. Rather it connects folks who do with others who want a ride.

Similarly, crowdfunding platforms connect businesses with backers.

For example, electric bike maker Reevo raised millions on Indiegogo, effectively selling pre-orders to customers who, in some cases, will wait a year or more to receive the bike.

Screenshot of Indiegogo home page showing ReevoScreenshot of Indiegogo home page showing Reevo

Reevo raised funds on the crowdfunding platform Indiegogo.

Consigning Inventory

Perhaps the least discussed option for funding inventory is consignment or even crowdfunding consignment.

“We’re specific to physical-product companies, which gives us the ability to look at things like production and distribution risk. Because we are very specific to that niche, we are also able to get our businesses funded for less,” said Kickfurther’s De Clercq during a live interview with the CommerceCo by Practical Ecommerce community on July 15, 2021.

Kickfurther reviews businesses before allowing them to present their co-ops on the platform. Once approved, funding usually comes quickly. Kickfurther investors can support entrepreneurs and earn a return with consignment opportunities. Fractional investment starts at just $20.

At the time of writing, recently funded co-ops included drink maker Greater Than and apparel brand House of Fluff.

Inventory consignment is not new. Consider the Winmark Corporation. The company owns several nationwide second-hand retail brands, including Play It Again Sports, Plato’s Closet, and Once Upon a Child. Each of these obtains inventory from retail consignment: Individuals drop off second-hand goods, and the stores pay those individuals when the product sells.

Kickfurther applies this idea to ecommerce companies.

Benefit to Investors

In a technical sense, “As investors, we take ownership in [inventory] and consign it to the company to sell on our behalf, and when they sell it, then the underlying cash gets distributed,” said Michael Fox-Rabinovitz, managing partner of Chartwell Capital and the author of “Own a Fraction, Earn a Fortune.” Fox-Rabinovitz invests through Kickfurther and also joined the CommerceCo community during the live interview.

“Realistically, we look at the deal. If we like it, we’ll allocate cash to it…it is really fractional ownership in a sense,” Fox-Rabinovitz said.

But at least some investors don’t necessarily think about the Kickfurther co-ops in this way.

The investors inject money into a company that is both interesting to them and a sound investment. They profit from supporting a growing business that develops exciting new products.

Creating An Accessible Dialog From Scratch

First of all, don’t do this at home. Do not write your own dialogs or a library to do so. There are plenty of them out there already that have been tested, audited, used and reused and you should prefer these ones over your own. a11y-dialog is one of them, but there are more (listed at the end of this article).

Let me take this post as an opportunity to remind you all to be cautious when using dialogs. It is tentalizing to address all design problems with them, especially on mobile, but there often are other ways to overcome design issues. We tend to quickly fall into using dialogs not because they are necessarily the right choice but because they are easy. They set aside screen estate problems by trading them for context switching, which is not always the right trade-off. The point is: consider whether a dialog is the right design pattern before using it.

In this post, we’re going to write a small JavaScript library for authoring accessible dialogs from the very beginning (essentially recreating a11y-dialog). The goal is to understand what goes into it. We’re not going to deal with styling too much, just the JavaScript part. We will use modern JavaScript for sake of simplicity (such as classes and arrow functions), but keep in mind that this code might not work in legacy browsers.

  1. Defining the API
  2. Instantiating the dialog
  3. Showing and hiding
  4. Closing with overlay
  5. Closing with escape
  6. Trapping focus
  7. Maintaining focus
  8. Restoring focus
  9. Giving an accessible name
  10. Handling custom events
  11. Cleaning up
  12. Bring it all together
  13. Wrapping up

Defining The API

First, we want to define how we’re going to use our dialog script. We are going to keep it as simple as possible to begin with. We give it the root HTML element for our dialog, and the instance we get has a .show(..) and a .hide(..) method.

class Dialog { constructor(element) {} show() {} hide() {}
}

Instantiating The Dialog

Let’s say we have the following HTML:

<div id="my-dialog">This will be a dialog.</div>

And we instantiate our dialog like this:

const element = document.querySelector('#my-dialog')
const dialog = new Dialog(element)

There are a few things we need to do under the hood when instantiating it:

  • Hide it so it’s hidden by default (hidden).
  • Mark it as a dialog for assistive technologies (role="dialog").
  • Make the rest of the page inert when open (aria-modal="true").
constructor (element) { // Store a reference to the HTML element on the instance so it can be used // across methods. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true)
}

Note that we could have added these 3 attributes in our initial HTML not to have to add them with JavaScript, but this way it’s out of sight, out of mind. Our script can make sure things will work as they should, regardless of whether we’ve thought about adding all our attributes or not.

Showing And Hiding

We have two methods: one to show the dialog and one to hide it. These methods won’t do much (for now) besides toggling the hidden attribute on the root element. We’re also going to maintain a boolean on the instance to quickly be able to assess if the dialog is shown or not. This will come in handy later.

show() { this.isShown = true this.element.removeAttribute('hidden')
} hide() { this.isShown = false this.element.setAttribute('hidden', true)
}

To avoid the dialog being visible before JavaScript kicks in and hides it by adding the attribute, it might be interesting to add hidden to the dialog directly in the HTML from the get go.

<div id="my-dialog" hidden>This will be a dialog.</div>

Closing With Overlay

Clicking outside of the dialog should close it. There are several ways to do so. One way could be to listen to all click events on the page and filter out those happening within the dialog, but that’s relatively complex to do.

Another approach would be to listen to click events on the overlay (sometimes called “backdrop”). The overlay itself can be as simple as a <div> with some styles.

So when opening the dialog, we need to bind click events on the overlay. We could give it an ID or a certain class to be able to query it, or we could give it a data attribute. I tend to favor these for behavior hooks. Let’s modify our HTML accordingly:

<div id="my-dialog" hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div>
</div>

Now, we can query the elements with the data-dialog-hide attribute within the dialog and give them a click listener that hides the dialog.

constructor (element) { // … rest of the code // Bind our methods so they can be used in event listeners without losing the // reference to the dialog instance this._show = this.show.bind(this) this._hide = this.hide.bind(this) const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide))
}

The nice thing about having something quite generic like this is that we can use the same thing for the close button of the dialog as well.

<div id="my-dialog" hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div>
</div>

Closing With Escape

Not only should the dialog be hidden when clicking outside of it, but it also should be hidden when pressing Esc. When opening the dialog, we can bind a keyboard listener to the document, and remove it when closing it. This way, it only listens to key presses while the dialog is open instead of all the time.

show() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown)
} hide() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown)
} handleKeyDown(event) { if (event.key === 'Escape') this.hide()
}

Trapping Focus

Now that’s the good stuff. Trapping the focus within the dialog is kind of at the essence of the whole thing, and has to be the most complicated part (although probably not as complicated as you might think).

The idea is pretty simple: when the dialog is open, we listen for Tab presses. If pressing Tab on the last focusable element of the dialog, we programmatically move the focus to the first. If pressing Shift + Tab on the first focusable element of the dialog, we move it to the last one.

The function might look like this:

function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() }
}

The next thing we need to figure out is how to get all the focusable elements of the dialog (getFocusableChildren). We need to query all the elements that can theoretically be focusable, and then we need to make sure they effectively are.

The first part can be done with focusable-selectors. It’s a teeny tiny package I wrote which provides this array of selectors:

module.exports = [ 'a[href]:not([tabindex^="-"])', 'area[href]:not([tabindex^="-"])', 'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])', 'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked', 'select:not([disabled]):not([tabindex^="-"])', 'textarea:not([disabled]):not([tabindex^="-"])', 'button:not([disabled]):not([tabindex^="-"])', 'iframe:not([tabindex^="-"])', 'audio[controls]:not([tabindex^="-"])', 'video[controls]:not([tabindex^="-"])', '[contenteditable]:not([tabindex^="-"])', '[tabindex]:not([tabindex^="-"])',
]

And this is enough to get you 99% there. We can use these selectors to find all focusable elements, and then we can check every one of them to make sure it is actually visible on screen (and not hidden or something).

import focusableSelectors from 'focusable-selectors' function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length
} function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible)
}

We can now update our handleKeyDown method:

handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event)
}

Maintaining Focus

One thing that’s often overlooked when creating accessible dialogs is making sure the focus remains within the dialog even after the page has lost focus. Think of it this way: what happens if once the dialog is open? We focus the URL bar of the browser, and then start tabbing again. Our focus trap is not going to work, since it only preserves the focus within the dialog when it’s inside the dialog to begin with.

To fix that problem, we can bind a focus listener to the <body> element when the dialog is shown, and move the focus to the first focusable element within the dialog.

show () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true)
} hide () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.removeEventListener('focus', this._maintainFocus, true)
} maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn()
} moveFocusIn () { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus()
}

Which element to focus when opening the dialog is not enforced, and it could depend on which type of content the dialog displays. Generally speaking, there are a couple of options:

  • Focus the first element.
    This is what we do here, since it is made easier by the fact that we already have a getFocusableChildren function.
  • Focus the close button.
    This is also a good solution, especially if the button is absolutely positioned relatively to the dialog. We can conveniently make this happen by placing our close button as the first element of our dialog. If the close button lives in the flow of the dialog content, at the very end, it could be a problem if the dialog has a lot of content (and therefore is scrollable), as it would scroll the content to the end on open.
  • Focus the dialog itself.
    This is not very common among dialog libraries, but it should also work (although it would require adding tabindex="-1" to it so that’s possible since a <div> element is not focusable by default).

Note that we check whether there is an element with the autofocus HTML attribute within the dialog, in which case we would move the focus to it instead of the first item.

Restoring Focus

We’ve managed to successfully trap the focus within the dialog, but we forgot to move the focus inside the dialog once it opens. Similarly, we need to restore the focus back to the element that had it before the dialog was open.

When showing the dialog, we can start by keeping a reference to the element that has the focus (document.activeElement). Most of the time, this will be the button that was interacted with to open the dialog, but in rare cases where a dialog is opened programmatically, it could be something else.

show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn()
}

When hiding the dialog, we can move the focus back to that element. We guard it with a condition to avoid a JavaScript error if the element somehow no longer exists (or if it was a SVG):

hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() }
}

Giving An Accessible Name

It is important our dialog has an accessible name, which is how it will be listed in the accessibility tree. There are a couple of ways to address it, one of which is to define a name in the aria-label attribute, but aria-label has issues.

Another way is to have a title within our dialog (whether hidden or not), and to associate our dialog to it with the aria-labelledby attribute. It might look like this:

<div id="my-dialog" hidden aria-labelledby="my-dialog-title"> <div data-dialog-hide></div> <div> <h1 id="my-dialog-title">My dialog title</h1> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div>
</div>

I guess we could make our script apply this attribute dynamically based on the presence of the title and whatnot, but I’d say this is just as easily solved by authoring proper HTML, to begin with. No need to add JavaScript for that.

Handling Custom Events

What if we want to react to the dialog being open? Or closed? There is currently no way to do it, but adding a small event system should not be too difficult. We need a function to register events (let’s call it .on(..)), and a function to unregister them (.off(..)).

class Dialog { constructor(element) { this.events = { show: [], hide: [] } } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) }
}

Then when showing and hiding the method, we’ll call all functions that have been registered for that particular event.

class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) }
}

Cleaning Up

We might want to provide a method to clean up a dialog in case we’re done using it. It would be responsible for unregistering event listeners so they don’t last more than they should.

class Dialog { destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) }
}

Bringing It All Together

import focusableSelectors from 'focusable-selectors' class Dialog { constructor(element) { this.element = element this.events = { show: [], hide: [] } this._show = this.show.bind(this) this._hide = this.hide.bind(this) this._maintainFocus = this.maintainFocus.bind(this) this._handleKeyDown = this.handleKeyDown.bind(this) element.setAttribute('hidden', true) element.setAttribute('role', 'dialog') element.setAttribute('aria-modal', true) const closers = [...element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) } show() { this.isShown = true this.previouslyFocused = document.activeElement this.element.removeAttribute('hidden') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(event => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('hidden', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(event => event()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) } moveFocusIn() { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() }
} function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() }
} function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length
} function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible)
}

Wrapping Up

That was quite something, but we eventually got there! Once again, I would advise against rolling out your own dialog library since it’s not the most straightforward and errors could be highly problematic for assistive technology users. But at least now you know how it works under the hood!

If you need to use dialogs in your project, consider using one of the following solutions (kind reminder that we have our comprehensive list of accessible components as well):

Here are more things that could be added but were not for sake of simplicity: