@symbo.ls/tap
v3.14.705
Published
Modern touch responsiveness for Symbols apps — the declarative FastClick successor: touch-action, tap-highlight, viewport guarantee, instant pressed feedback.
Maintainers
Keywords
Readme
@symbo.ls/tap
Modern touch responsiveness for Symbols apps — the declarative successor to FastClick (FT Labs, archived 2022).
The subject, in short
Mobile browsers historically waited ~300ms after touchend before firing
click, to disambiguate taps from double-tap-to-zoom. FastClick removed that
delay by listening to touch events and synthesizing immediate clicks —
which created its own bug family: ghost clicks, double-firing dropdowns, input
focus quirks, and the needsclick escape hatch.
Browsers then fixed the root cause. The delay is gone when either:
- the page declares
<meta name="viewport" content="width=device-width">(Chrome 32+, iOS Safari 9.3+), or - the element carries
touch-action: manipulation(pan + pinch stay enabled, double-tap-zoom is disabled for that element).
So the modern solution is declarative — no event synthesis, no click suppression, no heuristics. That is what this plugin ships:
- Viewport guarantee — injects the device-width viewport meta only when the document lacks one (never overrides).
touch-action: manipulationon every interactive element (tags + ARIA roles) — kills residual tap-to-zoom jank on controls.- Tap-highlight removal on controls that own their pressed states —
the OS gray/blue flash stops fighting design-system
:activestyles. - Instant pressed feedback — passive, capture-phase pointer delegation
stamps
data-pressedon the pressed control. Style it from your design system ('[data-pressed]': { … }); nothing visual is imposed by default. Side effect: document-level pointer listeners also make CSS:activefire reliably on iOS Safari.
Deliberately not included by default: click synthesis or ghost-click suppression. With the declarative fixes in place, modern engines emit exactly one trusted click per tap; suppression heuristics are how FastClick ate legitimate clicks.
Usage
Runtime (DOMQL apps):
import { tapPlugin } from '@symbo.ls/tap'
// in the create() context
plugins: [tapPlugin]Options ride the context: context.tap = { viewport, styles, pressed,
pressedStyle, longPress } (all boolean except longPress, which is also
boolean but can be an options object; defaults true, true, true, false,
false).
SSR / static HTML (what the @symbo.ls/mermaid renderer does for every published site):
import { getTapStyles, getTapRuntimeSnippet } from '@symbo.ls/tap'
const head = `<style id="smbls-tap-styles">${getTapStyles()}</style>
<script>${getTapRuntimeSnippet()}</script>`Both paths share the data-smbls-tap marker on <html> — whichever installs
first wins; the other no-ops.
Long-press → contextmenu (opt-in)
iOS WKWebView never fires a native contextmenu on a long touch (and never
for Apple Pencil), so any surface that wires per-item actions through
onContextmenu / addEventListener('contextmenu', …) is unreachable by
touch unless something synthesizes the event. longPress is the declarative,
opt-in answer — OFF by default, because popping a menu is a UX decision a
surface makes on purpose, not something every app should get for free:
// Runtime — every touch surface in this app gets the affordance:
plugins: [tapPlugin]
// context: { tap: { longPress: true } }
// or tune the defaults (500ms stationary press, 10px move tolerance):
// context: { tap: { longPress: { ms: 400, moveTolerance: 8 } } }
// SSR:
getTapRuntimeSnippet({ longPress: true })A ~500ms stationary touch/pen press dispatches a real, bubbling
MouseEvent('contextmenu', …) on the touched element — driving both DOMQL
onContextmenu flat handlers and native addEventListener('contextmenu', …)
listeners identically to an actual right-click. Mouse/trackpad presses are
untouched (they already get a real contextmenu). This is not click
synthesis — the FastClick trap this plugin otherwise avoids — it only ever
dispatches contextmenu.
Gesture surfaces
Pair with the touchAction / overscrollBehavior element props (registered
in css-in-props alongside this plugin):
touchAction: 'none'on drag handles and reorder surfaces (pointer-capture drags stop fighting page scroll)touchAction: 'pan-y'on horizontal swipersoverscrollBehavior: 'contain'on inner scrollers (no scroll chaining)
