Zero-config, lightweight drop-in “intent press” utility. Automatically fire “click-like” handlers only when the user actually meant it (not on scroll releases, drags, text selection, or ghost clicks).
You want tappable cards and list items to feel solid, but you don't want to:
- ❌ Trigger navigation after a scroll release (“oops click”).
- ❌ Open things while selecting text.
- ❌ Maintain a spaghetti mess of
isDraggingflags and timeouts. - ❌ Fight ghost clicks /
pointercanceledge cases across browsers.
nope-click is the solution. It wraps pointer interactions into a small “intent transaction” and only commits when the interaction stays intentional.
- Universal: Works with React, Vue, Svelte, Solid, Angular, and Vanilla JS.
- Tiny: Tree-shakeable, no runtime deps.
- Performant: Passive listeners, scoped listeners via
AbortController, minimal work per event.
npm install nope-click
# or
yarn add nope-click
# or
pnpm add nope-clickUse the useIntentPress hook.
import { useState } from 'react'
import { useIntentPress } from 'nope-click/react'
const MyCard = () => {
const [count, setCount] = useState(0)
const press = useIntentPress(() => setCount((c) => c + 1))
return (
<>
<div
onPointerDown={press.onPointerDown}
onClickCapture={press.onClickCapture}
style={{ padding: 12, border: '1px solid #ddd' }}
>
Intent presses: {count}
</div>
<small>Try scrolling on touch, dragging, or selecting text.</small>
</>
)
}
}Use the v-intent-press directive.
<script setup>
import { ref } from 'vue'
import { vIntentPress } from 'nope-click/vue'
const count = ref(0)
</script>
<template>
<div v-intent-press="() => count++">
Intent presses: {{ count }}
</div>
</template>Use the intentPress action.
<script>
import { intentPress } from 'nope-click/svelte'
let count = 0
</script>
<!-- Pass options directly to the action -->
<div use:intentPress={{ onIntent: () => (count += 1), options: { clickGuard: true } }}>
Intent presses: {count}
</div>
Use the intentPress directive.
Note for TypeScript users: You need to extend the JSX namespace to avoid type errors with use:.
import { createSignal } from 'solid-js'
import { intentPress } from 'nope-click/solid'
// ⚠️ TypeScript only: Add this declaration to fix "Property 'use:intentPress' does not exist"
declare module "solid-js" {
namespace JSX {
interface Directives {
intentPress: boolean | { onIntent: (ev: any) => void; options?: object }
}
}
}
function App() {
const [count, setCount] = createSignal(0)
return (
<div use:intentPress={{ onIntent: () => setCount((c) => c + 1) }}>
Intent presses: {count()}
</div>
)
}
}Use the standalone IntentPressDirective.
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IntentPressDirective } from 'nope-click/angular';
@Component({
selector: 'app-card',
standalone: true,
imports: [CommonModule, IntentPressDirective],
template: `
<div
[intentPress]="onIntent"
[intentPressOptions]="{ clickGuard: true }"
style="padding: 12px; border: 1px solid #ddd;"
>
Intent presses: {{ count }}
</div>
`
})
export class CardComponent {
count = 0;
onIntent = () => { this.count++; };
}import { createIntentPress } from 'nope-click/core'
const el = document.getElementById('card')
// Enable intent presses
const press = createIntentPress(() => {
console.log('intent!')
})
el.addEventListener('pointerdown', press.onPointerDown, { passive: true })
el.addEventListener('click', press.onClickCapture, { capture: true })
// Later, if you want to stop:
// press.destroy()You can customize the duration and easing function.
/// React
useIntentPress(onIntent, { slop: 10, clickGuard: true })
// Vue
<div v-intent-press="{ handler: onIntent, options: { slop: 10 } }">
// Svelte
<div use:intentPress={{ onIntent, options: { slop: 10 } }}>| Option | Type | Default | Description |
|---|---|---|---|
slop |
number |
auto |
Movement allowed (px) before canceling as a drag. |
maxPressMs |
number |
0 |
Max press duration; 0 disables timeout. |
allowModified |
boolean |
false |
Allow ctrl/alt/meta/shift modified presses. |
allowTextSelection |
boolean |
false |
If false, cancels when selection becomes a range. |
allowNonPrimary |
boolean |
false |
Allow non-primary mouse buttons. |
preventDefault |
boolean |
false |
Call preventDefault() on pointerdown when safe. |
clickGuard |
boolean |
true |
Suppress the trailing “ghost click” (capture phase). |
enabled |
boolean |
true |
Enable/disable without rewiring. |
pointerdownstarts a “press transaction” (remember start point, selection snapshot, scroll parents).- cancel when the user scrolls, drags past
slop, selects text, or the browser cancels the pointer. pointerupcommits: hit-test the release point (elementFromPoint), then call your handler.- click capture guard (optional) suppresses the follow-up ghost click.
"We eliminated the
isDraggingspaghetti mess, saved your users from accidental scroll-clicks, and absorbed the cross-browserpointercancelnightmare. You saved dozens of hours not reinventing a wheel. Your donation is a fair trade for a rock-solid UI and weekends free from debugging."
If this library saved you time, please consider supporting the development:
- Fiat (Cards/PayPal): via Boosty (one-time or monthly).
- Crypto (USDT/TON/BTC/ETH): view wallet addresses on Telegram.
MIT
nope-click, intent-press, intent-click, press, tap, touch, mobile, pointer-events, pointerdown, pointerup, pointercancel, click, click-capture, click-guard, ghost-click, accidental-click, scroll-release, drag, text-selection, hit-test, elementFromPoint, AbortController, passive-listeners, event-handling, interaction, ui, ux, zero-config, lightweight, tree-shakeable, react, vue, svelte, solid, angular, vanilla-js, typescript