Cancellable

The Cancellable building block adds three reactive attributes —data-hover, data-active, and data-focus-visible— to your button and anchor elements. These attributes normalize the behavior of the :hover, :active and :focus-visible states, offering an enhanced and more consistent user experience across browsers and platforms.


The problem

Button and anchor elements behave inconsistently across browsers when it comes to CSS states. The demo below illustrates this by showing a button and anchor styled with default CSS :hover, :focus-visible, and :active states.

What happens when you click one of these elements and, before releasing the click, drag your cursor outside of the element? Does the counter increment?

Give it a try, and you’ll notice the first inconsistency: the counter doesn’t increment, but the CSS state remains visually active, which can be confusing.

  1. Visual vs Functional Cancellability
    The click is functionally cancelled when dragging outside the target, but the visual state remains :active on desktop and :hover on mobile.

Styling with CSS :hover, :active and :focus-within states

Anchor 0

Other inconsistencies:

  1. Keyboard cancellability
    There is no equivalent cancellability for keyboard users. Hitting the Escape key while holding Space or Enter doesn’t cancel the click
  2. Multiple events
    Space fires a click on release, Enter fires a click on press, and holding down Space emits a single click event, while holding down Enter emits multiple click events. However, pressing Escape while holding Enter stops the sequence of clicks. Anchors, on the other hand, don’t fire multiple click events.
  3. Browser-Specific Issues
    • Chrome: When a button is focused, pressing Space triggers the :active state, but pressing Enter does not.
    • Firefox: Neither Space nor Enter trigger the :active state on buttons
    • Safari: Focus doesn’t work on buttons by design. This won’t be fixed (see MDN’s note on Safari behavior)
  4. Anchors
    • Pressing Enter on an anchor does not trigger the :active state.
    • Pressing Space on an anchor does not even fire a click and just scrolls the page. This is just how anchors work and is not a problem in itself, but rather something to have in mind when normalizing behaviors for the few places where you need anchors styled as buttons
    • On desktop, dragging the mouse cancels the click even if the pointer stays inside the anchor. It also removes the cursor style, and displays a small tooltip under the cursor with the link url.
  5. Mobile
    • Holding down a touch for about 300ms maintains the button in the :hover:active state even though the click is cancelled. This is similar to the mouse cancellability above, and releasing the touch doesn’t trigger the click. It also leaves the button in the :hover state begging for a click somewhere else on the page to remove it
    • A longer touch of 500ms or more on the anchor opens a context menu. On the button it selects the text if we don’t set user-select to none
    • Dragging the pointer outside the button cancels the click and leaves it in the :hover state. For quick moves there can be a miss, the :hover state is not reliably applied. Dragging off the element also scrolls or reloads the page depending on the direction and on the position of the boundaries, if we forget to set the CSS touch-action property to none
    • Clicking outside the button with another finger while still pressing down cancels the click and leaves the element in a :hover state

Fixing the situation

The Cancellable class normalizes these inconsistencies, providing a consistent and carefully enhanced experience across browsers and platforms. In particular it detects screen-reader synthetic clicks and is fully accessible.

Here’s what is fixes:

  1. Unified Active State
    The primary pointer, Space and Enter all trigger the click event and set the data-active state on release
  2. Single Click Event
    Only one click event is fired, regardless of whether the user holds the pointer down, Space or Enter. No more repeated firing with Enter
  3. Cancellability
    • The pointer click can be cancelled by dragging outside the element or pressing Escape while holding the pointer down
    • Keyboard clicks can be cancelled by pressing Escape while holding Space or Enter
    • On mobile, dragging outside the element or tapping outside it with another finger cancels the click, without scrolling or reloading the page and without opening the context menu or selecting the text. The final data-hover state is only set if the interaction happened, not if the click was cancelled.
  4. Active State Restoration
    The data-active state turns off when the pointer is dragged outside the element and turns back on if the pointer re-enters the target area while holding the click

Demo: Improved Behavior

Try out the improved version with these normalized states:

Styling with the data-hover, data-active and data-focus-within attributes from the Cancellable class

Anchor 0

Button attributes

{
  "data-active": false,
  "data-hover": false,
  "data-focus-visible": false
}

Anchor attributes

{
  "data-active": false,
  "data-hover": false,
  "data-focus-visible": false
}

Usage

<script lang="ts">
  import { Cancellable } from "chocobytes/blocks/cancellable.svelte.js";
  import { choco } from "chocobytes/index.js";

  const cancellable = new Cancellable();
</script>

<button use:choco={cancellable}>Improved</button>

Progressive enhancement

Cancellable allows you to progressively enhance your buttons and anchor buttons by adding an .enhanced CSS class when JS is available. This ensures graceful degradation as users without JS can still have a functional experience with basic styles. You can define fallback styles by targeting the non-enhanced states with selectors like :not(.enhanced):hover. When JS is active, the full experience is unlocked with styles targeting attributes such as [data-hover="true"].

The Button component (coming soon!) provides a fully progressively enhanced implementation.


API

The Cancellable class provides a progressive enhancement to the user experience. Simply add it to your elements as in the usage example above

Constructor CancellableOptions

allowContextMenus?: boolean
Whether to display context menus on mobile. If true, a long press on an anchor displays the context menu allowing users to copy the link, open it in a new tab etc. On buttons, a long press will display the copy text context menu if the CSS user-select property is not none
Default: false

Instance properties

hover: boolean
Whether the element is being hovered
active: boolean
Whether the element is in an active state
focusVisible: boolean
Whether the element has visible focus