Mouse clicks are easy: you press, you release, you get an event. Movement is messier. As the pointer slides across the page it crosses boundaries between elements, dives into children, skips over things when you move fast, and sometimes appears from nowhere. This chapter is about the events that fire during all that motion and the small surprises that trip people up.
Events mouseover/mouseout, relatedTarget
The mouseover event fires when the pointer comes over an element. Its partner, mouseout, fires when the pointer leaves.
mouseover fires on entry, mouseout fires on exit.
What makes these two events special is a property most events don’t carry: relatedTarget. It works together with target. When the pointer crosses from one element to another, one element becomes target and the other becomes relatedTarget. Which is which depends on the event.
For mouseover:
event.target is the element the pointer just arrived at.
event.relatedTarget is the element it came from. Read it as relatedTarget → target.
For mouseout the roles flip:
event.target is the element the pointer just left.
event.relatedTarget is the element it’s heading into. Read it as target → relatedTarget.
mouseover on B
relatedTarget = A (came from)
target = B (arrived)
A → B
mouseout on A
target = A (left)
relatedTarget = B (heading to)
A → B
target and relatedTarget swap roles between the two events for the same physical move.
In the demo below, each tile and the dot sitting inside it is a separate element. Move the mouse around and watch the events stream into the text area. Each one reports both target and relatedTarget, so you can see the pairing in action.
interactiveWatch target and relatedTarget on every crossing
Live example — Watch target and relatedTarget on every crossing
HTML
<div id="stage">
<div class="tile" data-name="RED">
<div class="dot" data-name="RED-dot"></div>
</div>
<div class="tile alt" data-name="BLUE">
<div class="dot" data-name="BLUE-dot"></div>
</div>
</div>
<pre id="log">Move the mouse over the tiles and dots…</pre>
mousemove fires as the pointer moves. But it does not fire for every pixel the pointer passes through.
The browser samples the pointer position periodically rather than tracking it continuously. When it checks and sees the position changed, it dispatches the events for wherever the pointer is now. The gap between samples is where things get skipped.
Move the mouse fast enough and whole DOM elements between the start and end points never receive events:
A fast move from #FROM to #TO can skip every element in between.
In that scenario the pointer flies from #FROM to #TO. mouseout fires on #FROM, then mouseover fires on #TO, and the intermediate <div> elements get nothing.
This is good for performance. There could be dozens of elements along a path, and you rarely want to run enter/leave logic for each one just because the cursor grazed it on the way somewhere else.
The flip side: you can’t assume the pointer “visited” every element between two points. It jumps.
You can feel the sampling directly. The strip below counts one mousemove for every event it receives and, separately, the straight-line distance in pixels the pointer has covered. Drag slowly and the two numbers stay close; sweep across quickly and you’ll rack up far more pixels than events, proof that whole stretches of the path fired nothing.
interactivemousemove is sampled, not per-pixel
Live example — mousemove is sampled, not per-pixel
HTML
<div id="pad">move across me — slow, then fast</div>
<div id="stats">
<span>events: <b id="events">0</b></span>
<span>pixels traveled: <b id="pixels">0</b></span>
</div>
<button id="reset" type="button">Reset</button>
One consequence is that the pointer can land in the middle of the page straight from outside the window, without touching anything at the edges first. When that happens, relatedTarget is null, because there’s no element it came from:
The pointer can appear mid-page from outside — relatedTarget is null.
You can feel this behavior live below. The HTML nests <div id="child"> inside <div id="parent">. Move the mouse fast over the pair and you might trigger events on only the child, only the parent, or nothing at all.
Try one thing in particular: move into the child div, then flick the pointer straight down and out through the parent. If you’re quick enough, the parent gets ignored entirely, the cursor crosses it without leaving a mark.
interactiveFast moves can skip the parent entirely
Live example — Fast moves can skip the parent entirely
HTML
<div id="parent">
parent
<div id="child">child</div>
</div>
<pre id="log">Flick the pointer through, fast and slow…</pre>
const log = document.getElementById('log');
function name(el) { return el ? (el.id || el.tagName) : 'null'; }
function record(e) {
// only log the element this handler is bound to (ignore bubbled events)
if (e.target !== e.currentTarget) return;
const line = document.createElement('div');
const tag = e.type === 'mouseover' ? 'over ' : 'out ';
line.textContent = tag + '| ' + name(e.currentTarget) +
' (from/to ' + name(e.relatedTarget) + ')';
log.prepend(line);
while (log.children.length > 40) log.lastChild.remove();
}
for (const id of ['parent', 'child']) {
const el = document.getElementById(id);
el.addEventListener('mouseover', record);
el.addEventListener('mouseout', record);
}
Mouseout when leaving for a child
Here’s the behavior that surprises almost everyone. mouseout fires when the pointer moves from an element into one of its own descendants. Given this markup:
<div id="parent"> <div id="child">...</div></div>
If the pointer is over #parent and then moves deeper into #child, you get mouseout on #parent. The cursor never physically left the parent’s box, yet the parent reports that the mouse went out.
Moving into #child fires mouseout on #parent, even though the pointer is still inside it.
It looks strange until you know the rule the browser follows:
So when the cursor moves onto #child, that becomes the single element it’s over. #parent is no longer the element, even though the child sits inside it. Leaving #parent’s status as the current element counts as a mouseout.
Now the second half of the story, and it’s what makes this manageable. The mouseover that fires on #childbubbles. If #parent has a mouseover handler, that handler runs too, because the event bubbles up from child to parent:
mouseover on #child bubbles up, so a handler on #parent also runs.
The demo makes this concrete. #child sits inside #parent, and #parent has mouseover/mouseout handlers that print event details. Move the mouse from the parent into the child and you’ll see two lines appear on the parent:
mouseout [target: parent] — the pointer left the parent, then
mouseover [target: child] — it arrived at the child, and that event bubbled up.
interactiveHandlers live on #parent only — watch the out/over pair
Live example — Handlers live on #parent only — watch the out/over pair
HTML
<div id="parent">
#parent
<div id="child">#child</div>
</div>
<pre id="log">Move from the parent area into the child…</pre>
const parent = document.getElementById('parent');
const log = document.getElementById('log');
function name(el) { return el ? '#' + (el.id || el.tagName) : 'null'; }
function record(e) {
const line = document.createElement('div');
const tag = e.type === 'mouseover' ? 'mouseover' : 'mouseout ';
line.textContent = tag + ' [target: ' + name(e.target) + ']';
// the "over" pair member gets a marker so the pattern is easy to spot
if (e.type === 'mouseover' && e.target !== parent) line.style.color = '#5eead4';
log.prepend(line);
while (log.children.length > 40) log.lastChild.remove();
}
// both handlers on the parent; the child has none of its own
parent.addEventListener('mouseover', record);
parent.addEventListener('mouseout', record);
So a single move into the child produces two handler calls on the parent:
parent.onmouseout = function(event) { /* event.target: the parent element */};parent.onmouseover = function(event) { /* event.target: the child element (bubbled up) */};
If your handlers ignore event.target, it looks like the pointer left #parent and instantly came back. That’s the illusion.
It didn’t. The pointer is still inside #parent the whole time. It just moved deeper into a child, and the browser’s “one element at a time” rule turned that into an out-then-over pair.
This matters the moment your handler does something on leave. Say parent.onmouseout starts a fade-out animation. You don’t want that fade running every time the user moves onto a button or an icon inside the parent. Two ways out:
Check relatedTarget inside mouseout. If the pointer is still somewhere inside the element (relatedTarget is a descendant), ignore the event.
Switch to mouseenter/mouseleave, which don’t have this problem at all. That’s next.
Events mouseenter and mouseleave
mouseenter and mouseleave are the calmer cousins of mouseover/mouseout. They also fire on entering and leaving an element, but with two differences that remove all the awkwardness above:
Movement to and from descendants doesn’t count. Going deeper into a child fires nothing.
They do not bubble.
mouseover / mouseout
enter parent
→ mouseover parent
move into child
→ mouseout parent
→ mouseover child (bubbles)
mouseenter / mouseleave
enter parent
→ mouseenter parent
move into child
(nothing)
Same move, different events: over/out fire on every boundary; enter/leave only fire at the outer edge.
The behavior is refreshingly literal. When the pointer enters an element, mouseenter fires once. Wherever the pointer wanders after that inside the element or its descendants, no more mouseenter events. When the pointer finally leaves the element as a whole, mouseleave fires.
The next demo mirrors the earlier parent/child example, except the top element now uses mouseenter/mouseleave. The only events you’ll see are for entering and leaving the top element. Hopping onto the child and back produces nothing, because transitions between descendants are invisible to these events.
interactiveSame layout, but the top element uses mouseenter/mouseleave
Live example — Same layout, but the top element uses mouseenter/mouseleave
HTML
<div id="parent">
#parent
<div id="child">#child</div>
</div>
<pre id="log">Enter the parent, wander onto the child and back…</pre>
const parent = document.getElementById('parent');
const log = document.getElementById('log');
let count = 0;
function record(e) {
count++;
const line = document.createElement('div');
line.textContent = count + '. ' + e.type + ' on #' + e.currentTarget.id;
line.style.color = e.type === 'mouseenter' ? '#86efac' : '#fca5a5';
log.prepend(line);
while (log.children.length > 40) log.lastChild.remove();
}
// enter/leave ignore the child completely — no events when hopping onto it
parent.addEventListener('mouseenter', record);
parent.addEventListener('mouseleave', record);
Event delegation
The simplicity of mouseenter/mouseleave comes at a price: they don’t bubble, so you can’t delegate with them.
Picture a table with hundreds of cells, and you want to react as the pointer enters and leaves each <td>. The tempting move is one handler on <table> that watches every cell. That works for click events, which bubble. It fails for mouseenter/mouseleave.
So for delegation you’re back to mouseover/mouseout, which do bubble. Start with the naive version that highlights whatever element is under the pointer:
// highlight the element currently under the pointertable.onmouseover = function(event) { let target = event.target; target.style.background = 'pink';};table.onmouseout = function(event) { let target = event.target; target.style.background = '';};
That runs on <table> and works through bubbling. Try it, the element under the mouse lights up as you move:
interactiveNaive: one handler on the table highlights whatever is under the pointer
Live example — Naive: one handler on the table highlights whatever is under the pointer
HTML
<table id="grid">
<tbody>
<tr>
<td>A1</td><td>A2</td><td><b>A3</b></td>
</tr>
<tr>
<td>B1</td><td><i>B2</i></td><td>B3</td>
</tr>
</tbody>
</table>
<p id="note">Notice it also lights up the <b> and <i> inside cells.</p>
But it highlights every element, <td>, and anything nested inside a cell, and it reacts to movement within a cell too. We only care about one thing: entering a <td> and leaving it. Every other transition (inside a cell, or over the gaps between cells) is noise we want to filter out.
The plan:
Keep the currently highlighted <td> in a variable, call it currentElem.
On mouseover, ignore the event if we’re already inside the current <td>.
On mouseout, ignore it unless we actually left the current <td>.
Delegation state machine: a single currentElem tracks which cell is highlighted.
The full example handles every case: entering a cell, moving around inside it, moving between the descendants of a cell, and leaving. The two ideas holding it together:
It uses event delegation on the table to catch enter/leave for any <td>. That’s why it relies on mouseover/mouseout (which bubble) rather than mouseenter/mouseleave (which don’t).
It filters out the extra events, so an onEnter/onLeave action runs only when the pointer crosses the boundary of a <td> as a whole, never for movement among a cell’s children.
interactiveFiltered: only the <td> as a whole highlights
Live example — Filtered: only the <td> as a whole highlights
const table = document.getElementById('grid');
const cur = document.getElementById('cur');
let currentElem = null;
function onEnter(td) {
td.classList.add('hot');
cur.textContent = td.textContent.trim();
}
function onLeave(td) {
td.classList.remove('hot');
cur.textContent = 'none';
}
table.onmouseover = function(event) {
if (currentElem) return; // still inside the current <td>
const target = event.target.closest('td');
if (!target || !table.contains(target)) return;
currentElem = target;
onEnter(currentElem);
};
table.onmouseout = function(event) {
if (!currentElem) return;
// walk from relatedTarget upward: if we're still inside currentElem, ignore
let rel = event.relatedTarget;
while (rel) {
if (rel === currentElem) return;
rel = rel.parentNode;
}
onLeave(currentElem);
currentElem = null;
};
Move the cursor in and out of cells, and around inside them, fast or slow. Only the <td> as a whole highlights this time, unlike the twitchy first version.
Summary
This chapter covered five events: mouseover, mouseout, mousemove, mouseenter, and mouseleave.
Worth carrying with you:
A fast mouse move can skip intermediate elements. The browser samples position periodically rather than tracking every pixel, so don’t assume the pointer touched everything on its path.
mouseover/mouseout and mouseenter/mouseleave all carry relatedTarget, the element the pointer is coming from or going to, complementing target. It can be null when the pointer crosses the window boundary.
mouseover/mouseout fire even when moving between a parent and its own child, because the browser treats the pointer as being over a single element at a time, the deepest one. The mouseover that fires on the child bubbles, which can look like the pointer briefly left and returned.
mouseenter/mouseleave ignore movement to and from descendants and fire only for the element as a whole. They also don’t bubble, so use mouseover/mouseout when you need delegation.