w3tweaks.com · CSS Tutorial

CSS pointer-events

Click-through layers · disabled states · hover fixes · gotchas

Tab 1

Layer Explorer — Click Anywhere in the Canvas

Three stacked layers. Toggle each layer's pointer-events on/off and click the canvas to see which layer catches the click. Watch the event log update in real time.

Layer controls
Layer 1 (bottom)
Layer 2 (middle)
Layer 3 (top)
Quick presets
Click anywhere → Dots show where events land
Layer 1
Layer 2
Layer 3
Click a layer to see which one catches the event...
Key insight: pointer-events affects hit-testing, not visibility. Setting pointer-events: none makes an element transparent to mouse, touch, and pen input — the browser skips it entirely during hit-testing and passes the event to whatever is behind it in the stacking order. The element still renders, still occupies layout space, and is still reachable by keyboard Tab navigation.
Tab 2

6 Real UI Patterns

Production use cases. Interact with each demo — hover, click, Tab through.

🚫 Disabled Button State
.btn-disabled {
  pointer-events: none;
  opacity: 0.6;
  cursor: not-allowed; ← visual only
}

Note: cursor: not-allowed only changes the cursor appearance. pointer-events: none actually blocks the click. Use both together for the best disabled state.

💬 Tooltip Hover Flicker Fix
❌ Jiggles (pointer-events: auto)
Tooltip steals hover — it jiggles!
✓ Stable (pointer-events: none)
Tooltip stays — no jiggle!
.tooltip {
  pointer-events: none; ← key fix
}
/* Tooltip won't steal hover state */

Without pointer-events: none, the tooltip appears over the trigger, steals the hover state, and the tooltip disappears. With it, hover stays on the trigger element permanently.

🪟 Click-Through Decorative Overlay
overlay (passes through) →
.overlay {
  position: absolute; inset: 0;
  pointer-events: none; ← passes through
  /* gradient/texture stays visible */
}

The gradient overlay renders above the buttons but clicks pass through it to the interactive elements below. No JavaScript event forwarding needed.

🔓 Re-enable Child Inside Disabled Parent
parent: none
blocked text
.parent { pointer-events: none; }
/* override: specific child is interactive */
.parent .active-child { pointer-events: auto; }

Click the purple button — it works even though the red-dashed parent has pointer-events: none. The child's auto override re-enables just that element.

⬡ SVG Custom Hit Areas
pointer-events: fill
pointer-events: stroke
pointer-events: all
rect { pointer-events: fill; } ← only inside fill
circle { pointer-events: stroke; } ← only on stroke line
path { pointer-events: all; } ← entire bounding box

SVG-specific values give precise control over which part of an SVG element is "clickable" — just the fill, just the stroke, or the entire bounding box.

⏳ Form Loading State
/* Block while submitting */
form.submitting {
  pointer-events: none;
  opacity: 0.7;
}

Click Submit to see the form lock during "loading". pointer-events: none on the form prevents double-submission without disabling each input individually.

Tab 3

Critical Gotchas

Four ways pointer-events surprises developers — and how to handle each.

⚠️ Gotcha 1: Ghost Clicks (opacity:0 trap)

If you hide an element with opacity: 0 without adding pointer-events: none, the element is invisible but still clickable — a "ghost button".

Try clicking the LEFT half — that's the visible button. Then the RIGHT half — surprise: an invisible ghost button is sitting there too!

/* ❌ Invisible but still clickable */
.hidden { opacity: 0; }

/* ✅ Truly gone from interactions */
.hidden { opacity: 0; pointer-events: none; }
⚠️ Gotcha 2: Keyboard Still Works!

pointer-events: none blocks mouse, touch, and pen — but keyboard navigation via Tab still reaches the element. Screen readers can also activate it.

⌨ Tab here and press Enter
/* pointer-events: none does NOT prevent: */
/* - Tab key focus */
/* - Enter/Space key activation */
/* - Screen reader interaction */

/* To truly disable for ALL input: */
.disabled {
  pointer-events: none;
  tabindex: "-1"; ← JS to set
  aria-disabled: "true";
}

Tab to the red button and press Enter — it fires despite pointer-events: none. For a true disabled state, also set tabindex="-1" and aria-disabled="true".

Gotcha 3: cursor:not-allowed ≠ pointer-events:none

They look similar but do completely different things. Hover each box:

cursor: not-allowed
Cursor changes ✓
Still clickable! ❌
pointer-events: none
Not clickable ✓
Cursor unchanged
Both together ✓
Right cursor ✓
Not clickable ✓
/* Only changes the cursor visual */
.bad-disabled { cursor: not-allowed; }

/* Best disabled: visual + interaction */
.good-disabled {
  cursor: not-allowed;
  pointer-events: none;
  opacity: 0.6;
}
Gotcha 4: Events Still Bubble Through

If a child element has pointer-events: auto inside a parent with none, the child fires events that bubble UP through the parent, triggering any parent event listeners.

parent: pointer-events:none
Click the child button...
/* Child can still fire events */
/* that bubble to the parent! */
parent.addEventListener('click', e => {
  // STILL fires when child is clicked
  // because events bubble through
});

The parent itself cannot be clicked directly. But if the child fires a click, it bubbles through the parent's DOM node and triggers any parent listeners.

touch-action — the mobile complement: pointer-events: none blocks click, hover, and tap. But on mobile, scroll and pinch-zoom are touch gestures, not pointer events. To also block scroll/zoom on an element, add touch-action: none. Be careful — blocking touch-action on large areas can make the page feel broken on mobile.
Read the tutorial