Dec 20

Pointer Events

Pointer events provide an API for unifying detecting interaction from various "pointers" including a mouse, pen/stylus, or touch.

By Stephanie Eckles

While we've long had the ability to detect mouse events with JavaScript, the web was missing a singular interface to detect interaction regardless of input type. As of the release of Safari 13 in September 2019, the pointer events API became the cross-browser, unifying solution.

As MDN docs describe, a pointer "is a hardware-agnostic device that can target a specific set of screen coordinates." Generally, you can consider pointers to include mouse, pen/stylus, or touch interactions.

Pointer events inherit from the mouse events interface, so you receive corresponding events, such as:

For backwards compatibility, the browser may opt to map these generic events to mouse events, with some exceptions.

Additional specific pointer event properties include those that tell about the dimensions of the pointer (width and height), the pressure being applied, the pointer's tilt, its type, a unique pointer id, and whether it's the primary pointer of the type.

Using pointer events to resize UI

To explore how these properties work together and learn two additional pointer methods, we'll be building "Santa's Workshop Handbook" - an online reference for elves and other workshop workers!

The handbook shares a need found in many documentation sites or multi-column application windows: the ability to resize one or more panels to reveal the content or hide it away for more reading or work space.

Fundamentally, the expected interaction is "click and drag," but pointer events will let us write this to be touch-friendly, too! We'll also ensure the feature is accessible for keyboard users.

First, we'll add the basic HTML and CSS for the handbook.

Handbook HTML and CSS
.demo {
padding: 0 !important;
}

.workshop-handbook {
--panel-width: 200px;

font-family: system-ui;
font-size: 1.25rem;
display: grid;
align-content: start;
grid-template-columns: var(--panel-width) 1fr;
position: relative;
background-color: firebrick;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cfilter id='paper' x='0%25' y='0%25' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.11' result='noise' numOctaves='5' /%3E%3CfeDiffuseLighting in='noise' lighting-color='firebrick' surfaceScale='1.5'%3E%3CfeDistantLight azimuth='85' elevation='70' /%3E%3C/feDiffuseLighting%3E%3C/filter%3E%3C/defs%3E%3Crect x='0' y='0' width='100%25' height='100%25' filter='url(%23paper)'/%3E%3C/svg%3E");
overflow: hidden;
}

.workshop-handbook * {
margin: 0;
}

.workshop-handbook :is(nav, article) {
all: unset;
max-height: 60vh;
max-height: 60dvh;
overflow-y: auto;
overscroll-behavior: contain;
}

.workshop-handbook nav {
padding: 1rem;
}

.workshop-handbook nav ol {
padding-left: 0;
display: grid;
gap: 1em;
}

.workshop-handbook nav li {
color: white;
/* force the overflow and add ellipsis */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

.workshop-handbook nav a {
font-family: Papyrus;
text-decoration: none;
color: white;
position: relative;
padding-left: 1em;
}

.workshop-handbook nav a:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}

.workshop-handbook nav a:focus-visible {
outline: none;
}

.workshop-handbook nav a:focus-visible::before {
content: "";
position: absolute;
top: calc(50% - 0.5em);
left: 0;
border: 0.5em solid transparent;
border-left-color: currentColor;
}

.workshop-handbook article {
padding: clamp(1.25rem, 5%, 3rem);
background-color: #edf9f6;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cfilter id='paper' x='0%25' y='0%25' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.11' result='noise' numOctaves='5' /%3E%3CfeDiffuseLighting in='noise' lighting-color='%23edf9f6' surfaceScale='1.5'%3E%3CfeDistantLight azimuth='85' elevation='70' /%3E%3C/feDiffuseLighting%3E%3C/filter%3E%3C/defs%3E%3Crect x='0' y='0' width='100%25' height='100%25' filter='url(%23paper)'/%3E%3C/svg%3E");
line-height: 1.5;
font-family: Chalkboard, Comic Sans MS;
}

.workshop-handbook article :not(h2) {
color: #222;
}

.workshop-handbook article * + * {
margin-top: 1em;
}

.workshop-handbook article h2 {
color: firebrick;
font-family: Papyrus;
font-weight: normal;
}

.workshop-handbook .visually-hidden {
clip-path: inset(50%);
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}

@media (forced-colors: active) {
.workshop-handbook :is(nav, article) {
background-image: none;
}
}

πŸŽ… Welcome to the Workshop! πŸ› 

Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod fugiat autem ab? Veritatis?

Quisquam omnis mollitia assumenda enim? Eum dolorum unde quasi doloremque perferendis velit in.

Cumque qui dolore voluptatibus, reiciendis ipsam optio. Ad nihil repudiandae sunt ratione officiis.

Necessitatibus modi blanditiis earum suscipit reiciendis deserunt, placeat, quis perspiciatis, ullam natus magnam?

Soluta aspernatur exercitationem iste deserunt? Exercitationem debitis modi maxime nulla officiis facilis eius.

We've included the --panel-width custom property for the handbook, which we'll be manipulating based on the pointer's position relative to the amount the panel has been moved.

Pointer target element

What's missing in the markup so far is a target element to attach pointer events. In this case, a <button> is appropriate since it can receive focus and is expected to handle custom events.

Receiving focus is an important aspect since we want our resize trigger to be keyboard accessible, and that's the first step. That also means we need to consider where it makes sense in the focus order. I've decided to place it as the first focus target in the nav so that it's immediately discoverable for keyboard users.

<nav>
<button type="button" class="resize-trigger" aria-describedby="resize-description"><span class="visually-hidden">Resize table of contents</span></button>
<p hidden id="resize-description">Drag or use left and right arrow keys.</p>

Additionally, this is a custom control, and it doesn't quite map to any ARIA attributes. It's not a "drag and drop" since there is no drop target, so we've added two important features to clarify the function.

The first is the button label itself which we've made "visually hidden" meaning users of assistive technology like screen readers will have the label announced even though it's not visible. Second, we've included a bit more instructions in the [hidden] paragraph which has a unique id that's been referenced within the button's aria-describedby attribute. Assistive tech will announce something like "Resize table of contents, button, navigation (short pause) Drag or use left and right arrow keys." The "navigation" noted in the announcement is due to being the first focus target in the landmark <nav> element.

The following demo has been updated to include the .resize-trigger elements and styles and is visible on :hover and :focus. We've also included the related CSS media query to detect a coarse pointer. This will be often (but not always) be true for touch devices, or when a mouse is not present or not the primary pointing device. This style makes it always visible so it is easier to find and manipulate without a mouse.

Resize trigger element and CSS
.workshop-handbook {
--resize-width: 0.75rem;
}

.workshop-handbook .resize-trigger {
--resize-bg: linear-gradient(to right, #e4b81e, #9f8837);

all: unset;
outline: none;
position: absolute;
top: 0;
left: var(--panel-width);
width: var(--resize-width);
height: 100%;
cursor: ew-resize;
z-index: 1;
}

.workshop-handbook .resize-trigger:is(:hover, :focus) {
/* Ensure visibility in forced-colors mode which removes the gradient */
background-color: Highlight;
background-image: var(--resize-bg);
box-shadow: 0 0 5px 1px #f5e096;
outline: 1px solid transparent;
}

@media (pointer: coarse) {
.workshop-handbook .resize-trigger {
background-image: var(--resize-bg);
}
}

πŸŽ… Welcome to the Workshop! πŸ› 

Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod fugiat autem ab? Veritatis?

Quisquam omnis mollitia assumenda enim? Eum dolorum unde quasi doloremque perferendis velit in.

Cumque qui dolore voluptatibus, reiciendis ipsam optio. Ad nihil repudiandae sunt ratione officiis.

Necessitatibus modi blanditiis earum suscipit reiciendis deserunt, placeat, quis perspiciatis, ullam natus magnam?

Soluta aspernatur exercitationem iste deserunt? Exercitationem debitis modi maxime nulla officiis facilis eius.

Using the pointer events API

Now that the HTML is in place, it's time to make the resize trigger work with pointer events!

The basic flow is as follows:

The resizePanel() script receives the clientX position or other provided integer and compares it to the boundary of the handbook and a minimum and maximum size we'll allow for the panel. If the number is an allowed size, it updates the --panel-width CSS custom property which ultimately is responsible for the resizing since it sets the grid column width for the navigation.

Finally, for purposes of our application, we listen for keydown so that we can detect the ArrowLeft and ArrowRight keys to allow keyboard users to resize the panel, too.

Resizing pointer events
const handbook = document.getElementById("handbook");
const panelResize = handbook.querySelector(".resize-trigger");
const handbookBoundaries = handbook.getBoundingClientRect();
const rightBoundary = handbookBoundaries.right;
const leftBoundary = handbookBoundaries.left;
const contentMin = 150;

const resizePanel = (size) => {
if (size >= contentMin && size <= rightBoundary - leftBoundary - contentMin) {
handbook.style.setProperty("--panel-width", `${size}px`);
}
};

// Create drag and move interaction
panelResize.addEventListener("pointerdown", ({ pointerId }) => {
panelResize.onpointermove = ({ clientX }) => {
// Adjust position since demo is not a fullwidth window application
const panelPos = clientX - leftBoundary;
resizePanel(panelPos);
};
panelResize.setPointerCapture(pointerId);
});

// Release resizer when drag ends / mouse is disengaged
panelResize.addEventListener("pointerup", ({ pointerId }) => {
panelResize.onpointermove = null;
panelResize.releasePointerCapture(pointerId);
});

// Allow keyboard resizing
panelResize.addEventListener("keydown", ({ key }) => {
if (["ArrowLeft", "ArrowRight"].includes(key)) {
// get current value and add or remove 10px
let posX = parseInt(
getComputedStyle(handbook).getPropertyValue("--panel-width")
);

if (key === "ArrowLeft") {
posX -= 10;
} else {
posX += 10;
}

resizePanel(posX);
}
});

πŸŽ… Welcome to the Workshop! πŸ› 

Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod fugiat autem ab? Veritatis?

Quisquam omnis mollitia assumenda enim? Eum dolorum unde quasi doloremque perferendis velit in.

Cumque qui dolore voluptatibus, reiciendis ipsam optio. Ad nihil repudiandae sunt ratione officiis.

Necessitatibus modi blanditiis earum suscipit reiciendis deserunt, placeat, quis perspiciatis, ullam natus magnam?

Soluta aspernatur exercitationem iste deserunt? Exercitationem debitis modi maxime nulla officiis facilis eius.

Additional resources

Learn more about pointer events from the following resources: