shadimage
grootstudio
blockavatar-tooltipssource: ok · hit

Avatar Tooltips

Interactive overlapping avatars that reveal animated, spring-based tooltips on hover.

npx shadcn add "https://grootstudio.vercel.app/r/avatar-tooltips.json"
free

Interaction

scroll-revealtooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/14/2026, 1:10:46 PM

registry/radix-nova/avatar-tooltips/avatar-tooltips.tsx
"use client";

import React, { useState } from "react";
import { motion, useTransform, useMotionValue, useSpring, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";

export type AvatarItem = {
  readonly id: string | number;
  readonly name: string;
  readonly designation?: string;
  readonly icon: React.ElementType;
};

const AvatarTooltipItem = ({ item }: { item: AvatarItem }) => {
  const x = useMotionValue(0);
  const [isHovered, setIsHovered] = useState(false);
  const Icon = item.icon;

  const rotate = useSpring(useTransform(x, [-100, 100], [-45, 45]), {
    stiffness: 100,
    damping: 15,
  });

  const translateX = useSpring(useTransform(x, [-100, 100], [-50, 50]), {
    stiffness: 100,
    damping: 15,
  });

  return (
    <div
      className="group relative -mr-2"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onMouseMove={(e) => {
        const rect = e.currentTarget.getBoundingClientRect();
        x.set(e.clientX - rect.left - rect.width / 2);
      }}
    >
      <AnimatePresence mode="popLayout">
        {isHovered && (
          <motion.div
            initial={{ opacity: 0, y: 10, scale: 0.95 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 10, scale: 0.95 }}
            transition={{
              type: "spring",
              stiffness: 260,
              damping: 20,
            }}
            style={{
              left: "50%",
              translateX: "-50%",
              rotate: rotate,
              x: translateX,
            }}
            className="pointer-events-none absolute bottom-full mb-3 z-50 flex flex-col items-center rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-xl backdrop-blur-sm"
          >
            <div className="absolute bottom-[-4.73px] left-1/2 -translate-x-1/2 h-2 w-2 rotate-45 border-b border-r rounded-[1px] bg-popover" />
            <p className="relative z-10 whitespace-nowrap text-sm font-semibold leading-none">
              {item.name}
            </p>
            {item.designation && (
              <p className="relative z-10 mt-1 whitespace-nowrap text-[10px] text-muted-foreground leading-none">
                {item.designation}
              </p>
            )}
          </motion.div>
        )}
      </AnimatePresence>

      <div className="relative flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border border-border bg-background shadow-sm transition-transform duration-300 group-hover:z-30 group-hover:scale-110 active:scale-95">
        <Icon className="size-6 text-foreground" />
      </div>
    </div>
  );
};

export function AvatarTooltips({ items, className }: { items: readonly AvatarItem[], className?: string }) {
  return (
    <div className={cn("flex flex-col items-center gap-3 mt-4 relative", className)}>
      <div className="flex items-center justify-center -space-x-2">
        {items.map((item) => (
          <AvatarTooltipItem key={item.id} item={item} />
        ))}
      </div>
    </div>
  );
}

More from grootstudio

Browse all components in this library →