Hover the dot
Next.jsv13.4.8
Errors3
RouteStatic
gooey-tooltip-001.tsx
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { useId, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
const CARD_SPRING = { type: "spring", stiffness: 200, damping: 17, mass: 1 } as const;
export function GooeyTooltip({
icon,
label = "Show details",
open,
children,
className,
}: {
icon: ReactNode;
label?: string;
open?: boolean;
children: ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
const uid = useId().replace(/:/g, "");
const [hovered, setHovered] = useState(false);
const active = reduce ? true : (open ?? hovered);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={cn("relative inline-flex flex-col items-center", className)}
>
<svg aria-hidden className="absolute h-0 w-0">
<defs>
<filter id={"goo-" + uid}>
<feGaussianBlur in="SourceGraphic" stdDeviation="11" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -10"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* goo layer: card + cheese strand + dot in one filter. The strand
is the SAME colour as the card, so wherever they overlap it hides
inside the card — only the neck outside the card ever reads.
Closed, the card is fully collapsed (scale 0) so the idle trigger
is just the white dot. On hover the card rises and the strand
stretches after it on the SAME spring, tip buried in the card
(pizza cheese pull). Once the card lands, the neck thins and
snaps, recoiling into the dot while the card floats detached. */}
<div
aria-hidden
className="flex h-[188px] flex-col items-center justify-end"
style={reduce ? undefined : { filter: "url(#goo-" + uid + ")" }}
>
<motion.div
initial={false}
animate={active ? { y: 0, scale: 1 } : { y: 130, scale: 0 }}
transition={reduce ? { duration: 0 } : CARD_SPRING}
className="h-[118px] w-56 shrink-0 rounded-2xl bg-neutral-800"
/>
<div className="relative h-10 w-full shrink-0">
{!reduce && (
<div className="absolute bottom-1 left-1/2 h-28 w-14 -translate-x-1/2">
<motion.div
initial={false}
animate={
active
? { scaleY: 1, scaleX: [1, 1, 0.28, 0], opacity: [0, 1, 1, 0] }
: { scaleY: 0.05, scaleX: 0.5, opacity: 0 }
}
transition={
active
? {
// Stretch tracks the card spring exactly; the neck +
// snap only start once the card has landed.
scaleY: CARD_SPRING,
scaleX: { duration: 0.85, times: [0, 0.52, 0.74, 0.9], ease: "easeInOut" },
opacity: { duration: 0.85, times: [0, 0.12, 0.74, 0.9], ease: "easeInOut" },
}
: {
// Close: strand stays invisible while the card sinks.
scaleY: CARD_SPRING,
scaleX: { duration: 0.25, ease: "easeOut" },
opacity: { duration: 0.2, ease: "easeOut" },
}
}
style={{ transformOrigin: "50% 100%" }}
className="h-full w-full rounded-full bg-neutral-800"
/>
</div>
)}
</div>
<motion.div
initial={false}
animate={active ? { scale: [0.92, 1, 1.09, 0.95, 1] } : { scale: 0.92 }}
transition={
reduce
? { duration: 0 }
: active
? { duration: 0.85, times: [0, 0.45, 0.68, 0.84, 1], ease: "easeInOut" }
: { type: "spring", stiffness: 300, damping: 20 }
}
className="size-14 shrink-0 rounded-full bg-neutral-800"
/>
</div>
{/* crisp content layer riding the same motion, unfiltered */}
<div className="pointer-events-none absolute inset-0 flex h-[188px] flex-col items-center justify-end">
<motion.div
initial={false}
animate={
active
? { opacity: 1, y: 0, scale: 1 }
: { opacity: 0, y: 130, scale: 0 }
}
transition={reduce ? { duration: 0 } : CARD_SPRING}
style={{ transformOrigin: "50% 100%" }}
className="h-[118px] w-56 shrink-0 overflow-hidden rounded-2xl p-4"
>
{children}
</motion.div>
<div className="h-10 w-full shrink-0" />
<button
type="button"
aria-label={label}
aria-expanded={active}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
className="pointer-events-auto grid size-14 shrink-0 cursor-pointer place-items-center rounded-full outline-none [&_svg]:size-6 [&_svg]:text-white"
>
{icon}
</button>
</div>
</div>
);
}
"use client";
export function NextjsIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 256 256" aria-hidden className={className}>
<defs>
{/* All shapes track currentColor, so a white icon on the grey dot
renders as the white ringed disc + N silhouette. */}
<linearGradient id="ruvix-next-shade-a" x1="55.633%" x2="83.228%" y1="56.385%" y2="96.08%">
<stop offset="0%" stopColor="currentColor" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient id="ruvix-next-shade-b" x1="50%" x2="49.953%" y1="0%" y2="73.438%">
<stop offset="0%" stopColor="currentColor" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<circle id="ruvix-next-disc" cx="128" cy="128" r="128" />
</defs>
<mask id="ruvix-next-mask" fill="#fff">
<use href="#ruvix-next-disc" />
</mask>
<g mask="url(#ruvix-next-mask)">
<circle cx="128" cy="128" r="128" fill="currentColor" />
<path fill="url(#ruvix-next-shade-a)" d="M212.634 224.028L98.335 76.8H76.8v102.357h17.228V98.68L199.11 234.446a128 128 0 0 0 13.524-10.418" />
<path fill="url(#ruvix-next-shade-b)" d="M163.556 76.8h17.067v102.4h-17.067z" />
</g>
</svg>
);
}