Motion
Members Hover
A row of members that grows on hover, swaps a giant wordmark for the member's name letter by letter, and trails an arrow that points back at the tile.
jswnth-ui
import { HoverMembers, type Member } from "@/components/ui/hover-members";
const members: Member[] = [
{ name: "Aria", image: "https://picsum.photos/seed/aria-m/300/380" },
{ name: "Kel", image: "https://picsum.photos/seed/kel-d/300/380" },
{ name: "Ravi", image: "https://picsum.photos/seed/ravi-s/300/380" },
{ name: "Juno", image: "https://picsum.photos/seed/juno-p/300/380" },
{ name: "Mira", image: "https://picsum.photos/seed/mira-k/300/380" },
{ name: "Theo", image: "https://picsum.photos/seed/theo-l/300/380" },
{ name: "Sana", image: "https://picsum.photos/seed/sana-r/300/380" },
{ name: "Owen", image: "https://picsum.photos/seed/owen-b/300/380" },
{ name: "Nia", image: "https://picsum.photos/seed/nia-t/300/380" },
];
export default function HoverMembersDemo() {
return <HoverMembers members={members} wordmark="jswnth-ui" />;
}Installation
1. Install the dependencies.
terminal npm install motion clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/hover-members.tsx "use client"; import { AnimatePresence, motion, useMotionValue, useSpring } from "motion/react"; import { useCallback, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type Member = { name: string; image: string; alt?: string; }; type HoverMembersProps = { members: Member[]; /** Shown whenever nothing is hovered. Rises in from above. */ wordmark?: string; /** Colour of a hovered member's name. */ accent?: string; /** Diameter of the cursor follower, in pixels. */ cursorSize?: number; /** How far the follower trails behind the cursor, in pixels. */ cursorOffset?: number; /** How close the cursor must get to the row before the follower appears. */ proximity?: number; className?: string; /** Override the wordmark typography without touching the animation. */ textClassName?: string; }; const REST_SIZE = { width: 48, height: 56 }; const HOVER_SIZE = { width: 136, height: 164 }; const sizeSpring = { type: "spring" as const, stiffness: 320, damping: 30, mass: 0.7 }; export function HoverMembers({ members, wordmark = "jswnth-ui", accent = "#ff3b30", cursorSize = 96, cursorOffset = 34, proximity = 90, className, textClassName, }: HoverMembersProps) { const stageRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement>(null); const tileRefs = useRef<(HTMLElement | null)[]>([]); const [hovered, setHovered] = useState<number | null>(null); const [near, setNear] = useState(false); // Raw pointer position, then a spring so the follower lags the cursor. const rawX = useMotionValue(0); const rawY = useMotionValue(0); const x = useSpring(rawX, { stiffness: 420, damping: 34, mass: 0.6 }); const y = useSpring(rawY, { stiffness: 420, damping: 34, mass: 0.6 }); const rawAngle = useMotionValue(-45); const angle = useSpring(rawAngle, { stiffness: 260, damping: 26 }); const handleMove = useCallback( (event: React.MouseEvent<HTMLDivElement>) => { const stage = stageRef.current?.getBoundingClientRect(); const row = rowRef.current?.getBoundingClientRect(); if (!stage || !row) return; const localX = event.clientX - stage.left; const localY = event.clientY - stage.top; rawX.set(localX + cursorOffset - cursorSize / 2); rawY.set(localY + cursorOffset - cursorSize / 2); // Shortest distance from the cursor to the row's box; zero when inside it. const dx = Math.max(row.left - event.clientX, 0, event.clientX - row.right); const dy = Math.max(row.top - event.clientY, 0, event.clientY - row.bottom); setNear(Math.hypot(dx, dy) <= proximity); if (hovered !== null) { const tile = tileRefs.current[hovered]?.getBoundingClientRect(); if (tile) { const targetX = tile.left + tile.width / 2 - (event.clientX + cursorOffset); const targetY = tile.top + tile.height / 2 - (event.clientY + cursorOffset); rawAngle.set((Math.atan2(targetY, targetX) * 180) / Math.PI); } } else { rawAngle.set(-45); } }, [cursorOffset, cursorSize, hovered, proximity, rawAngle, rawX, rawY], ); const active = hovered === null ? null : members[hovered]; const label = active?.name ?? wordmark; return ( <div ref={stageRef} onMouseMove={handleMove} onMouseLeave={() => { setNear(false); setHovered(null); }} className={cn("relative w-full overflow-hidden select-none", className)} > {/* Members ------------------------------------------------------------ */} <div ref={rowRef} className="flex items-center justify-center gap-2" style={{ height: HOVER_SIZE.height }} > {members.map((member, index) => { const isHovered = hovered === index; return ( <motion.button key={member.name} type="button" ref={(node) => { tileRefs.current[index] = node; }} onMouseEnter={() => setHovered(index)} onFocus={() => { setHovered(index); setNear(true); }} onBlur={() => setHovered(null)} aria-label={member.name} animate={isHovered ? HOVER_SIZE : REST_SIZE} transition={sizeSpring} className="relative shrink-0 overflow-hidden rounded-[18%] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current" > <img src={member.image} alt={member.alt ?? member.name} loading="lazy" draggable={false} className="size-full object-cover" /> </motion.button> ); })} </div> {/* Wordmark ----------------------------------------------------------- */} <div className="mt-6 flex justify-center px-4"> <AnimatePresence mode="wait" initial={false}> <SplitText key={label} text={label} from={active ? "below" : "above"} className={cn( "font-[family-name:var(--font-display,var(--font-sans))] text-[clamp(2.5rem,11vw,7rem)] leading-[0.9] tracking-[-0.01em] uppercase", textClassName, )} style={active ? { color: accent } : undefined} /> </AnimatePresence> </div> {/* Cursor follower ---------------------------------------------------- */} <motion.div aria-hidden style={{ x, y, width: cursorSize, height: cursorSize }} animate={{ opacity: near ? 1 : 0, scale: near ? 1 : 0.4 }} transition={{ duration: 0.22, ease: "easeOut" }} className="pointer-events-none absolute top-0 left-0 z-10" > <div className="grid size-full place-items-center rounded-full" style={{ backgroundColor: accent }} > <motion.svg style={{ rotate: angle }} width={cursorSize * 0.32} height={cursorSize * 0.32} viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" > <path d="M4 12h15" /> <path d="m13 6 6 6-6 6" /> </motion.svg> </div> </motion.div> </div> ); } /** * Letters rise into place on alternating speeds — odd letters snap, even * letters take their time — and leave together, quickly. */ function SplitText({ text, from, className, style, }: { text: string; from: "above" | "below"; className?: string; style?: React.CSSProperties; }) { const offscreen = from === "below" ? "115%" : "-115%"; return ( <motion.span className={cn("flex whitespace-pre", className)} style={style} aria-label={text} > {[...text].map((character, index) => ( <span key={`${character}-${index}`} className="inline-block overflow-hidden py-[0.08em]"> <motion.span className="inline-block" initial={{ y: offscreen }} animate={{ y: "0%", transition: { duration: index % 2 === 0 ? 0.34 : 0.62, delay: index * 0.02, ease: [0.22, 1, 0.36, 1], }, }} exit={{ y: offscreen, transition: { duration: 0.18, ease: "easeIn" }, }} > {character === " " ? " " : character} </motion.span> </span> ))} </motion.span> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| members | Member[] | — | Each entry needs a `name` and an `image` url. |
| wordmark | string | "jswnth-ui" | Shown whenever nothing is hovered. Rises in from above. |
| accent | string | "#ff3b30" | Colour of the hovered name and the cursor follower. |
| cursorSize | number | 96 | Diameter of the cursor follower, in pixels. |
| cursorOffset | number | 34 | How far the follower trails behind the cursor, in pixels. |
| proximity | number | 90 | How close the cursor must get to the row before the follower appears. |
| textClassName | string | — | Override the wordmark typography without touching the animation. |