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.
- 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.
<script lang="ts">
let button = $state(0);
let anchor = $state(0);
</script>
<p class="mt-0 mb-10 text-balance text-center">
Styling with CSS <code>:hover</code>, <code>:active</code> and
<code class="whitespace-nowrap">:focus-within</code> states
</p>
<div class="grid grid-cols-2 gap-8">
<button
class="bg-coral focus-visible:outline-coral touch-none rounded py-2 px-4 outline-none transition-all hover:cursor-pointer hover:bg-orange-700 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95"
onclick={() => {
console.log("button clicked");
button++;
}}
>
Button <span class="tabular-nums">{button}</span>
</button>
<a
class="bg-coral focus-visible:outline-coral inline-block cursor-default touch-none rounded py-2 px-4 text-center text-slate-100 no-underline transition-all hover:cursor-pointer hover:bg-orange-700 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95"
href="/"
onclick={(e) => {
e.preventDefault();
console.log("anchor clicked");
anchor++;
}}
>
Anchor <span class="tabular-nums">{anchor}</span>
</a>
</div>
Styling with CSS :hover
, :active
and :focus-within
states
Other inconsistencies:
- Keyboard cancellability
There is no equivalent cancellability for keyboard users. Hitting the Escape key while holding Space or Enter doesn’t cancel the click - 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. - 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)
- Chrome: When a button is focused, pressing Space triggers the
- 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.
- Pressing Enter on an anchor does not trigger the
- 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
tonone
- 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 CSStouch-action
property tonone
- Clicking outside the button with another finger while still pressing down cancels the click
and leaves the element in a
:hover
state
- Holding down a touch for about 300ms maintains the button in the
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:
- Unified Active State
The primary pointer, Space and Enter all trigger the click event and set thedata-active
state on release - 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 - 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.
- Active State Restoration
Thedata-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:
<script lang="ts">
import { Cancellable } from "chocobytes/blocks/cancellable.svelte.js";
import { choco } from "chocobytes/index.js";
let button = $state(0);
let anchor = $state(0);
let cancellableButton = new Cancellable();
let cancellableAnchor = new Cancellable();
</script>
<p class="mt-0 mb-10 text-balance text-center">
Styling with the <code class="whitespace-nowrap">data-hover</code>,
<code class="whitespace-nowrap">data-active</code>
and
<code class="whitespace-nowrap">data-focus-within</code> attributes from the
<code>Cancellable</code> class
</p>
<div class="not-prose grid grid-cols-2 gap-8">
<button
class="bg-coral rounded py-2 px-4 outline-none transition-all"
onclick={() => {
console.log("button clicked");
button++;
}}
use:choco={cancellableButton}
>
Button {button}
</button>
<a
class="bg-coral cursor-default rounded py-2 px-4 text-center text-slate-100 no-underline outline-none transition-all"
href="/"
onclick={(e) => {
e.preventDefault();
console.log("anchor clicked");
anchor++;
}}
use:choco={cancellableAnchor}
>
Anchor {anchor}
</a>
</div>
<!-- Just to see what's going on -->
<div class="mt-10 grid">
<p>Button attributes</p>
<pre>{JSON.stringify(cancellableButton.attributes, null, 2)}</pre>
<p>Anchor attributes</p>
<pre>{JSON.stringify(cancellableAnchor.attributes, null, 2)}</pre>
</div>
<style>
[data-hover="true"] {
background-color: var(--color-orange-700);
cursor: pointer;
}
[data-active="true"] {
scale: 95%;
}
[data-focus-visible="true"] {
outline-color: var(--color-coral);
}
</style>
Styling with the data-hover
, data-active
and data-focus-within
attributes from the Cancellable
class
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 CSSuser-select
property is notnone
- 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