Direction aware hover
Each tile's overlay slides in from the edge the pointer entered through and leaves toward the edge it exits.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useRef } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const CITIES = [
{ name: "Lisbon", stays: 42, art: styles.a },
{ name: "Porto", stays: 31, art: styles.b },
{ name: "Sintra", stays: 12, art: styles.c },
{ name: "Faro", stays: 18, art: styles.d },
{ name: "Lagos", stays: 24, art: styles.e },
{ name: "Evora", stays: 9, art: styles.f },
];
const OFFSETS = { top: "0, -101%", right: "101%, 0", bottom: "0, 101%", left: "-101%, 0" };
type Edge = keyof typeof OFFSETS;
// The edge nearest the point, measured against the tile's own aspect ratio.
function edgeOf(tile: HTMLElement, clientX: number, clientY: number): Edge {
const rect = tile.getBoundingClientRect();
const dx = (clientX - rect.left - rect.width / 2) / rect.width;
const dy = (clientY - rect.top - rect.height / 2) / rect.height;
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "right" : "left";
return dy > 0 ? "bottom" : "top";
}
export default function Demo() {
const open = useRef<HTMLElement | null>(null);
const reduced = usePrefersReducedMotion();
const slideIn = (tile: HTMLElement, edge: Edge) => {
const overlay = tile.querySelector<HTMLElement>("[data-overlay]")!;
// Jump to the entry edge without a transition, then animate to rest.
overlay.style.transition = "none";
overlay.style.transform = `translate(${OFFSETS[edge]})`;
overlay.getBoundingClientRect();
overlay.style.transition = "";
overlay.style.transform = "translate(0, 0)";
tile.classList.add(styles.open);
open.current = tile;
};
const slideOut = (tile: HTMLElement, edge: Edge) => {
tile.querySelector<HTMLElement>("[data-overlay]")!.style.transform = `translate(${OFFSETS[edge]})`;
tile.classList.remove(styles.open);
if (open.current === tile) open.current = null;
};
const enter = (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType !== "touch") slideIn(event.currentTarget, edgeOf(event.currentTarget, event.clientX, event.clientY));
};
const leave = (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType !== "touch") slideOut(event.currentTarget, edgeOf(event.currentTarget, event.clientX, event.clientY));
};
// Touch has no enter or leave: a tap toggles the tile from the edge nearest the finger.
const tap = (event: React.PointerEvent<HTMLElement>) => {
if (event.pointerType !== "touch") return;
const tile = event.currentTarget;
const edge = edgeOf(tile, event.clientX, event.clientY);
if (open.current && open.current !== tile) slideOut(open.current, edge);
if (tile.classList.contains(styles.open)) slideOut(tile, edge);
else slideIn(tile, edge);
};
return (
<div className={`${styles.root} ${reduced ? styles.still : ""}`}>
<div className={styles.grid}>
{CITIES.map((city) => (
<a key={city.name} href="#" className={`${styles.tile} ${city.art}`} onPointerEnter={enter} onPointerLeave={leave} onPointerUp={tap} onClick={(e) => e.preventDefault()}>
<span className={styles.name}>{city.name}</span>
<span data-overlay className={styles.overlay}>
<strong>{city.name}</strong>
<span>{city.stays} stays</span>
</span>
</a>
))}
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; container-type: size; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 10px; width: min(100%, 680px); height: min(100%, 400px); }
.tile { position: relative; display: flex; align-items: flex-end; padding: 10px 12px; border-radius: 12px; overflow: hidden; text-decoration: none; touch-action: manipulation; }
.name { position: relative; padding: 2px 8px; border-radius: 999px; background: var(--white); font-size: 13px; font-weight: 650; transition: opacity .2s; }
.open .name { opacity: 0; }
.a { background: linear-gradient(var(--sky) 55%, var(--accent) 55%); }
.b { background: linear-gradient(160deg, var(--sand) 50%, var(--accent-soft) 50%); }
.c { background: radial-gradient(circle at 70% 30%, var(--white) 0 14%, transparent 15%), linear-gradient(var(--accent-soft), var(--accent)); }
.d { background: linear-gradient(var(--accent-pale) 62%, var(--sand) 62%); }
.e { background: radial-gradient(circle at 30% 40%, var(--sand) 0 18%, transparent 19%), linear-gradient(var(--sky) 60%, var(--accent-text) 60%); }
.f { background: linear-gradient(20deg, var(--accent-text) 30%, var(--sand) 30%); }
.overlay {
position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 2px;
background: color-mix(in srgb, var(--ink) 82%, transparent); color: var(--white); font-size: 13px;
transform: translate(0, 101%); transition: transform .45s var(--out);
}
.overlay strong { font-size: 16px; letter-spacing: -0.01em; }
.overlay span { color: var(--accent-soft); font-weight: 600; }
/* Reduced motion: the overlay fades in place instead of sliding. */
.still .overlay { transform: none !important; opacity: 0; transition: opacity .2s; }
.still .open .overlay { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .overlay { transition-duration: .2s; } }
Use it for
- Image grids and portfolios where the overlay holds a title or count
- Destination or category tiles on a landing page
Avoid
- Tiles whose overlay holds the only way to reach an action
- Very small tiles, where the slide is too short to read as directional
How it works
On pointerenter and pointerleave the point is compared with the tile centre, divided by the tile width and height so the diagonals match the corners, and the larger axis picks the edge. On enter the overlay jumps to that edge with the transition turned off, a forced layout commits it, and then the transition is restored and the overlay moves to rest. On leave it simply transitions to the exit edge.
Accessibility
Tile names stay visible without hover. Touch users tap a tile to slide its overlay in from the nearest edge, and a second tap or another tile closes it. Reduced motion fades the overlay in place instead of sliding.