UIOverlays and Menusmotion/react

HoverCard

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

Notes by@ada

Customize

14 props
500
250
10
184
12800
300

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add hover-card

Writes 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.

example.tsx
1import { HoverCard } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <HoverCard />6  );7}

Props

PropTypeDefault
open

Controlled open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called whenever the card requests to open or close.

(open: boolean) => voidundefined
openDelay

Delay in ms before the card opens on hover.

number500
closeDelay

Delay in ms before the card closes after the pointer leaves trigger and card.

number250
side

Preferred side; flips when the viewport has no room.

"top" | "right" | "bottom" | "left""bottom"
align

Alignment along the trigger edge (clamped to the viewport).

"start" | "center" | "end""center"
sideOffset

Gap between trigger and card in pixels.

number10
name

Profile display name; avatar initials are derived from it.

string"Ada Lovelace"
handle

Profile handle, also the trigger link text.

string"@ada"
bio

Short profile bio.

string"Mathematician and writer. …"
following

Number of accounts followed (compact formatted).

number184
followers

Number of followers (compact formatted).

number12800
joinedLabel

Footer line with a calendar icon; empty hides it.

string"Joined December 1842"
href

Link target of the trigger; "#" prevents navigation.

string"#"
width

Card width in pixels.

number300
accentColor

Accent for the avatar gradient and trigger underline.

string"#6366f1"
showArrow

Show the pointing arrow.

booleanfalse
children

Custom card body replacing the profile content.

React.ReactNodeundefined
className

Class merged onto the root.

string""
style

Inline styles merged onto the root.

React.CSSPropertiesundefined

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

components/ui/hover-card.tsx
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}