컴포넌트
툴팁
마우스를 올리거나 포커스하면 짧은 설명을 띄우고 연달아 열면 바로 나타나는 툴팁이에요.
- #arc
- #component
- #tooltip
- #hint
- #overlay
- #hover
- #popup
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/tooltip.json파일
components/arc/tooltip/tooltip.module.csscomponents/arc/tooltip/tooltip.tsx
npm 패키지
@radix-ui/react-tooltip@^1.4.1motion@^14.2.0
함께 오는 항목
tooltip.module.csstooltip.module.csstooltip.module.csscss
.tooltip { --tooltip-y: 3px; --tooltip-dot: 4px; --tooltip-dot-gap: 2px; position: relative; z-index: 90; max-width: 15rem; padding: var(--space-1) var(--space-2); border: 1px solid var(--foreground); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-body); transform-origin: var(--radix-tooltip-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-fast) var(--ease-enter); }
.tooltip[data-side="bottom"] { --tooltip-y: -3px; }
/* A 4px dot marks the trigger, 2px off the bubble on the side that faces it. The 1px border is outside the padding box the offsets measure from. */
.tooltip::after { content: ""; position: absolute; width: var(--tooltip-dot); height: var(--tooltip-dot); border-radius: var(--radius-pill); background: var(--foreground); pointer-events: none; }
.tooltip[data-side="top"]::after { top: calc(100% + 1px + var(--tooltip-dot-gap)); left: calc(50% - var(--tooltip-dot) / 2); }
.tooltip[data-side="bottom"]::after { bottom: calc(100% + 1px + var(--tooltip-dot-gap)); left: calc(50% - var(--tooltip-dot) / 2); }
.tooltip[data-side="left"]::after { left: calc(100% + 1px + var(--tooltip-dot-gap)); top: calc(50% - var(--tooltip-dot) / 2); }
.tooltip[data-side="right"]::after { right: calc(100% + 1px + var(--tooltip-dot-gap)); top: calc(50% - var(--tooltip-dot) / 2); }
/* The first tooltip waits, then rises a few pixels from its trigger. Within the skip window the next one only fades.
Transitions instead of keyframes, so returning to the trigger while it fades out reverses the fade instead of restarting it. */
@starting-style {
.tooltip[data-state$="-open"] { opacity: 0; transform: translateY(var(--tooltip-y)) scale(.97); }
.tooltip[data-instant][data-state$="-open"] { transform: none; }
}
.tooltip[data-instant][data-state$="-open"] { transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
.tooltip[data-state="closed"] { opacity: 0; transform: scale(.98); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: tooltip-exit var(--duration-instant) linear both; }
.text { position: relative; display: block; overflow: clip; overflow-clip-margin: var(--space-2); }
.measure { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(15rem - 2 * var(--space-2) - 2px); visibility: hidden; pointer-events: none; }
.line { display: block; width: max-content; max-width: calc(15rem - 2 * var(--space-2) - 2px); }
@keyframes tooltip-exit { to { --tooltip-exit: 1; } }
@media (prefers-reduced-motion: reduce) {
.tooltip, .tooltip[data-state="closed"], .tooltip[data-instant][data-state$="-open"] { transform: none; transition: opacity var(--duration-instant) var(--ease-standard); }
}
tooltip.tsxtooltip.tsxtooltip.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { ReactElement, ReactNode } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./tooltip.module.css";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: "top" | "bottom" | "left" | "right";
}
const DELAY = 250;
const SKIP_WINDOW = 300;
/* Every Tooltip brings its own provider, so the skip window is shared here: while any tooltip is open, and briefly after the last one closes, the next opens without delay or travel. */
let warm = false;
let openCount = 0;
let coolTimer = 0;
const listeners = new Set<() => void>();
const warmth = {
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
get: () => warm,
set(next: boolean) { if (warm === next) return; warm = next; listeners.forEach(listener => listener()); },
opened() { openCount += 1; window.clearTimeout(coolTimer); warmth.set(true); },
closed() { openCount = Math.max(0, openCount - 1); if (openCount) return; window.clearTimeout(coolTimer); coolTimer = window.setTimeout(() => warmth.set(false), SKIP_WINDOW); },
};
/** String content crossfades when it changes while open, and the bubble springs to the new text size. */
function TooltipText({ text }: { text: string }) {
const reduced = useReducedMotion();
const measure = useRef<HTMLSpanElement>(null);
const measured = useRef<string | null>(null);
const [size, setSize] = useState<{ width: number; height: number; animate: boolean } | null>(null);
useLayoutEffect(() => {
const node = measure.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const box = entry.borderBoxSize?.[0];
const current = node.textContent;
const animate = measured.current !== null && measured.current !== current;
measured.current = current;
setSize({ width: Math.ceil(box?.inlineSize ?? node.offsetWidth), height: Math.ceil(box?.blockSize ?? node.offsetHeight), animate });
});
observer.observe(node);
return () => observer.disconnect();
}, []);
return <motion.span className={styles.text} initial={false} animate={size ? { width: size.width, height: size.height } : undefined} transition={size?.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}>
<span ref={measure} className={styles.measure} aria-hidden="true">{text}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={text} className={styles.line} initial={reduced ? false : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>
</AnimatePresence>
</motion.span>;
}
export function Tooltip({ content, children, side = "top" }: TooltipProps) {
const isWarm = useSyncExternalStore(warmth.subscribe, warmth.get, () => false);
// Controlled so the instant flag lands in the same render that mounts the content (Radix reports uncontrolled changes a frame late).
const [open, setOpen] = useState(false);
const [instant, setInstant] = useState(false);
useEffect(() => {
if (!open) return;
warmth.opened();
return warmth.closed;
}, [open]);
return <TooltipPrimitive.Provider delayDuration={DELAY} skipDelayDuration={0}>
<TooltipPrimitive.Root open={open} delayDuration={isWarm ? 0 : DELAY} onOpenChange={next => { if (next) setInstant(warmth.get()); setOpen(next); }}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content className={styles.tooltip} data-instant={instant || undefined} side={side} sideOffset={12} collisionPadding={12}>
{typeof content === "string" || typeof content === "number" ? <TooltipText text={String(content)}/> : content}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>;
}
export default Tooltip;
이 항목을 쓰는 조합
- 떠 있는 버튼 묶음컴포넌트
화면 위에 떠 있는 도구 모음 형태의 버튼 묶음이에요.