All components

Direction-Aware Hover Effect (JavaScript)

JavaScript animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A grid of labeled cells where a solid highlight block glides beneath the hovered cell, snapping to its exact bounds and swapping to a per-cell accent color. Driven by mousemove + elementFromPoint hit-testing and pure CSS transitions on transform/width/height/background (no GSAP).

How it's built

Categoryhover
Techvanilla JS
Complexitywidget
Performance costlight
Mobile-safedesktop-first

hover grid highlight cursor-follow css-transition vanilla-js interactive minimal direction-aware

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Direction-Aware Grid Hover Highlight

Goal

Build a full-viewport, dark "tech-menu" page with a two-row grid of labeled cells (skill/technology tags like "( html )", "( gsap )"…). The star effect: a single solid highlight block glides beneath whichever cell the cursor is over, smoothly tweening its position, its width/height, and its background color to snap exactly onto the hovered cell's bounds. Because the block always animates from the previously hovered cell toward the new one, it appears to slide in from whatever direction the cursor entered — a "direction-aware" hover. Each cell owns its own accent color, so the block also cross-fades through a palette as you sweep across the grid.

Tech

Vanilla HTML/CSS/JS, shipped as index.html + styles.css + an ES-module script.js (<script type="module" src="./script.js">). No GSAP, no npm dependencies, no smooth scroll. The entire animation is a CSS transition on the highlight element; JS only writes inline transform / width / height / background-color values on mousemove, using document.elementFromPoint hit-testing. Must run in a fresh Vite project with zero installs.

Layout / HTML

<nav>
  <p>Motionprompts</p>
  <p>/ Experiment 448</p>
</nav>

<div class="container">
  <div class="grid">
    <div class="grid-row">
      <div class="grid-item"><p>( html )</p></div>
      <div class="grid-item"><p>( css )</p></div>
      <div class="grid-item"><p>( javascript )</p></div>
    </div>
    <div class="grid-row">
      <div class="grid-item"><p>( gsap )</p></div>
      <div class="grid-item"><p>( scrolltrigger )</p></div>
      <div class="grid-item"><p>( react )</p></div>
      <div class="grid-item"><p>( next.js )</p></div>
      <div class="grid-item"><p>( three.js )</p></div>
    </div>
  </div>
  <div class="highlight"></div>
</div>

<footer>
  <p>Unlock Source Code with PRO</p>
  <p>Link in description</p>
</footer>

Key points:

  • Exactly 8 .grid-item cells: 3 in the first row, 5 in the second. Each contains one <p> label wrapped in parentheses with inner spaces, e.g. ( html ).
  • .highlight is a sibling of .grid, placed directly inside .container (it is positioned against the container, not the grid).
  • nav and footer are fixed chrome bars; the second nav <p> and both footer <p>s are dimmed.

Styling

  • Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }.
  • body: font-family: "Akkurat Mono", sans-serif; (a monospace grotesk — any mono with a sans-serif fallback reads right), background-color: #1a1a1a.
  • All p: text-transform: uppercase; color: #fff; font-size: 13px; font-weight: 500; (labels render as "( HTML )" etc.).
  • nav, footer: position: fixed; width: 100vw; padding: 1em; display: flex; justify-content: space-between; align-items: center; background-color: #1a1a1a; z-index: 10;. Nav pinned top: 0 with border-bottom: 1px solid rgba(255,255,255,0.2); footer pinned bottom: 0 with the same border-top. nav p:not(:first-child) and footer p get opacity: 0.3.
  • .container: position: relative; width: 100%; height: 100svh; display: flex; align-items: center; justify-content: center; — full-viewport stage that centers the grid.
  • .grid: position: relative; margin: 0 auto; width: 90%; height: 60%; display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.2);.
  • .grid-row, .grid-item: flex: 1; display: flex; justify-content: center; align-items: center; height: 100%; — both rows split the grid height 50/50; cells split each row evenly (3 wide cells on top, 5 narrower below).
  • Hairline dividers, all 1px solid rgba(255,255,255,0.2): .grid-row:nth-child(1) { border-bottom: … } and .grid-item:not(:last-child) { border-right: … }.
  • .grid-item p: position: relative; z-index: 2;essential: the label paints above the highlight block, so text stays readable on top of the colored fill.
  • .highlight:

``css position: absolute; top: 0; left: 0; background: white; pointer-events: none; /* essential: must never be hit by elementFromPoint */ transition: transform 0.25s ease, width 0.25s ease, height 0.25s ease, background-color 0.25s ease; opacity: 1; ` It anchors at the container's top-left; JS drives it purely via inline transform: translate(x, y) + width + height + background-color`.

The effect (exhaustive — this replaces the GSAP section)

There is no GSAP and no JS tweening loop. The motion engine is the CSS transition: transform 0.25s ease, width 0.25s ease, height 0.25s ease, background-color 0.25s ease on .highlight; the JS just rewrites the target inline values and the browser tweens old→new over 0.25s with the default ease curve. Reproduce the logic exactly:

Everything runs inside a DOMContentLoaded listener.

1. Per-cell accent colors. Define this 8-color palette, in this order:

const highlightColors = [
  "#E24E1B", // burnt orange
  "#4381C1", // steel blue
  "#F79824", // amber
  "#04A777", // emerald
  "#5B8C5A", // sage green
  "#2176FF", // vivid blue
  "#818D92", // slate grey
  "#22AAA1", // teal
];

Loop all .grid-items and stamp item.dataset.color = highlightColors[index % highlightColors.length] — with 8 cells, each gets a unique color in DOM order (row 1 left→right, then row 2 left→right).

2. moveToElement(element) — snap the highlight to a cell. If element exists:

  • rect = element.getBoundingClientRect(), containerRect = container.getBoundingClientRect().
  • Set inline styles on the highlight:
  • transform = translate(${rect.left - containerRect.left}px, ${rect.top - containerRect.top}px) — the cell's position relative to the container.
  • width = rect.width + "px", height = rect.height + "px" — the cell's exact size (cells in the 3-column row are wider than in the 5-column row, so the block visibly stretches/squeezes when crossing rows).
  • backgroundColor = element.dataset.color.

The CSS transition interpolates all four properties simultaneously, so the block slides, resizes, and recolors in one 0.25s glide from wherever it was to the new cell. This is what makes it feel direction-aware: enter a cell from the left and the block slides in from the left cell; drop down a row and it slides down while widening/narrowing.

3. moveHighlight(e) — mousemove hit-testing. Attached as a mousemove listener on .container (one listener, not per-cell):

  • hoveredElement = document.elementFromPoint(e.clientX, e.clientY).
  • If hoveredElement has class grid-itemmoveToElement(hoveredElement).
  • Else if hoveredElement.parentElement has class grid-item (the cursor is over the <p> label) → moveToElement(hoveredElement.parentElement).
  • Otherwise do nothing — over borders, gaps, or outside the grid the highlight simply stays parked on the last cell (it never hides or fades).

This is why pointer-events: none on .highlight is mandatory: without it, elementFromPoint would return the highlight itself and the effect would freeze.

4. Initial state. Immediately call moveToElement(firstGridItem) on load so the highlight starts parked on the first cell ("( html )") with its color #E24E1B. (Because the element starts at top:0; left:0 of the container with no size, the very first paint tweens it into place — acceptable and matches the original.)

No ScrollTrigger, no timelines, no stagger, no rAF loop — trigger is mousemove only, plus the one on-load placement.

Assets / images

None. The page is pure typography, hairline borders, and flat color fills.

Behavior notes

  • Desktop-only effect. At max-width: 900px: .highlight { display: none; } (the effect is disabled), .container becomes height: 100%; min-height: 100svh; padding: 25vh 0;, .grid becomes height: max-content, each .grid-row switches to flex-direction: column, cells become full-width with padding: 60px 0, and the cell dividers swap from border-right to border-bottom (:not(:last-child)). Also footer p:nth-child(2) { text-align: right; }.
  • The highlight always fully covers exactly one cell — its geometry is measured live via getBoundingClientRect, so it stays correct at any viewport size (above the breakpoint).
  • Motion only happens on mouse movement; there are no loops, timers, or scroll behavior.
  • Dark UI throughout: near-black #1a1a1a ground, white 13px uppercase mono labels, 20%-white hairlines, and the 8 saturated accent fills listed above.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--bg, --ink, --muted, --faint, --line, --lift, --ochre. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a standalone document: one script that runs once, wires a single mousemove listener onto .container, and never has to undo itself — the tab reloads long before a second listener could ever stack on top of the first. React withdraws that guarantee, and here the failure is quiet by design rather than loud: because moveToElement always writes the same four inline values (transform, width, height, backgroundColor) for a given cursor position, a duplicated listener does not visibly break the highlight — it just runs moveToElement twice per mousemove event, calling getBoundingClientRect twice and setting the same four styles twice, silently, one more subscriber added every time this component mounts. Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen, but the JSX (<div class="container"> with .grid and .highlight inside it) is not torn down between those two runs — only the effect body re-executes — so both mounts wire their handler onto the exact same .container node. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script subscribes straight to document.addEventListener("DOMContentLoaded", …) with no document.readyState guard in front of it. By the time a React component mounts, that event has already fired, so wiring it up this way would simply never run — no error, no highlight block, nothing to debug. Drop the wrapper entirely and move its body — the container/highlight/gridItems/firstItem lookups, the loop that stamps item.dataset.color, moveToElement, moveHighlight, the initial moveToElement(firstItem) call, and the container.addEventListener("mousemove", moveHighlight) call — into a useEffect with an empty dependency array. The highlightColors array itself touches no DOM and can stay outside the component as a module-level constant, read by index exactly as it is now.

*(2) Element lookups* — .container, .highlight, and every .grid-item are looked up by class off document. Give the outer wrapper a root ref, and inside the effect resolve container and highlight from dedicated refs placed on those two elements in JSX, then call container.querySelectorAll(".grid-item") and take its first entry for firstItem rather than a second unscoped document.querySelector(".grid-item"). During the StrictMode remount two copies of this subtree briefly coexist, and an unscoped lookup is not guaranteed to resolve to the copy whose effect is currently running rather than the one on its way out.

*(3) Cleanup* — There is no GSAP, no smooth-scroll library, and no self-driven requestAnimationFrame loop in this component: the motion is entirely the CSS transition on .highlight reacting to the inline styles moveToElement writes. The only object the effect creates that outlives a single call is the mousemove listener itself, and removing it is the one thing the cleanup has to do:

useEffect(() => {
  const container = containerRef.current;
  const highlight = highlightRef.current;
  const gridItems = container.querySelectorAll(".grid-item");
  // ...highlightColors stamping onto gridItems, moveToElement, moveHighlight,
  // exactly as described above, closing over `container` and `highlight`...

  moveToElement(gridItems[0]);
  container.addEventListener("mousemove", moveHighlight);

  return () => container.removeEventListener("mousemove", moveHighlight);
}, []);

Skipping this turns the test from the intro into a concrete, reproducible bug: mount the component, navigate away, come back, and .container now answers every mousemove with two calls to moveToElement instead of one, each recomputing the same getBoundingClientRect and writing the same four inline styles the other just wrote. Nothing looks wrong on screen, because the two calls agree on every value — the leak only shows up as one more subscriber on the highest-frequency event on the page, compounding with every remount this route survives.