Skip to content
jjswnth/ui

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

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
membersMember[]Each entry needs a `name` and an `image` url.
wordmarkstring"jswnth-ui"Shown whenever nothing is hovered. Rises in from above.
accentstring"#ff3b30"Colour of the hovered name and the cursor follower.
cursorSizenumber96Diameter of the cursor follower, in pixels.
cursorOffsetnumber34How far the follower trails behind the cursor, in pixels.
proximitynumber90How close the cursor must get to the row before the follower appears.
textClassNamestringOverride the wordmark typography without touching the animation.