Ignores clicks on a button for a moment after it appears, moves or changes, so people don't press something that just slid under the cursor.
StopsClicked button that just appeared
"use client"
import * as React from "react"
import { Sparkles } from "lucide-react"
import { ClickShield, type ShieldReason } from "@/components/ui/click-shield"
const reasons: Record<ShieldReason, string> = {
appeared: "it had only just appeared",
moved: "a banner had just pushed it down",
changed: "its label had just changed",
returned: "you had only just come back to the tab",
focused: "focus had just been moved onto it",
}
const button =
"inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50"
export function Demo() {
const [banner, setBanner] = React.useState(false)
const [log, setLog] = React.useState("A late banner is about to push these buttons down.")
// The offer "loads" a moment after the page, like a real late banner.
React.useEffect(() => {
const timer = setTimeout(() => setBanner(true), 450)
return () => clearTimeout(timer)
}, [])
const blocked = (label: string) => (reason: ShieldReason) => setLog(`Ignored a click on “${label}”: ${reasons[reason]}.`)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">Pro plan · ₹499/month</p>
<button
type="button"
onClick={() => {
setBanner(false)
setTimeout(() => setBanner(true), 300)
}}
className="text-xs text-muted-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Replay the shift
</button>
</div>
{banner && (
<p className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-950 dark:text-amber-50">
<Sparkles aria-hidden className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
Switch to yearly and save 2 months.
</p>
)}
<div className="flex flex-wrap gap-2">
<ClickShield onBlockedClick={blocked("Cancel subscription")}>
<button
type="button"
onClick={() => setLog("Subscription cancelled.")}
className={`${button} bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60`}
>
Cancel subscription
</button>
</ClickShield>
<ClickShield onBlockedClick={blocked("Keep plan")}>
<button
type="button"
onClick={() => setLog("Kept your plan.")}
className={`${button} border bg-background shadow-xs hover:bg-accent dark:border-input dark:bg-input/30`}
>
Keep plan
</button>
</ClickShield>
</div>
<p aria-live="polite" className="min-h-8 text-xs text-muted-foreground">
{log}
</p>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Copy and paste the following code into
components/uiclick-shield.tsx167 lines"use client" // Bollard UI — click-shield · MIT licence · https://bollard-ui.vercel.app/components/click-shield import * as React from "react" import { cn } from "@/lib/utils" /** Why the shield went up: the target just mounted, shifted, relabelled, the tab came back, or focus was moved onto it by code. */ export type ShieldReason = "appeared" | "moved" | "changed" | "returned" | "focused" export interface ClickShieldOptions { /** How long clicks are ignored after a change, in ms. Chrome's dialog guard uses 500. */ duration?: number /** Turn the shield off entirely. */ enabled?: boolean /** Called for every click the shield swallowed, with the reason it was up. */ onBlockedClick?: (reason: ShieldReason) => void } /** Pixels an element may drift (subpixel layout, zoom) before it counts as moved. */ const MOVE_TOLERANCE = 2 /** A focus that follows a Tab, arrow key or pointer press within this many ms was the user's own. */ const USER_FOCUS_WINDOW = 400 const NAV_KEYS = new Set(["Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]) /** * Ignores pointer clicks on `ref` for `duration` ms after it appears, moves * (not counting scrolls), changes its label, or the tab becomes visible again. * A press that *started* inside the window is ignored even if released after it. * * Keyboard users aim with focus, not a cursor, so moves and relabels never block * them. Their one trap is a dialog that steals focus onto a button while they are * mid-keystroke, so Enter/Space are only shielded right after code (not the user) * moved focus there, and a held Enter's auto-repeat never activates. */ export function useClickShield( ref: React.RefObject<HTMLElement | null>, { duration = 500, enabled = true, onBlockedClick }: ClickShieldOptions = {}, ) { const [reason, setReason] = React.useState<ShieldReason | null>(null) const until = React.useRef(0) const reasonRef = React.useRef<ShieldReason>("appeared") const pressShielded = React.useRef(false) const focus = React.useRef({ at: -Infinity, byUser: true }) const userInputAt = React.useRef(-Infinity) const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined) const raise = React.useCallback( (next: ShieldReason) => { until.current = performance.now() + duration reasonRef.current = next setReason(next) clearTimeout(timer.current) timer.current = setTimeout(() => setReason(null), duration) }, [duration], ) React.useEffect(() => { const el = ref.current if (!enabled || !el) return raise("appeared") // Positions are polled per frame: nothing else reports a shift caused by content elsewhere on the page. let scrolledAt = -Infinity let last = el.getBoundingClientRect() let frame = requestAnimationFrame(function tick() { const rect = el.getBoundingClientRect() const moved = Math.abs(rect.left - last.left) > MOVE_TOLERANCE || Math.abs(rect.top - last.top) > MOVE_TOLERANCE if (moved && performance.now() - scrolledAt > 100) raise("moved") last = rect frame = requestAnimationFrame(tick) }) let label = el.textContent const observer = new MutationObserver(() => { if (el.textContent === label) return label = el.textContent raise("changed") }) observer.observe(el, { subtree: true, childList: true, characterData: true }) const onScroll = () => (scrolledAt = performance.now()) const onVisible = () => document.visibilityState === "visible" && raise("returned") const onUserInput = (event: Event) => { if (event.type === "pointerdown" || NAV_KEYS.has((event as KeyboardEvent).key)) userInputAt.current = performance.now() } document.addEventListener("scroll", onScroll, { capture: true, passive: true }) document.addEventListener("visibilitychange", onVisible) document.addEventListener("keydown", onUserInput, true) document.addEventListener("pointerdown", onUserInput, true) return () => { cancelAnimationFrame(frame) observer.disconnect() clearTimeout(timer.current) document.removeEventListener("scroll", onScroll, true) document.removeEventListener("visibilitychange", onVisible) document.removeEventListener("keydown", onUserInput, true) document.removeEventListener("pointerdown", onUserInput, true) } }, [ref, enabled, raise]) const handlers = { onPointerDownCapture: () => { pressShielded.current = enabled && performance.now() < until.current }, onFocusCapture: () => { const now = performance.now() const byUser = now - userInputAt.current < USER_FOCUS_WINDOW focus.current = { at: now, byUser } if (enabled && !byUser) raise("focused") }, onKeyDownCapture: (event: React.KeyboardEvent) => { // A held Enter clicks on every auto-repeat; only a fresh press may activate. if (enabled && event.repeat && (event.key === "Enter" || event.key === " ")) event.preventDefault() }, onClickCapture: (event: React.MouseEvent) => { if (!enabled) return const now = performance.now() const keyboard = event.detail === 0 const blocked = keyboard ? !focus.current.byUser && now - focus.current.at < duration : pressShielded.current || now < until.current pressShielded.current = false if (!blocked) return event.preventDefault() event.stopPropagation() onBlockedClick?.(keyboard ? "focused" : reasonRef.current) }, } return { shielded: reason !== null, reason, handlers } } export interface ClickShieldProps extends ClickShieldOptions, React.ComponentProps<"span"> {} /** * Wrap a button (or a group of them) so a click meant for something else can't * land on it: when a banner loads above it, a popup opens under the cursor or * its label flips from "Save" to "Delete", clicks are ignored for ~500ms while * it fades back in. See `useClickShield` for how keyboard users are treated. */ export function ClickShield({ duration, enabled, onBlockedClick, className, children, ref: forwarded, ...props }: ClickShieldProps) { const ref = React.useRef<HTMLSpanElement>(null) React.useImperativeHandle(forwarded, () => ref.current as HTMLSpanElement) const { shielded, reason, handlers } = useClickShield(ref, { duration, enabled, onBlockedClick }) return ( <span ref={ref} data-slot="click-shield" data-shielded={shielded || undefined} data-reason={reason ?? undefined} className={cn( "inline-flex [&>*]:transition-[opacity,filter] [&>*]:duration-300", shielded && "[&>*]:opacity-60 [&>*]:saturate-50 [&>*]:duration-0", className, )} {...props} {...handlers} > {children} </span> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { ClickShield, type ShieldReason } from "@/components/ui/click-shield"
<ClickShield onBlockedClick={blocked("Cancel subscription")}>
<button
type="button"
onClick={() => setLog("Subscription cancelled.")}
className={`${button} bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60`}
>
Cancel subscription
</button>
</ClickShield>What it catches
- Clicks on a control that just mounted
- Clicks after the control moved under the pointer (layout shift)
- Clicks right after its label changed, or when the tab comes back
- Enter/Space key repeats on focus that code moved there
Props
| Prop | Type | Default |
|---|---|---|
durationHow long clicks are ignored after a change, in ms. Chrome's dialog guard uses 500. | number | — |
enabledTurn the shield off entirely. | boolean | — |
onBlockedClickCalled for every click the shield swallowed, with the reason it was up. | (reason: ShieldReason) => void | — |
* required. Also accepts the native props of <span> (className, aria-*, data-*…), passed through to it.
Accessibility
- Keyboard presses are not delayed unless focus was moved by code
- Exposes data-shielded for styling; no ARIA changes to the wrapped control
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Swallows real clicks during the window too; keep duration short
- A timing guard only: it doesn't confirm intent for destructive actions
What it stops
Clicked button that just appeared
How people describe it
Also known as
layout shift · cls · misclick · click protection · late button · dialog appears · ghost click
Dependencies
None beyond React and Tailwind CSS.