
Making a Button Feel Native on Touch and Mouse
TL;DR: A finger expects acknowledgment within ~100ms, but
clickis a poor signal for visual press feedback. Split the two jobs: run the action onclick, paint the press with CSS:active. Then correct the platform’s rough edges: the iOS:activetrap, sticky hover, the tap highlight, focus rings. Quick checklist:
- Feels laggy on touch: missing
touch-action: manipulation?- Ghost / double clicks: firing the action from
touchstartinstead ofclick?- Dead
:activeon iOS: notouchstartlistener bound?- Sticky hover: a bare
:hoverwith no@media (hover: hover)gate?- No feedback at all: removed
-webkit-tap-highlight-colorwithout adding a custom:active?- Stuck focus outline: styled
:focusinstead of:focus-visible?- Double-submit:
loadingnot folded into the same gate the handler checks?- Lying disabled state: feedback selectors missing
:not(:disabled)?
A finger expects acknowledgment within ~100ms. Anything slower reads as a dead button, and the user taps again. The trouble is that the browser’s own activation event, click, is a poor signal for visual press feedback: on touch it can lag, the platform paints its own clashing tap highlight, and a :hover state designed for a mouse “sticks” on a phone because there is no pointer to move away. This post is about making a custom button feel native on both mouse and touch. The idea running through it is simple: let the platform do the heavy lifting, then correct its rough edges. And never confuse the semantic event (the action) with the visual one (the press).
The running example wraps a native <button> in a reusable component. Every technique below is plain DOM plus CSS and ports unchanged to any framework, or to vanilla JavaScript. Where I show component code I use Angular, since that’s what I reach for. The CSS is the portable core and works everywhere.
1. The latency problem: why a press needs feedback before click
Principle: Decouple the visual acknowledgment of a press from the semantic activation event. The press should be confirmed on physical contact (pointerdown/touchstart); the action should still fire on click.
A click is the right place to run your handler. The browser dedupes touch and synthesized mouse into exactly one click per intent, regardless of input device. But click is the end of an interaction, after the finger lifts and (historically) after a delay. If you wait for click to repaint the button, the user perceives lag.
The fix is to put all pressed visuals on CSS :active, which the browser applies the instant a pointer goes down. It happens synchronously with the physical press: no JavaScript, no event listener latency, no framework re-render or change-detection cost. Activation stays on click. These are two different jobs, and they belong on two different signals.
click → run the action (browser fires exactly one, deduped)
:active → paint the press (applied on pointerdown/touchstart, instant)
Everything that follows is in service of making :active fire reliably and look right across platforms.
2. The iOS :active trap: an empty touchstart listener
Principle: Bind a no-op touchstart listener so CSS :active actually fires on iOS Safari, instead of driving press visuals from JavaScript.
Why. iOS Safari historically does not apply the :active pseudo-class to an element on touch unless that element (or an ancestor) has a touch event listener bound. Without one, your carefully chosen pressed color never appears on iPhone or iPad and the button feels dead. It only misbehaves on iOS, so desktop testing won’t catch it. Binding even an empty listener flips Safari into treating the element as interactive, and the browser paints :active for free. That is far cheaper and faster than measuring touchstart in JS and toggling a class yourself.
How (portable). Register an empty touchstart handler on the button, then express all pressed visuals in CSS:
button.addEventListener('touchstart', () => {})
.btn:active { background: var(--btn-bg-pressed); }
The listener does nothing functional; its mere presence is the workaround. Keep the action on click, and do not fire it from touchstart.
In Angular, bind the no-op handler in the template and back it with a one-line empty method. The comment is load-bearing documentation. Without it, the next person deletes “dead code” and silently regresses iOS:
<button (click)="handleClick()" (touchstart)="noop()"> … </button>
/** iOS Safari requires a touchstart listener for :active CSS to fire. */
noop() {}
Gotchas
- If you fire the real action from
touchstart, you get double activation on touch (touchstartand the synthesizedclick), which is the classic ghost-click bug. Touch is for the:activevisual only.- Frameworks may register
touchstartas a passive listener. That’s fine here because the handler never callspreventDefault(). Do not rely on this listener to block scrolling.- Don’t delete the empty listener as “dead code.” It regresses press feedback on iOS only, so it’s invisible in desktop QA.
3. Killing the tap delay: touch-action: manipulation
Principle: Declare touch-action: manipulation on tappable controls to remove the legacy ~300ms double-tap-zoom wait.
Why. Mobile browsers historically delayed the synthetic click by ~300ms to see whether the user was starting a double-tap-to-zoom gesture. That delay makes every button feel laggy. touch-action: manipulation tells the browser this element only does pan and pinch (no double-tap zoom), so it can dispatch click without waiting. On modern mobile browsers with a responsive viewport the delay is mostly gone already, but this one line is still the durable, per-element guarantee, and it makes FastClick-style JS polyfills obsolete.
How (portable). One CSS declaration on the interactive element. It disables double-tap zoom on that element while preserving scrolling and pinch-zoom:
.btn { touch-action: manipulation; }
Gotchas
- Scope it to the control. Setting
touch-actionon a scroll container can break scrolling.- A
width=device-widthviewport meta tag alone is not enough on older WebKit; the per-elementtouch-actionis the durable fix.- Don’t reach for a JS tap-delay polyfill; it adds listeners and ghost-click risk this one line avoids.
4. Owning the feedback: suppress the platform tap-highlight
Principle: Set -webkit-tap-highlight-color: transparent so the OS doesn’t paint its own gray/blue flash, but only after you’ve supplied your own :active state.
Why. On tap, mobile WebKit and Blink paint a translucent highlight rectangle over the element. It clashes with custom colors, often has the wrong border-radius, and screams “generic web button” rather than “native app.” Removing it lets your own :active background be the only feedback: consistent across platforms and on-brand.
How (portable).
.btn { -webkit-tap-highlight-color: transparent; }
.btn:active { background: var(--btn-bg-pressed); } /* the replacement, required */
The ordering matters conceptually. This property is WebKit/Blink-specific and only removes feedback, so pair it with a real :active rule that works everywhere.
Gotcha
- Removing the highlight without adding a custom
:activeleaves touch users with zero acknowledgment, which is strictly worse than the default. Always replace, never just delete.
5. Sticky hover: split :hover (mouse-only) from :active (always)
Principle: Gate every :hover rule behind @media (hover: hover) and (pointer: fine), and keep :active ungated so press feedback works on every input.
Why. On touch devices, :hover “sticks”: after a tap the element keeps its hover style until you tap elsewhere, because there is no pointer to move away. Buttons look permanently highlighted. The interaction media query scopes hover styling to genuine mouse and trackpad users, while :active (the press) is defined separately and applies to everyone. (hover: hover) asks “can this device hover?” and (pointer: fine) asks “is the primary pointer precise?” Together they isolate real pointer hardware.
How (portable). Nest hover inside the query; leave :active outside it:
@media (hover: hover) and (pointer: fine) {
.btn:hover:not(:disabled) { background: var(--btn-bg-hover); }
}
.btn:active:not(:disabled) { background: var(--btn-bg-hover); } /* applies to all inputs */
Tip. Hoist the query string into a single preprocessor variable and reuse it across every variant, so the rule can never drift between them. For example, in SCSS:
$mouse: '(hover: hover) and (pointer: fine)';
@media #{$mouse} {
&:hover:not(:disabled) { background-color: var(--btn-bg-hover); }
}
&:active:not(:disabled) { background-color: var(--btn-bg-hover); }
Gotchas
- A bare
:hoverwith no media gate is the sticky-hover-after-tap bug on phones and tablets.- Hybrid devices (touchscreen laptops) report
(hover: hover)and have touch. Gating hover behind the query is still correct;:activecovers the touch press regardless.- Forgetting to also define
:activemeans mouse users get feedback and touch users get none. The two rules are independent, so you need both.
6. Hover-equals-active: make the press land on first contact
Principle: Give :active the same target color as :hover, so the press registers as an immediate, visible state change on the very first contact. Keep the transition short.
Why. The press is the precise moment the user wants confirmation. Reusing the already-defined hover color for :active means the button shows a clear delta the instant a finger or mouse-down lands: no waiting for click, no separate design token to maintain, and desktop (mousedown) and touch (tap) get visually identical acknowledgment. The browser applies :active automatically on press, so feedback is synchronous with the physical input and decoupled from the slower click.
How (portable).
.btn:active:not(:disabled) { background: var(--btn-bg-hover); }
This is the same value as the --btn-bg-pressed from the earlier sections. Press and hover deliberately share one token, so there’s no second color to keep in sync.
Gotchas
- If the transition duration is long, the press color eases in slowly and feels mushy. Keep it short (~
0.2s) so the change is perceptible immediately.- Driving the pressed color from a JS class toggled on pointer events reintroduces latency and a framework re-render or change-detection cost that CSS
:activeavoids entirely.
7. Honest affordances: guard every state with :not(:disabled)
Principle: Append :not(:disabled) to every hover/active selector so disabled controls never light up under hover or press.
Why. A disabled button that still changes color on hover or press lies: it implies it’s actionable. Scoping all feedback with :not(:disabled) makes “disabled” a true visual dead-end: no hover, no active, just the muted disabled style. This keeps the affordance honest and mirrors the JS-level click suppression in the next section.
How (portable).
.btn:hover:not(:disabled) { … }
.btn:active:not(:disabled) { … }
.btn:disabled {
cursor: not-allowed;
opacity: var(--btn-disabled-opacity, 0.5);
}
Combined with the native disabled attribute, the browser both blocks the events and refuses the feedback styling.
Gotchas
- This only works for free on native elements. A
div[role=button]does not get the:disabledpseudo-class, so you must add your own attribute or class and select on it.- Omitting the guard makes disabled buttons appear interactive, a common accessibility and UX defect.
8. Three-layer suppression: native [disabled] + pointer-events + JS guard
Principle: Block interaction at the native attribute, CSS, and JS-handler levels simultaneously rather than trusting any single one. Treat loading as a form of disabled.
Why (defense in depth). Each layer covers a different gap:
- The native
[disabled]attribute stops the browser dispatchingclickand removes the control from tab order. pointer-events: noneon the busy/loading state blocks pointer interaction even mid-spinner.- A guard in the click handler refuses to emit when effectively disabled.
Any one layer can be bypassed: a programmatic .click(), a race where the attribute lags a frame, or loading being set asynchronously just after the first emit. All three together guarantee no action fires while disabled or loading, which prevents double-submits on in-flight async actions.
How (portable). Compute one boolean and bind it everywhere:
const effectivelyDisabled = disabled || loading
// native attribute
button.disabled = effectivelyDisabled
// JS guard
function handleClick() {
if (effectivelyDisabled) return
emitAction()
}
.btn.loading { pointer-events: none; }
In Angular, effectivelyDisabled is a derived signal (disabled() || loading()) bound to the native [disabled] attribute; the handler early-returns on it; the .loading class sets pointer-events: none:
handleClick() {
if (this.effectivelyDisabled()) {
return
}
this.pressed.emit()
}
A test should explicitly assert that clicking a disabled or loading button emits nothing.
Gotchas
- A JS guard alone leaves the button focusable and clickable via assistive tech or programmatic clicks. The attribute alone misses the
loading=busycase if you forget to foldloadingintodisabled.pointer-events: nonealso kills hover and cursor feedback. That’s desired for a busy button but wrong for a generally interactive element, so scope it to the loading state.- Double-submit slips through if
loadingis set after the first emit. Foldloadinginto the sameeffectivelyDisabledgate the handler checks.
9. Stand on the native <button>: keyboard, dedup, and disabled for free
Principle: Wrap a real <button>, not a div, to inherit keyboard activation, focus management, disabled semantics, and the browser’s touch+mouse click deduplication.
Why. A native <button> gives you Enter/Space activation, correct tab order, the :disabled pseudo-class, role=button semantics, and, critically for touch, the browser’s own synthesized-click logic that already dedupes touch and mouse into a single click. Leaning entirely on click for activation is only safe because the platform guarantees one click per intent. Rebuild this on a div and you invite missing keyboard support, double-firing, and broken assistive-tech behavior.
How (portable). Use <button> as the interactive root; listen for click for the action; reserve touch listeners for visuals only.
Tip. Default the button’s type to 'button' so it never accidentally submits an enclosing <form>, and confirm with a test that exactly one action fires per click.
Gotchas
- A
role=buttondivmust manually implement Enter and Space, disabled, and focus, and must guard against the touch+click double-fire that<button>handles for you.- Forgetting
type="button"on a button inside a<form>causes accidental form submission, because the defaulttypeissubmit.
10. The keyboard ring: :focus-visible, not :focus
Principle: Draw the focus outline with :focus-visible so keyboard users get a ring while mouse and touch presses don’t leave a lingering outline.
Why. Plain :focus shows the outline after every click or tap and leaves it stuck on the button after activation. It’s visually noisy, and often mistaken for a “selected” state. :focus-visible uses the browser’s heuristic to show the ring only when focus came from the keyboard (or other non-pointer means), giving accessible navigation without polluting the pointer-driven press experience.
How (portable). Style :focus-visible; avoid styling bare :focus:
.btn:focus-visible {
outline: 2px solid var(--btn-focus-ring, #3b82f6);
outline-offset: 2px;
}
Gotchas
- Removing
outlineon:focuswithout providing:focus-visibledestroys keyboard accessibility. Always replace, never just delete.- If a variant deliberately styles bare
:focustoo (for example, a primary call-to-action that mirrors its active state on focus), mix it carefully so the keyboard ring still reads clearly.
11. Crisp presses: disable selection and the long-press callout
Principle: Apply user-select: none so a press-and-hold doesn’t start text selection or pop the OS selection/callout UI.
Why. On touch, holding a button (natural while waiting for feedback) can trigger text selection of the label or the iOS callout menu. It feels broken and interrupts the press. On desktop, a fast double-click selects the label text. user-select: none makes the control behave like a button, not selectable content.
How (portable). Together with -webkit-tap-highlight-color and touch-action, this completes the “feels like a native control, not a web page element” trio:
.btn {
user-select: none;
-webkit-user-select: none; /* older WebKit */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
Gotchas
- Older iOS may also need
-webkit-touch-callout: noneto fully suppress the long-press callout on certain content;user-selectalone may not cover it.- Don’t apply
user-select: noneto containers that legitimately hold selectable text. Scope it to the control.
12. Icon-only buttons: hit targets and ARIA
Principle: Give icon-only buttons an explicit square footprint per size tier, surface aria-label only when there’s no visible text, and reflect toggle state in aria-pressed.
Why. An icon with no padding is a tiny, easy-to-miss target on touch and invisible to screen readers. Sizing icon-only buttons to a fixed square (matching your height tiers) makes the tap area equal the visual. Emitting aria-label only when the layout is icon-only avoids overriding a visible label with a redundant or conflicting accessible name. Toggle buttons expose aria-pressed so assistive tech announces the on/off state that the active styling conveys visually.
How (portable). Explicit width = height per size (e.g. a 32px square); compute the accessible name conditionally; set aria-pressed only for toggles (leave it unset otherwise so the attribute is absent):
.btn.icon-only { width: 32px; padding: 0; }
// aria-label only when icon-only; null → attribute omitted
const effectiveAriaLabel = computed(() =>
layout() === 'icon-only' && ariaLabel() ? ariaLabel() : null
)
// bind aria-pressed to the toggle state; default it to undefined → attribute omitted for non-toggles
Gotchas
- A small icon-only tier (e.g. 24px square) can meet WCAG 2.5.8 (Minimum, AA)’s 24px target while still being well under the ~44px comfortable touch target (Apple’s Human Interface Guidelines, or WCAG 2.5.5 Enhanced AAA). Acceptable only for dense desktop toolbars; prefer larger tiers on touch-primary surfaces.
- Emitting
aria-labelon a button that already has visible text overrides the visible label for screen readers, a mismatch. Gate it to icon-only.- Always emitting
aria-pressed(evenfalse) on a non-toggle mislabels it as a toggle. Leave the input undefined so the attribute is omitted.
13. Short, scoped transitions, and reduced motion
Principle: Transition only the properties that change between states, on a short ~200ms ease. Never use transition: all, and respect prefers-reduced-motion.
Why. A press that snaps with zero transition can look harsh; one that eases over 400ms+ feels sluggish and detached from the input. A ~0.2s ease on exactly the changing properties (background, border, color) is polished but still immediate. Enumerating properties instead of all avoids accidentally animating layout or transform, and keeps it cheap.
How (portable).
.btn {
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
}
Keep continuous animations (like a loading spinner) as separate @keyframes, and consider pausing or simplifying those under reduced motion too.
Gotchas
- Animating the
:activecolor over a long duration delays the perceived acknowledgment of a press. Keep it short.transition: allcan pick up layout properties and cause jank. Enumerate.
Generalizing beyond buttons
The split that makes this work (semantic event = click; visual-only = touchstart plus CSS pseudo-classes) applies to any custom control: sliders, toggles, list rows, menu items, tabs, cards. A few pieces are directly reusable elsewhere:
- The
@media (hover: hover) and (pointer: fine)split fixes sticky hover anywhere:hovercurrently sticks on touch (cards, menu items, tabs). - The
effectivelyDisabled = disabled || loadingfold plus three-layer suppression generalizes to any async-action control to prevent double-submit. - The empty
touchstartlistener unlocks:activefor any element whose press visuals you want the browser to paint for free on iOS.
The lesson underneath all of it: prefer the platform’s native element and CSS pseudo-classes (:active, :hover, :focus-visible, :disabled) for state, and reserve JavaScript and touch-specific listeners for the few rough edges the platform leaves behind.