A delayed preview card that springs out from a link on hover or keyboard focus, showing a user profile with avatar initials, name, bio, follower stats and join date. Open and close delays, side, alignment and a viewport collision flip are all configurable.
Preview
Installation
Copies the component source and its CSS Module into your project.
npx motionfoundry add hover-cardWrites components/ui/hover-card.tsx and components/styles/hover-card.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { HoverCard } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <HoverCard />6 );7}Props
Accessibility
The trigger is a real link that opens the card on hover (after openDelay) and immediately on keyboard focus; it references the card with aria-describedby while open so screen readers announce the preview. The card stays open while the pointer is over it, closes after closeDelay when the pointer leaves, on blur, and on Escape. The card is supplementary and holds no interactive content, so it never traps focus. It is portaled to document.body only after mount and copies the --mf-* theme variables from its trigger. Under prefers-reduced-motion it only fades.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/hover-card.module.css";7 8type Side = "top" | "right" | "bottom" | "left";9type Align = "start" | "center" | "end";10 11export interface HoverCardProps {12 /** Controlled open state */13 open?: boolean;14 /** Initial open state when uncontrolled */15 defaultOpen?: boolean;16 /** Called whenever the card requests to open or close */17 onOpenChange?: (open: boolean) => void;18 /** Delay in ms before the card opens on hover */19 openDelay?: number;20 /** Delay in ms before the card closes after the pointer leaves */21 closeDelay?: number;22 /** Preferred side (flips when there is no room) */23 side?: Side;24 /** Alignment along the trigger edge */25 align?: Align;26 /** Gap between trigger and card in pixels */27 sideOffset?: number;28 /** Profile display name (initials are derived from it) */29 name?: string;30 /** Profile handle, also used as the trigger text */31 handle?: string;32 /** Short profile bio */33 bio?: string;34 /** Number of accounts followed */35 following?: number;36 /** Number of followers */37 followers?: number;38 /** Footer line, e.g. join date */39 joinedLabel?: string;40 /** Link target of the trigger ("#" prevents navigation) */41 href?: string;42 /** Card width in pixels */43 width?: number;44 /** Accent color for the avatar and trigger underline */45 accentColor?: string;46 /** Show the pointing arrow */47 showArrow?: boolean;48 /** Custom card body; replaces the profile content */49 children?: React.ReactNode;50 /** Optional className merged onto the root */51 className?: string;52 /** Optional inline styles merged onto the root */53 style?: React.CSSProperties;54}55 56const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];57const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;58const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(v, max));59const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };60 61function readThemeVars(el: Element | null): Record<string, string> {62 const out: Record<string, string> = {};63 if (!el) return out;64 const cs = getComputedStyle(el);65 for (const t of THEME_TOKENS) {66 const v = cs.getPropertyValue(t).trim();67 if (v) out[t] = v;68 }69 return out;70}71 72function computePlacement(r: DOMRect, w: number, h: number, side: Side, align: Align, off: number) {73 const vw = window.innerWidth;74 const vh = window.innerHeight;75 const pad = 8;76 const room: Record<Side, number> = { top: r.top, bottom: vh - r.bottom, left: r.left, right: vw - r.right };77 const need = (side === "top" || side === "bottom" ? h : w) + off + pad;78 const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;79 const vertical = s === "top" || s === "bottom";80 let top: number;81 let left: number;82 if (vertical) {83 top = s === "top" ? r.top - off - h : r.bottom + off;84 left = align === "start" ? r.left : align === "end" ? r.right - w : r.left + r.width / 2 - w / 2;85 left = clamp(left, pad, vw - w - pad);86 } else {87 left = s === "left" ? r.left - off - w : r.right + off;88 top = align === "start" ? r.top : align === "end" ? r.bottom - h : r.top + r.height / 2 - h / 2;89 top = clamp(top, pad, vh - h - pad);90 }91 const arrow = vertical ? clamp(r.left + r.width / 2 - left, 14, w - 14) : clamp(r.top + r.height / 2 - top, 14, h - 14);92 return { top, left, side: s, arrow };93}94 95const compact = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e4 ? `${+(n / 1e3).toFixed(1)}k` : n.toLocaleString("en-US"));96 97export function HoverCard({98 open: openProp,99 defaultOpen = false,100 onOpenChange,101 openDelay = 500,102 closeDelay = 250,103 side = "bottom",104 align = "center",105 sideOffset = 10,106 name = "Ada Lovelace",107 handle = "@ada",108 bio = "Mathematician and writer. Publishing notes on the Analytical Engine and the first published algorithm.",109 following = 184,110 followers = 12800,111 joinedLabel = "Joined December 1842",112 href = "#",113 width = 300,114 accentColor = "#6366f1",115 showArrow = false,116 children,117 className = "",118 style,119}: HoverCardProps) {120 const reduce = useReducedMotion();121 const id = React.useId();122 const [inner, setInner] = React.useState(defaultOpen);123 const open = openProp !== undefined ? openProp : inner;124 const [mounted, setMounted] = React.useState(false);125 const [pos, setPos] = React.useState<ReturnType<typeof computePlacement> | null>(null);126 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});127 const triggerRef = React.useRef<HTMLAnchorElement>(null);128 const cardRef = React.useRef<HTMLDivElement>(null);129 const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);130 131 React.useEffect(() => {132 setMounted(true);133 return () => clearTimeout(timer.current);134 }, []);135 136 const setOpen = React.useCallback(137 (v: boolean) => {138 if (openProp === undefined) setInner(v);139 onOpenChange?.(v);140 },141 [openProp, onOpenChange],142 );143 144 const schedule = (v: boolean, delay: number) => {145 clearTimeout(timer.current);146 if (v === open) return;147 if (delay <= 0) setOpen(v);148 else timer.current = setTimeout(() => setOpen(v), delay);149 };150 151 const place = React.useCallback(() => {152 const t = triggerRef.current;153 const c = cardRef.current;154 if (t && c) setPos(computePlacement(t.getBoundingClientRect(), c.offsetWidth, c.offsetHeight, side, align, sideOffset));155 }, [side, align, sideOffset]);156 157 useIsoLayoutEffect(() => {158 if (!open) return;159 setThemeVars(readThemeVars(triggerRef.current));160 place();161 const onKey = (e: KeyboardEvent) => {162 if (e.key === "Escape") {163 clearTimeout(timer.current);164 setOpen(false);165 }166 };167 const ro = typeof ResizeObserver !== "undefined" && cardRef.current ? new ResizeObserver(place) : null;168 if (ro && cardRef.current) ro.observe(cardRef.current);169 document.addEventListener("keydown", onKey);170 window.addEventListener("resize", place);171 window.addEventListener("scroll", place, true);172 return () => {173 ro?.disconnect();174 document.removeEventListener("keydown", onKey);175 window.removeEventListener("resize", place);176 window.removeEventListener("scroll", place, true);177 };178 }, [open, place, setOpen]);179 180 const s = pos?.side ?? side;181 const d = reduce ? 0 : 6;182 const from = s === "top" ? { y: d } : s === "bottom" ? { y: -d } : s === "left" ? { x: d } : { x: -d };183 const initials = name184 .split(/\s+/)185 .filter(Boolean)186 .slice(0, 2)187 .map((p) => p[0]?.toUpperCase())188 .join("");189 190 return (191 <div className={`${styles.root} ${className}`.trim()} style={{ "--mf-hc-accent": accentColor, ...style } as React.CSSProperties}>192 <span className={styles.lead}>Notes by</span>193 <a194 ref={triggerRef}195 href={href}196 className={styles.trigger}197 aria-describedby={open ? id : undefined}198 data-state={open ? "open" : "closed"}199 onClick={(e) => href === "#" && e.preventDefault()}200 onPointerEnter={(e) => e.pointerType !== "touch" && schedule(true, openDelay)}201 onPointerLeave={() => schedule(false, closeDelay)}202 onFocus={() => schedule(true, 0)}203 onBlur={() => schedule(false, 0)}204 >205 {handle}206 </a>207 {mounted &&208 createPortal(209 <AnimatePresence>210 {open && (211 <motion.div212 ref={cardRef}213 id={id}214 data-side={s}215 className={styles.card}216 style={{ ...themeVars, top: pos?.top ?? 0, left: pos?.left ?? 0, visibility: pos ? "visible" : "hidden", "--mf-hc-accent": accentColor, "--mf-hc-w": `${width}px`, "--mf-hc-arrow": `${pos?.arrow ?? 0}px` } as React.CSSProperties}217 initial={{ opacity: 0, scale: reduce ? 1 : 0.96, ...from }}218 animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}219 exit={{ opacity: 0, scale: reduce ? 1 : 0.97, transition: { duration: 0.12 } }}220 transition={reduce ? { duration: 0.1 } : { type: "spring", stiffness: 420, damping: 32, mass: 0.8 }}221 onPointerEnter={() => clearTimeout(timer.current)}222 onPointerLeave={() => schedule(false, closeDelay)}223 >224 {children ?? (225 <div className={styles.profile}>226 <span aria-hidden="true" className={styles.avatar}>227 {initials}228 </span>229 <div className={styles.info}>230 <div className={styles.name}>{name}</div>231 <div className={styles.handle}>{handle}</div>232 <p className={styles.bio}>{bio}</p>233 <div className={styles.stats}>234 <span>235 <strong>{compact(following)}</strong> Following236 </span>237 <span>238 <strong>{compact(followers)}</strong> Followers239 </span>240 </div>241 {joinedLabel && (242 <div className={styles.joined}>243 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">244 <rect x="3" y="5" width="18" height="16" rx="2" />245 <path d="M16 3v4M8 3v4M3 11h18" />246 </svg>247 {joinedLabel}248 </div>249 )}250 </div>251 </div>252 )}253 {showArrow && <span aria-hidden="true" className={styles.arrow} />}254 </motion.div>255 )}256 </AnimatePresence>,257 document.body,258 )}259 </div>260 );261}