/Navigation
Navigation • React
Hover Glide Cards
One highlight glides from card to card under the pointer, seen only through the cards themselves, so it drains out of one and fills the next.
Installation
Requires lucide-react for its icons. The shadcn CLI installs it for you; copying the code by hand means installing it yourself.
Terminal
npx shadcn@latest add @microkit/hover-glide-cardsCode
1"use client";23import { useEffect, useRef } from "react";4import { CalendarDays, FolderOpen, Mail, NotebookPen } from "lucide-react";56const apps = [7{ name: "Calendar", detail: "Plan around your week", icon: CalendarDays },8{ name: "Mail", detail: "Triage every inbox", icon: Mail },9{ name: "Notes", detail: "Draft from meetings", icon: NotebookPen },10{ name: "Files", detail: "Find anything shared", icon: FolderOpen },11];1213export function HoverGlideCards() {14const hostRef = useRef<HTMLDivElement>(null);15const layerRef = useRef<HTMLSpanElement>(null);16const glideRef = useRef<HTMLSpanElement>(null);1718useEffect(() => {19const host = hostRef.current;20const layer = layerRef.current;21const glide = glideRef.current;22if (!host || !layer || !glide) return;2324const tiles = Array.from(host.querySelectorAll("[data-glide-card]"));25const controller = new AbortController();26const { signal } = controller;27let active = -1;28let shown = false;2930// The highlight is only seen through the cards' shapes, so crossing a gap it drains out of one card and fills the next.31const drawMask = () => {32const bounds = host.getBoundingClientRect();33const shapes = tiles34.map((tile) => {35const rect = tile.getBoundingClientRect();36const x = rect.left - bounds.left + host.scrollLeft - host.clientLeft;37const y = rect.top - bounds.top + host.scrollTop - host.clientTop;38const radius = parseFloat(getComputedStyle(tile).borderTopLeftRadius) || 0;39return `<rect x="${x}" y="${y}" width="${rect.width}" height="${rect.height}" rx="${radius}"/>`;40})41.join("");42const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${host.scrollWidth}" height="${host.scrollHeight}">${shapes}</svg>`;43const image = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;44layer.style.width = `${host.scrollWidth}px`;45layer.style.height = `${host.scrollHeight}px`;46layer.style.setProperty("mask-image", image);47layer.style.setProperty("-webkit-mask-image", image);48};4950const place = () => {51const tile = tiles[active];52if (!tile) return;53const bounds = host.getBoundingClientRect();54const rect = tile.getBoundingClientRect();55const x = rect.left - bounds.left + host.scrollLeft - host.clientLeft;56const y = rect.top - bounds.top + host.scrollTop - host.clientTop;57// The first placement fades in where it lands; sliding in from wherever it last was would read as a glitch.58if (!shown) glide.style.transition = "opacity 180ms ease";59glide.style.transform = `translate(${x}px, ${y}px)`;60glide.style.width = `${rect.width}px`;61glide.style.height = `${rect.height}px`;62if (!shown) {63void glide.offsetWidth;64glide.style.transition = "";65shown = true;66}67glide.dataset.visible = "";68};6970const hide = () => {71shown = false;72delete glide.dataset.visible;73};7475for (const type of ["pointerover", "focusin"]) {76host.addEventListener(77type,78(event) => {79const tile = event.target instanceof Element ? event.target.closest("[data-glide-card]") : null;80const index = tile ? tiles.indexOf(tile) : -1;81if (index < 0) return;82if (!shown || index !== active) drawMask();83active = index;84place();85},86{ signal },87);88}89host.addEventListener("pointerleave", hide, { signal });90host.addEventListener(91"focusout",92(event) => {93const inside = event.relatedTarget instanceof Node && host.contains(event.relatedTarget);94if (!inside && !host.matches(":hover")) hide();95},96{ signal },97);9899// Cards reflow under a resting pointer when the grid resizes, so the cut-outs and the highlight are measured again.100const reflow = new ResizeObserver(() => {101if (!shown) return;102drawMask();103place();104});105reflow.observe(host);106107return () => {108controller.abort();109reflow.disconnect();110};111}, []);112113return (114<div className="relative grid w-full max-w-[312px] grid-cols-2 gap-[8px]" role="group" aria-label="Workspace apps" ref={hostRef}>115<span className="pointer-events-none absolute left-0 top-0 z-[1] block [mask-repeat:no-repeat] [-webkit-mask-repeat:no-repeat]" ref={layerRef} aria-hidden="true">116<span className="pointer-events-none absolute left-0 top-0 block h-0 w-0 rounded-[10px] bg-[light-dark(rgba(20,24,30,.055),rgba(255,255,255,.045))] opacity-0 [transition:transform_.3s_cubic-bezier(.32,.72,0,1),width_.3s_cubic-bezier(.32,.72,0,1),height_.3s_cubic-bezier(.32,.72,0,1),opacity_.18s_ease] data-[visible]:opacity-100 motion-reduce:[transition:opacity_.01ms] [@media(hover:none)]:hidden" ref={glideRef} />117</span>118{apps.map((app) => (119<button type="button" className="m-0 flex min-w-0 cursor-pointer flex-col items-start rounded-[10px] border border-[light-dark(#e3e6eb,#24272d)] bg-[light-dark(#ffffff,#121418)] p-[10px] text-left text-[color:light-dark(#14181e,#f2f4f6)] [font:inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff]" data-glide-card="" key={app.name}>120<span className="mb-[9px] grid size-[24px] place-items-center rounded-[7px] bg-[light-dark(#f0f2f5,#1b1e23)] text-[color:light-dark(#69727f,#868d97)]">121<app.icon size={14} strokeWidth={1.8} aria-hidden="true" />122</span>123<span className="max-w-full truncate text-[12px] font-semibold leading-[16px]">{app.name}</span>124<span className="mt-[2px] max-w-full truncate text-[11px] leading-[14px] text-[color:light-dark(#69727f,#868d97)]">{app.detail}</span>125</button>126))}127</div>128);129}