文字提示 / Tooltip
New悬停或聚焦触发的文字提示,进出场带模糊过渡与弹簧生成动画;另有 Morph 变体,一块共享气泡在相邻触发器之间滑移变形。
标准文字提示
tooltip.tsx单触发器文字提示:进出场模糊过渡、弹簧生成,支持四个方向定位。
Hover or focus each button. Content fades and un-blurs in.
"use client";
import { Heart, Settings, Share, Trash2 } from "lucide-react";
import { Tooltip } from "@/components/motion/tooltip";
export function TooltipPreview() {
return (
<div className="flex flex-col items-center gap-12">
<div className="flex flex-wrap items-center justify-center gap-4">
<Tooltip content="Like this post" side="top">
<button type="button" className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-foreground press">
<Heart className="h-4 w-4" />
</button>
</Tooltip>
<Tooltip content="Share" side="bottom">
<button type="button" className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-foreground press">
<Share className="h-4 w-4" />
</button>
</Tooltip>
<Tooltip content="Open settings" side="left">
<button type="button" className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-foreground press">
<Settings className="h-4 w-4" />
</button>
</Tooltip>
<Tooltip content="Move to trash" side="right">
<button type="button" className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-foreground press">
<Trash2 className="h-4 w-4" />
</button>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">Hover or focus each button. Content fades and un-blurs in.</p>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/motion/tooltip
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const anchorRef = useRef<HTMLSpanElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const point: Record<Side, { top: number; left: number }> = {
top: { top: r.top - GAP, left: cx },
bottom: { top: r.bottom + GAP, left: cx },
left: { top: cy, left: r.left - GAP },
right: { top: cy, left: r.right + GAP },
};
setCoords(point[side]);
}, [side]);
const show = useCallback(() => {
if (!canHover) return;
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [canHover, delay, place]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
setOpen((wasOpen) => {
if (wasOpen) lastHiddenAt = Date.now();
return false;
});
}, []);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
const variants = useMemo(
() => (reduce ? REDUCED_VARIANTS : buildVariants(side)),
[reduce, side],
);
if (!isValidElement(children)) return children;
const trigger = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
"aria-describedby": id,
},
);
return (
<>
<span
ref={anchorRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
>
{trigger}
</span>
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
aria-hidden
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<motion.span
id={id}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
style={{
transformOrigin: transformOrigin[side],
willChange: "transform, opacity",
}}
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
>
{content}
</motion.span>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
安装
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/motion/tooltip
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const anchorRef = useRef<HTMLSpanElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const point: Record<Side, { top: number; left: number }> = {
top: { top: r.top - GAP, left: cx },
bottom: { top: r.bottom + GAP, left: cx },
left: { top: cy, left: r.left - GAP },
right: { top: cy, left: r.right + GAP },
};
setCoords(point[side]);
}, [side]);
const show = useCallback(() => {
if (!canHover) return;
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [canHover, delay, place]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
setOpen((wasOpen) => {
if (wasOpen) lastHiddenAt = Date.now();
return false;
});
}, []);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
const variants = useMemo(
() => (reduce ? REDUCED_VARIANTS : buildVariants(side)),
[reduce, side],
);
if (!isValidElement(children)) return children;
const trigger = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
"aria-describedby": id,
},
);
return (
<>
<span
ref={anchorRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
>
{trigger}
</span>
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
aria-hidden
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<motion.span
id={id}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
style={{
transformOrigin: transformOrigin[side],
willChange: "transform, opacity",
}}
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
>
{content}
</motion.span>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
API 参考
contentReactNode—side?"bottom" | "left" | "right" | "top"topdelay?numberDelay before showing (ms). Default 120.
120className?string—wrapperClassName?stringClasses for the outer wrapper span. Use to fix baseline / fill parent.
—变形文字提示
tooltip-morph.tsxMorphTooltipGroup 让一组触发器共享同一块气泡:首次悬停弹簧入场,移到相邻触发器时不退场,而是滑移并改变宽度,文字带模糊交叉淡变。
Hover across the toolbar
"use client";
import { Code2, ListChecks, MessageSquare, Sparkles } from "lucide-react";
import {
MorphTooltip,
MorphTooltipGroup,
} from "@/components/motion/tooltip-morph";
const TOOLS = [
{ id: "draft", icon: Sparkles, label: "Draft with AI" },
{ id: "review", icon: ListChecks, label: "Review queued" },
{ id: "source", icon: Code2, label: "View source" },
{ id: "note", icon: MessageSquare, label: "Leave a note" },
] as const;
export function TooltipMorphPreview() {
return (
<div className="flex flex-col items-center gap-4">
<MorphTooltipGroup
side="top"
className="rounded-full border border-border bg-card p-1.5 shadow-sm"
>
{TOOLS.map(({ id, icon: Icon, label }) => (
<MorphTooltip key={id} content={label}>
<button
type="button"
aria-label={label}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground"
>
<Icon className="h-4 w-4" />
</button>
</MorphTooltip>
))}
</MorphTooltipGroup>
<p className="text-xs text-muted-foreground">
Hover across the toolbar
</p>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/motion/tooltip
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
// Gap between trigger and bubble, in px. Matches tooltip.tsx.
const GAP = 8;
// Grace window after the pointer leaves the whole group before the bubble
// exits — long enough to cross the dead space between adjacent triggers
// without dismissing, short enough to still read as "left the toolbar".
const LEAVE_GRACE_MS = 150;
interface Anchor {
/** Bubble center, px from the group's left edge. */
x: number;
/** Px from the group's near edge to the trigger's facing edge (minus gap). */
edge: number;
}
interface ActiveState {
id: string;
content: ReactNode;
className: string | undefined;
anchor: Anchor;
}
interface ActivateArgs {
id: string;
content: ReactNode;
className: string | undefined;
node: HTMLElement;
}
interface MorphTooltipContextValue {
activate: (args: ActivateArgs) => void;
leave: () => void;
canHover: boolean;
surfaceId: string;
}
const MorphTooltipContext = createContext<MorphTooltipContextValue | null>(
null,
);
function useMorphTooltipContext(component: string) {
const ctx = useContext(MorphTooltipContext);
if (!ctx)
throw new Error(`${component} must be used within <MorphTooltipGroup>`);
return ctx;
}
export interface MorphTooltipGroupProps {
/** Which side of the triggers the shared bubble grows on. Default "top". */
side?: Side;
/** Delay before the bubble first appears, in ms. Default 120 (matches Tooltip). */
delay?: number;
className?: string;
children: ReactNode;
}
// Bubble entrance mirrors Tooltip.tsx's feel: spring transform, faster
// tween-driven opacity/blur riding on top, offset from the trigger's side so
// it visibly grows out of it.
function buildBubbleVariants(side: Side): Variants {
const y = side === "top" ? 8 : -8;
return {
initial: { opacity: 0, scale: 0.9, filter: "blur(5px)", y },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
y: y * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_BUBBLE_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Content crossfade — label swaps as the bubble glides to a neighbour.
// Exit is faster than enter, per motion convention.
const CONTENT_VARIANTS: Variants = {
initial: { opacity: 0, filter: "blur(4px)" },
animate: { opacity: 1, filter: "blur(0px)", transition: SPRING_SWAP },
exit: {
opacity: 0,
filter: "blur(4px)",
transition: { duration: 0.1, ease: EASE_OUT },
},
};
const REDUCED_CONTENT_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
/**
* Container for a row of adjacent triggers (e.g. toolbar icon buttons) that
* share one tooltip surface. The bubble springs in over the first trigger;
* moving directly to a neighbour glides the same bubble across via layout
* FLIP instead of exiting and re-entering. Wrap each trigger in `MorphTooltip`.
*/
export function MorphTooltipGroup({
side = "top",
delay = 120,
className,
children,
}: MorphTooltipGroupProps) {
const groupRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<ActiveState | null>(null);
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceId = useId();
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
// Trigger rect relative to the group container — the surface is absolutely
// positioned within it, so viewport coords would need a portal + scroll
// listeners (see Tooltip.tsx); a local, non-portaled surface avoids that.
const measure = useCallback(
(node: HTMLElement): Anchor | null => {
const group = groupRef.current;
if (!group) return null;
const groupRect = group.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const x = nodeRect.left - groupRect.left + nodeRect.width / 2;
const edge =
side === "top"
? groupRect.bottom - nodeRect.top + GAP
: nodeRect.bottom - groupRect.top + GAP;
return { x, edge };
},
[side],
);
const activate = useCallback(
({ id, content, className: bubbleClassName, node }: ActivateArgs) => {
if (leaveTimer.current) {
clearTimeout(leaveTimer.current);
leaveTimer.current = null;
}
const anchor = measure(node);
if (!anchor) return;
const isFirst = active === null;
if (showTimer.current) {
clearTimeout(showTimer.current);
showTimer.current = null;
}
if (isFirst) {
// Cold start — wait out the hover-intent delay before appearing.
showTimer.current = setTimeout(() => {
setActive({ id, content, className: bubbleClassName, anchor });
showTimer.current = null;
}, delay);
} else {
// Already warm — morph to the new trigger immediately, no delay.
setActive({ id, content, className: bubbleClassName, anchor });
}
},
[active, delay, measure],
);
const leave = useCallback(() => {
if (showTimer.current) {
clearTimeout(showTimer.current);
showTimer.current = null;
}
if (leaveTimer.current) clearTimeout(leaveTimer.current);
leaveTimer.current = setTimeout(() => {
setActive(null);
leaveTimer.current = null;
}, LEAVE_GRACE_MS);
}, []);
const ctx = useMemo<MorphTooltipContextValue>(
() => ({ activate, leave, canHover, surfaceId }),
[activate, leave, canHover, surfaceId],
);
const bubbleVariants = reduce
? REDUCED_BUBBLE_VARIANTS
: buildBubbleVariants(side);
const contentVariants = reduce ? REDUCED_CONTENT_VARIANTS : CONTENT_VARIANTS;
return (
<MorphTooltipContext.Provider value={ctx}>
<div
ref={groupRef}
className={cn("relative inline-flex items-center gap-1", className)}
>
{children}
<AnimatePresence>
{active ? (
<motion.div
key="surface"
id={surfaceId}
role="tooltip"
layout={!reduce}
variants={bubbleVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ layout: SPRING_LAYOUT }}
style={
side === "top"
? {
position: "absolute",
left: active.anchor.x,
bottom: active.anchor.edge,
x: "-50%",
transformOrigin: "center bottom",
}
: {
position: "absolute",
left: active.anchor.x,
top: active.anchor.edge,
x: "-50%",
transformOrigin: "center top",
}
}
className={cn(
"pointer-events-none z-30 whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
active.className,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={active.id}
variants={contentVariants}
initial="initial"
animate="animate"
exit="exit"
className="block"
>
{active.content}
</motion.span>
</AnimatePresence>
</motion.div>
) : null}
</AnimatePresence>
</div>
</MorphTooltipContext.Provider>
);
}
export interface MorphTooltipProps {
content: ReactNode;
children: ReactElement;
/** Classes applied to the shared bubble while this trigger is the active one. */
className?: string;
}
/**
* Wraps a single trigger inside a `MorphTooltipGroup`. Hover or focus
* activates the group's shared bubble over this trigger; the bubble itself
* is rendered once by the group, not per-trigger.
*/
export function MorphTooltip({ content, children, className }: MorphTooltipProps) {
const ctx = useMorphTooltipContext("MorphTooltip");
const id = useId();
const ref = useRef<HTMLSpanElement>(null);
if (!isValidElement(children)) return children;
const trigger = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
onMouseEnter: () => {
if (!ctx.canHover) return;
if (ref.current)
ctx.activate({ id, content, className, node: ref.current });
},
onMouseLeave: () => ctx.leave(),
onFocus: () => {
if (ref.current)
ctx.activate({ id, content, className, node: ref.current });
},
onBlur: () => ctx.leave(),
"aria-describedby": ctx.surfaceId,
},
);
return (
<span ref={ref} className="relative inline-flex align-middle">
{trigger}
</span>
);
}
安装
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/motion/tooltip
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
// Gap between trigger and bubble, in px. Matches tooltip.tsx.
const GAP = 8;
// Grace window after the pointer leaves the whole group before the bubble
// exits — long enough to cross the dead space between adjacent triggers
// without dismissing, short enough to still read as "left the toolbar".
const LEAVE_GRACE_MS = 150;
interface Anchor {
/** Bubble center, px from the group's left edge. */
x: number;
/** Px from the group's near edge to the trigger's facing edge (minus gap). */
edge: number;
}
interface ActiveState {
id: string;
content: ReactNode;
className: string | undefined;
anchor: Anchor;
}
interface ActivateArgs {
id: string;
content: ReactNode;
className: string | undefined;
node: HTMLElement;
}
interface MorphTooltipContextValue {
activate: (args: ActivateArgs) => void;
leave: () => void;
canHover: boolean;
surfaceId: string;
}
const MorphTooltipContext = createContext<MorphTooltipContextValue | null>(
null,
);
function useMorphTooltipContext(component: string) {
const ctx = useContext(MorphTooltipContext);
if (!ctx)
throw new Error(`${component} must be used within <MorphTooltipGroup>`);
return ctx;
}
export interface MorphTooltipGroupProps {
/** Which side of the triggers the shared bubble grows on. Default "top". */
side?: Side;
/** Delay before the bubble first appears, in ms. Default 120 (matches Tooltip). */
delay?: number;
className?: string;
children: ReactNode;
}
// Bubble entrance mirrors Tooltip.tsx's feel: spring transform, faster
// tween-driven opacity/blur riding on top, offset from the trigger's side so
// it visibly grows out of it.
function buildBubbleVariants(side: Side): Variants {
const y = side === "top" ? 8 : -8;
return {
initial: { opacity: 0, scale: 0.9, filter: "blur(5px)", y },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
y: y * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_BUBBLE_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Content crossfade — label swaps as the bubble glides to a neighbour.
// Exit is faster than enter, per motion convention.
const CONTENT_VARIANTS: Variants = {
initial: { opacity: 0, filter: "blur(4px)" },
animate: { opacity: 1, filter: "blur(0px)", transition: SPRING_SWAP },
exit: {
opacity: 0,
filter: "blur(4px)",
transition: { duration: 0.1, ease: EASE_OUT },
},
};
const REDUCED_CONTENT_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
/**
* Container for a row of adjacent triggers (e.g. toolbar icon buttons) that
* share one tooltip surface. The bubble springs in over the first trigger;
* moving directly to a neighbour glides the same bubble across via layout
* FLIP instead of exiting and re-entering. Wrap each trigger in `MorphTooltip`.
*/
export function MorphTooltipGroup({
side = "top",
delay = 120,
className,
children,
}: MorphTooltipGroupProps) {
const groupRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<ActiveState | null>(null);
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceId = useId();
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
// Trigger rect relative to the group container — the surface is absolutely
// positioned within it, so viewport coords would need a portal + scroll
// listeners (see Tooltip.tsx); a local, non-portaled surface avoids that.
const measure = useCallback(
(node: HTMLElement): Anchor | null => {
const group = groupRef.current;
if (!group) return null;
const groupRect = group.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const x = nodeRect.left - groupRect.left + nodeRect.width / 2;
const edge =
side === "top"
? groupRect.bottom - nodeRect.top + GAP
: nodeRect.bottom - groupRect.top + GAP;
return { x, edge };
},
[side],
);
const activate = useCallback(
({ id, content, className: bubbleClassName, node }: ActivateArgs) => {
if (leaveTimer.current) {
clearTimeout(leaveTimer.current);
leaveTimer.current = null;
}
const anchor = measure(node);
if (!anchor) return;
const isFirst = active === null;
if (showTimer.current) {
clearTimeout(showTimer.current);
showTimer.current = null;
}
if (isFirst) {
// Cold start — wait out the hover-intent delay before appearing.
showTimer.current = setTimeout(() => {
setActive({ id, content, className: bubbleClassName, anchor });
showTimer.current = null;
}, delay);
} else {
// Already warm — morph to the new trigger immediately, no delay.
setActive({ id, content, className: bubbleClassName, anchor });
}
},
[active, delay, measure],
);
const leave = useCallback(() => {
if (showTimer.current) {
clearTimeout(showTimer.current);
showTimer.current = null;
}
if (leaveTimer.current) clearTimeout(leaveTimer.current);
leaveTimer.current = setTimeout(() => {
setActive(null);
leaveTimer.current = null;
}, LEAVE_GRACE_MS);
}, []);
const ctx = useMemo<MorphTooltipContextValue>(
() => ({ activate, leave, canHover, surfaceId }),
[activate, leave, canHover, surfaceId],
);
const bubbleVariants = reduce
? REDUCED_BUBBLE_VARIANTS
: buildBubbleVariants(side);
const contentVariants = reduce ? REDUCED_CONTENT_VARIANTS : CONTENT_VARIANTS;
return (
<MorphTooltipContext.Provider value={ctx}>
<div
ref={groupRef}
className={cn("relative inline-flex items-center gap-1", className)}
>
{children}
<AnimatePresence>
{active ? (
<motion.div
key="surface"
id={surfaceId}
role="tooltip"
layout={!reduce}
variants={bubbleVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ layout: SPRING_LAYOUT }}
style={
side === "top"
? {
position: "absolute",
left: active.anchor.x,
bottom: active.anchor.edge,
x: "-50%",
transformOrigin: "center bottom",
}
: {
position: "absolute",
left: active.anchor.x,
top: active.anchor.edge,
x: "-50%",
transformOrigin: "center top",
}
}
className={cn(
"pointer-events-none z-30 whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
active.className,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={active.id}
variants={contentVariants}
initial="initial"
animate="animate"
exit="exit"
className="block"
>
{active.content}
</motion.span>
</AnimatePresence>
</motion.div>
) : null}
</AnimatePresence>
</div>
</MorphTooltipContext.Provider>
);
}
export interface MorphTooltipProps {
content: ReactNode;
children: ReactElement;
/** Classes applied to the shared bubble while this trigger is the active one. */
className?: string;
}
/**
* Wraps a single trigger inside a `MorphTooltipGroup`. Hover or focus
* activates the group's shared bubble over this trigger; the bubble itself
* is rendered once by the group, not per-trigger.
*/
export function MorphTooltip({ content, children, className }: MorphTooltipProps) {
const ctx = useMorphTooltipContext("MorphTooltip");
const id = useId();
const ref = useRef<HTMLSpanElement>(null);
if (!isValidElement(children)) return children;
const trigger = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
onMouseEnter: () => {
if (!ctx.canHover) return;
if (ref.current)
ctx.activate({ id, content, className, node: ref.current });
},
onMouseLeave: () => ctx.leave(),
onFocus: () => {
if (ref.current)
ctx.activate({ id, content, className, node: ref.current });
},
onBlur: () => ctx.leave(),
"aria-describedby": ctx.surfaceId,
},
);
return (
<span ref={ref} className="relative inline-flex align-middle">
{trigger}
</span>
);
}
API 参考
MorphTooltipGroup
side?"bottom" | "top"Which side of the triggers the shared bubble grows on. Default "top".
topdelay?numberDelay before the bubble first appears, in ms. Default 120 (matches Tooltip).
120className?string—MorphTooltip
contentReactNode—className?stringClasses applied to the shared bubble while this trigger is the active one.
—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.