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-itemcells: 3 in the first row, 5 in the second. Each contains one<p>label wrapped in parentheses with inner spaces, e.g.( html ). .highlightis a sibling of.grid, placed directly inside.container(it is positioned against the container, not the grid).navandfooterare 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 pinnedtop: 0withborder-bottom: 1px solid rgba(255,255,255,0.2); footer pinnedbottom: 0with the sameborder-top.nav p:not(:first-child)andfooter pgetopacity: 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
hoveredElementhas classgrid-item→moveToElement(hoveredElement). - Else if
hoveredElement.parentElementhas classgrid-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),.containerbecomesheight: 100%; min-height: 100svh; padding: 25vh 0;,.gridbecomesheight: max-content, each.grid-rowswitches toflex-direction: column, cells become full-width withpadding: 60px 0, and the cell dividers swap fromborder-righttoborder-bottom(:not(:last-child)). Alsofooter 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
#1a1a1aground, white 13px uppercase mono labels, 20%-white hairlines, and the 8 saturated accent fills listed above.