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.