9 CSS Tooltip Bubbles — one line, copy-paste
A css tooltip bubble is a small floating label that shows extra text next to a button, link, or icon and disappears again once the pointer or keyboard focus
01 tooltip-bubble-top-basic
02 tooltip-bubble-fade-scale-pop
03 tooltip-bubble-anchor-flip
04 tooltip-bubble-rich-card-preview
05 tooltip-bubble-arrow-tail
06 tooltip-bubble-delay-show
07 tooltip-bubble-keyboard-focus-show
08 tooltip-bubble-kakao-style-bubble
09 tooltip-bubble-info-pulse-dot
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Basic top tooltip
- 02 Fade + scale pop
- 03 Auto-flip via anchor positioning
- 04 Rich card preview tooltip
- 05 Arrow tail
- 06 Delayed show on hover
- 07 Show on keyboard focus
- 08 Chat-app style bubble
- 09 Info pulse dot
The nine below move from the plainest shape to the most involved one. 01 and 02 only reposition and fade a single element on hover; 03 and 04 lean on two newer CSS layout features — anchor positioning and the Popover API — to solve where the bubble should sit without extra script. 05 through 07 refine the shape itself with a tail that turns the box into a css balloon tooltip, a built-in delay, and a second trigger for people who never touch a mouse. 08 and 09 are the idea-form entries, dressing the same mechanics up as a chat message and a pulsing help badge. Watch the grid above cycle through all nine on its own — every css tooltip bubble demo below runs inside a fixed, non-scrollable iframe with nothing to click, so it just loops through hidden, shown, and hidden again. For click responses in the wider sense, the micro interactions CSS set gathers them, and turning the pointer's whereabouts itself into the effect is what the custom cursor CSS effects do.
01Basic top tooltip
How this css tooltip animation runs
A small bubble appears above the target with a short line of text and disappears when the pointer leaves. This round layers a one-shot entry spring on top: scale overshoots to 1.12 before settling through a damped rotation wobble of 2.6°→-1.5°→0.6° around the arrow-side edge.
Where it fits
It sits absolutely inside a relatively positioned wrapper, 8px above the trigger, so it needs no script to track the button's size — reach for this shape for a help icon next to a button or an abbreviation hint in a table header.

<main class="stage">
<div class="tip-wrap">
<button class="info-btn" type="button" aria-describedby="tip01">Help</button>
<span class="tooltip is-demo" role="tooltip" id="tip01">Members only</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-out; // 나타날 땐 빠르게, 사라질 땐 부드럽게
$color: $subject-blue; // 트리거 버튼 색
@include stage($stage-ink);
.tip-wrap {
position: relative; // 툴팁 위치의 기준
}
.info-btn {
border: 0;
border-radius: $r-pill;
padding: 12px 22px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #fff;
background: $color;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px); // 버튼 바로 위, 8px 간격
left: 50%;
transform-origin: bottom center; // 규칙8: 화살표(버튼 쪽 아래변) 축 — 흔들림의 회전 중심
transform: translate(-50%, 4px);
opacity: 0;
background: $subject-cream;
color: $stage-ink;
padding: 8px 14px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
// 데모: 호버 없이 혼자 표시→숨김을 반복한다
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
// 등장 스프링 한 겹 — 스케일은 .85 에서 1.12 로 목표를 지나쳤다가 감쇠 반동(.95 → 1.03)으로 멎고,
// 회전은 화살표 축(아래변 중심)을 중심으로 2.6° → -1.5° → .6° 로 감쇠하며 흔들린다
@keyframes tip-loop {
0% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
12% { opacity: 1; transform: translate(-50%, 0) scale(1.12) rotate(2.6deg); }
22% { opacity: 1; transform: translate(-50%, 0) scale(.95) rotate(-1.5deg); }
30% { opacity: 1; transform: translate(-50%, 0) scale(1.03) rotate(.6deg); }
36%, 55% { opacity: 1; transform: translate(-50%, 0) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
100% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo { animation: none; opacity: 1; transform: translate(-50%, 0) scale(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tip-wrap {
position: relative;
}
.info-btn {
border: 0;
border-radius: 999px;
padding: 12px 22px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #fff;
background: #2f6df6;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center;
transform: translate(-50%, 4px);
opacity: 0;
background: #fff7e6;
color: #17141a;
padding: 8px 14px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
.tooltip.is-demo {
animation: tip-loop 2s ease-out infinite;
}
@keyframes tip-loop {
0% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: translate(-50%, 0) scale(1.12) rotate(2.6deg);
}
22% {
opacity: 1;
transform: translate(-50%, 0) scale(0.95) rotate(-1.5deg);
}
30% {
opacity: 1;
transform: translate(-50%, 0) scale(1.03) rotate(0.6deg);
}
36%, 55% {
opacity: 1;
transform: translate(-50%, 0) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./TopTooltip.module.scss";
export type TopTooltipProps = {
/** 버튼 글자 */
label: ReactNode;
/** 툴팁 글자 */
text: string;
/** 나타나고 사라지는 시간 (기본 200ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 버튼 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TopTooltip({
label,
text,
duration = 200,
easing = "ease-out",
color = "#2f6df6",
}: TopTooltipProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.wrap} style={vars}>
<button
type="button"
className={styles.btn}
aria-describedby="top-tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{label}
</button>
<span id="top-tooltip" role="tooltip" className={styles.tooltip} data-open={open}>
{text}
</span>
</span>
);
}01 · Basic top tooltip — Measured area 4.959% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.tip-wrap {
position: relative;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translate(-50%, 4px);
opacity: 0;
border-radius: $r-control;
pointer-events: none;
}
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
@keyframes tip-loop {
0%, 100% { opacity: 0; transform: translate(-50%, 4px); }
20%, 55% { opacity: 1; transform: translate(-50%, 0); }
80% { opacity: 0; transform: translate(-50%, 4px); }
}
02Fade + scale pop
How it works
The tooltip starts slightly scaled down and pops up to full size as it fades in. The pop now overshoots to 1.14 before a damped rotation wobble of -2.4°→1.4°→-0.5° around the top edge settles it.
Where it fits
The scale runs from .85 up to 1 on a spring-like easing curve before the loop shrinks it back down again, which suits a callout for a new feature or a toolbar icon that needs to stand out from its neighbors.

<main class="stage">
<div class="tip-wrap">
<button class="info-btn" type="button" aria-describedby="tip02">New</button>
<span class="tooltip is-demo" role="tooltip" id="tip02">Just added</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-pop; // 끝에서 살짝 튀는 팝 이징
$color: $subject-ink; // 트리거 버튼 색 (노란 무대라 먹색)
@include stage($stage-yellow);
.tip-wrap { position: relative; }
.info-btn {
border: 0;
border-radius: $r-control;
padding: 12px 22px;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: $stage-yellow;
background: $color;
cursor: pointer;
}
.tooltip {
position: absolute;
top: calc(100% + 10px); // 버튼 아래쪽에 표시
left: 50%;
transform-origin: top center; // 규칙8: 버튼 아래변에서 펼침
transform: translateX(-50%) scale(.85);
opacity: 0;
background: $stage-ink;
color: $stage-yellow;
padding: 8px 16px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
.tooltip.is-demo {
animation: pop-loop $duration $easing infinite;
}
// 등장 스프링 한 겹 — 스케일은 .85 → 1.14 오버슛 뒤 감쇠 반동(.94 → 1.02)으로 멎고,
// 회전은 화살표 축(윗변 중심)을 중심으로 -2.4° → 1.4° → -.5° 로 감쇠하며 흔들린다
@keyframes pop-loop {
0% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
12% { opacity: 1; transform: translateX(-50%) scale(1.14) rotate(-2.4deg); }
22% { opacity: 1; transform: translateX(-50%) scale(.94) rotate(1.4deg); }
30% { opacity: 1; transform: translateX(-50%) scale(1.02) rotate(-.5deg); }
36%, 55% { opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
100% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo { animation: none; opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tip-wrap {
position: relative;
}
.info-btn {
border: 0;
border-radius: 12px;
padding: 12px 22px;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #ffd23f;
background: #17141a;
cursor: pointer;
}
.tooltip {
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform-origin: top center;
transform: translateX(-50%) scale(0.85);
opacity: 0;
background: #17141a;
color: #ffd23f;
padding: 8px 16px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
.tooltip.is-demo {
animation: pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pop-loop {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: translateX(-50%) scale(1.14) rotate(-2.4deg);
}
22% {
opacity: 1;
transform: translateX(-50%) scale(0.94) rotate(1.4deg);
}
30% {
opacity: 1;
transform: translateX(-50%) scale(1.02) rotate(-0.5deg);
}
36%, 55% {
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
}import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./PopTooltip.module.scss";
export type PopTooltipProps = {
label: ReactNode;
text: string;
/** 팝 애니메이션 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PopTooltip({
label,
text,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: PopTooltipProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.wrap} style={vars}>
<button
type="button"
className={styles.btn}
aria-describedby="pop-tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{label}
</button>
<span id="pop-tooltip" role="tooltip" className={styles.tooltip} data-open={open}>
{text}
</span>
</span>
);
}02 · Fade + scale pop — Measured area 5.799% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.tooltip {
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform-origin: top center;
transform: translateX(-50%) scale(.85);
opacity: 0;
}
.tooltip.is-demo {
animation: pop-loop $duration $easing infinite;
}
@keyframes pop-loop {
0%, 100% { opacity: 0; transform: translateX(-50%) scale(.85); }
25%, 55% { opacity: 1; transform: translateX(-50%) scale(1); }
80% { opacity: 0; transform: translateX(-50%) scale(.85); }
}
03Auto-flip via anchor positioning
How it works
With anchor-name and position-anchor set once, the browser flips the tooltip to the other side on its own near a screen edge. Both the fallback and the anchored placement now share the same entry spring — scale overshoots to 1.1, then a damped rotation wobble of 2.2°→-1.3°→0.5° settles it.
Where it fits
Chrome and Edge have shipped anchor positioning since version 125, with the full spec landing at 151; Safari 27 supports it fully and Firefox 147 only partially, as of September 2026, so the demo wraps the rule in an @supports block and keeps a plain absolutely positioned fallback underneath for buttons near a fixed bottom bar or a narrow sidebar.

<main class="stage">
<button class="trigger" type="button" aria-describedby="tip03">Menu</button>
<span class="tooltip is-demo" role="tooltip" id="tip03">Saves up to 5 recent items</span>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-out;
$color: $subject-cream; // 트리거 버튼 색 (주황 무대라 크림)
@include stage($stage-orange);
.stage { position: relative; } // 트리거·툴팁의 절대 위치 기준
// 트리거를 무대 위쪽에 둔다 — 기본 위치(위)에 자리가 없어야 뒤집힌다
.trigger {
anchor-name: --tip03; // 앵커 이름 등록. 지원: Chrome 125+
position: absolute;
top: 22px;
left: 50%;
transform: translateX(-50%);
border: 0;
border-radius: $r-control;
padding: 12px 22px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: $subject-orange-deep; // AA: 크림 면 위 주황 글자 5.04:1
background: $color;
cursor: pointer;
}
.tooltip {
position: absolute; // 폴백: 앵커 없이도 버튼 아래에 뜬다
top: calc(22px + 100% + 10px);
left: 50%;
transform-origin: top center; // 규칙8: 버튼 쪽 윗변 축 — 흔들림의 회전 중심
transform: translateX(-50%) translateY(-4px);
opacity: 0;
background: $stage-ink;
color: $subject-cream;
padding: 8px 14px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
// 앵커 위치 지정 지원 브라우저: 버튼을 기준으로 아래에 붙이되, 위에 자리가 없으면 자동으로 아래로 뒤집는다
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning
@supports (position-anchor: --tip03) {
.tooltip {
position: fixed;
position-anchor: --tip03;
top: auto;
inset: unset;
position-area: top; // 기본은 버튼 위
position-try-fallbacks: flip-block; // 위에 자리가 없으면 아래로 뒤집힘
margin-block: 10px;
transform: translateY(0);
}
.tooltip.is-demo { animation-name: tip-loop-anchor; }
}
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
// 등장 스프링 한 겹(폴백 축) — 스케일 .85 → 1.1 오버슛 → 감쇠 반동, 회전은 윗변 축으로 2.2° → -1.3° → .5° 감쇠
@keyframes tip-loop {
0% { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(.85) rotate(0deg); }
12% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.1) rotate(2.2deg); }
22% { opacity: 1; transform: translateX(-50%) translateY(0) scale(.95) rotate(-1.3deg); }
30% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.02) rotate(.5deg); }
36%, 55% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(.85) rotate(0deg); }
100% { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(.85) rotate(0deg); }
}
// 등장 스프링 한 겹(앵커 축) — 같은 오버슛·감쇠를 앵커 배치 버튼에도 그대로
@keyframes tip-loop-anchor {
0% { opacity: 0; transform: scale(.85) rotate(0deg); }
12% { opacity: 1; transform: scale(1.1) rotate(2.2deg); }
22% { opacity: 1; transform: scale(.95) rotate(-1.3deg); }
30% { opacity: 1; transform: scale(1.02) rotate(.5deg); }
36%, 55% { opacity: 1; transform: scale(1) rotate(0deg); }
80% { opacity: 0; transform: scale(.85) rotate(0deg); }
100% { opacity: 0; transform: scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo { animation: none; opacity: 1; transform: scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.trigger {
anchor-name: --tip03;
position: absolute;
top: 22px;
left: 50%;
transform: translateX(-50%);
border: 0;
border-radius: 12px;
padding: 12px 22px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #c93000;
background: #fff7e6;
cursor: pointer;
}
.tooltip {
position: absolute;
top: calc(22px + 100% + 10px);
left: 50%;
transform-origin: top center;
transform: translateX(-50%) translateY(-4px);
opacity: 0;
background: #17141a;
color: #fff7e6;
padding: 8px 14px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
@supports (position-anchor: --tip03) {
.tooltip {
position: fixed;
position-anchor: --tip03;
top: auto;
inset: unset;
position-area: top;
position-try-fallbacks: flip-block;
margin-block: 10px;
transform: translateY(0);
}
.tooltip.is-demo {
animation-name: tip-loop-anchor;
}
}
.tooltip.is-demo {
animation: tip-loop 2s ease-out infinite;
}
@keyframes tip-loop {
0% {
opacity: 0;
transform: translateX(-50%) translateY(-4px) scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.1) rotate(2.2deg);
}
22% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(0.95) rotate(-1.3deg);
}
30% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.02) rotate(0.5deg);
}
36%, 55% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translateX(-50%) translateY(-4px) scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-4px) scale(0.85) rotate(0deg);
}
}
@keyframes tip-loop-anchor {
0% {
opacity: 0;
transform: scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: scale(1.1) rotate(2.2deg);
}
22% {
opacity: 1;
transform: scale(0.95) rotate(-1.3deg);
}
30% {
opacity: 1;
transform: scale(1.02) rotate(0.5deg);
}
36%, 55% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: scale(1) rotate(0deg);
}
}import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AnchorFlipTooltip.module.scss";
export type AnchorFlipTooltipProps = {
label: ReactNode;
text: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// anchor-name/position-anchor 는 Chrome 125+ 에서만 동작한다. 미지원 브라우저는
// .module.scss 의 기본 position:absolute 규칙이 버튼 아래에 그대로 띄운다.
export default function AnchorFlipTooltip({
label,
text,
duration = 200,
easing = "ease-out",
color = "#fff7e6",
}: AnchorFlipTooltipProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.wrap} style={vars}>
<button
type="button"
className={styles.trigger}
aria-describedby="anchor-tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{label}
</button>
<span id="anchor-tooltip" role="tooltip" className={styles.tooltip} data-open={open}>
{text}
</span>
</span>
);
}03 · Auto-flip via anchor positioning — Measured area 6.054% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.trigger {
anchor-name: --tip03;
}
@supports (position-anchor: --tip03) {
.tooltip {
position: fixed;
position-anchor: --tip03;
top: auto;
inset: unset;
position-area: top;
position-try-fallbacks: flip-block;
margin-block: 10px;
transform: translateY(0);
}
.tooltip.is-demo { animation-name: tip-loop-anchor; }
}
04Rich card preview tooltip
How it works
The Popover API lifts the tooltip into the top layer to show an image-and-text preview card. Its entry now overshoots to 1.08 before a damped rotation wobble of 1.8°→-1°→0.4° around the top edge settles it.
Where it fits
The popover attribute reached Chrome 114, Safari 17, and Firefox 125, and it suits a hover preview in a product list or a summary card sitting over a profile name.

<main class="stage">
<div class="wrap">
<a class="trigger" href="#" aria-describedby="card04">View item</a>
<div class="card" id="card04" popover="manual" role="tooltip">
<div class="card__thumb" aria-hidden="true"></div>
<p class="card__title">Wireless earbuds</p>
<p class="card__desc">Noise cancelling · 30h battery</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-spring;
$color: $subject-blue; // 트리거 링크 색
@include stage($stage-paper);
.stage { position: relative; }
.wrap { position: relative; }
.trigger {
display: inline-block;
border-radius: $r-pill;
padding: 12px 22px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #fff;
background: $color;
text-decoration: none;
}
// popover 속성은 UA 가 기본 여백·테두리를 준다 — 전부 지우고 우리 위치로 다시 잡는다
.card {
position: absolute; // 폴백: popover 미지원 브라우저도 이 위치에 보인다
top: calc(100% + 12px);
left: 50%;
transform-origin: top center; // 규칙8: 트리거 쪽 윗변 축 — 흔들림의 회전 중심
transform: translateX(-50%);
margin: 0;
border: 0;
padding: 14px;
width: min(220px, 68vw);
background: $stage-paper;
border-radius: $r-card;
box-shadow: $shadow-raised;
opacity: 0;
animation: card-loop $duration $easing infinite;
}
// popover 지원 브라우저: 최상위 레이어로 올라가면서 컨테이닝 블록이 뷰포트로 바뀐다.
// top:100% 는 더 이상 트리거 기준이 아니게 되므로 무대 높이 기준 고정값으로 다시 잡는다
// (실측 2026-09-09: top:calc(100%+12px) 를 그대로 두니 트리거가 아니라 뷰포트 100%+12px 아래로
// 밀려나 무대 밖으로 나가면서 넘침·움직임 0 이 됐다).
.card:popover-open {
position: fixed;
top: 150px;
left: 50%;
}
.card__thumb {
height: 64px;
border-radius: $r-control;
background: linear-gradient(135deg, $subject-blue, $subject-cream);
margin-bottom: 10px;
}
.card__title { margin: 0 0 4px; font-weight: 700; font-size: 14px; line-height: 1.3; font-family: inherit; color: $stage-ink; }
.card__desc { margin: 0; font-size: clamp(11px, 3vw, 13px); color: $stage-ink; opacity: .7; }
// 등장 스프링 한 겹 — 스케일 .94 → 1.08 오버슛 뒤 감쇠 반동(.97 → 1.015), 회전은 윗변 축으로 1.8° → -1° → .4° 감쇠
@keyframes card-loop {
0% { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(.94) rotate(0deg); }
12% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.08) rotate(1.8deg); }
22% { opacity: 1; transform: translateX(-50%) translateY(0) scale(.97) rotate(-1deg); }
30% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.015) rotate(.4deg); }
36%, 55% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(.94) rotate(0deg); }
100% { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(.94) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.card { animation: none; opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.wrap {
position: relative;
}
.trigger {
display: inline-block;
border-radius: 999px;
padding: 12px 22px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #fff;
background: #2f6df6;
text-decoration: none;
}
.card {
position: absolute;
top: calc(100% + 12px);
left: 50%;
transform-origin: top center;
transform: translateX(-50%);
margin: 0;
border: 0;
padding: 14px;
width: min(220px, 68vw);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
animation: card-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card:popover-open {
position: fixed;
top: 150px;
left: 50%;
}
.card__thumb {
height: 64px;
border-radius: 12px;
background: linear-gradient(135deg, #2f6df6, #fff7e6);
margin-bottom: 10px;
}
.card__title {
margin: 0 0 4px;
font-weight: 700;
font-size: 14px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.card__desc {
margin: 0;
font-size: clamp(11px, 3vw, 13px);
color: #17141a;
opacity: 0.7;
}
@keyframes card-loop {
0% {
opacity: 0;
transform: translateX(-50%) translateY(-6px) scale(0.94) rotate(0deg);
}
12% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.08) rotate(1.8deg);
}
22% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(0.97) rotate(-1deg);
}
30% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.015) rotate(0.4deg);
}
36%, 55% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translateX(-50%) translateY(-6px) scale(0.94) rotate(0deg);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-6px) scale(0.94) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
}// Popover API 로 카드를 최상위 레이어에 띄운다. 미지원 브라우저는 showPopover 가 없어 조용히 넘어간다
try { document.getElementById("card04").showPopover(); } catch (e) {}import { useRef, useState } from "react";
import type { CSSProperties, MouseEvent } from "react";
import styles from "./CardPreviewTooltip.module.scss";
export type CardPreviewTooltipProps = {
label: string;
title: string;
desc: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PopoverEl = HTMLDivElement & { showPopover?: () => void; hidePopover?: () => void };
export default function CardPreviewTooltip({
label,
title,
desc,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CardPreviewTooltipProps) {
const cardRef = useRef<PopoverEl | null>(null);
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const show = () => {
setOpen(true);
cardRef.current?.showPopover?.();
};
const hide = () => {
setOpen(false);
cardRef.current?.hidePopover?.();
};
return (
<span className={styles.wrap} style={vars}>
<a
className={styles.trigger}
href="#"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
onClick={(e: MouseEvent<HTMLAnchorElement>) => e.preventDefault()}
>
{label}
</a>
{/* @ts-ignore popover 는 React 타입 스텁에 없다 — 표준 HTML 속성 그대로 넘긴다 */}
<div ref={cardRef} popover="manual" role="tooltip" className={styles.card} data-open={open}>
<div className={styles.thumb} aria-hidden="true" />
<p className={styles.title}>{title}</p>
<p className={styles.desc}>{desc}</p>
</div>
</span>
);
}04 · Rich card preview tooltip — Measured area 23.918% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
.card {
position: absolute;
top: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
border: 0;
width: min(220px, 68vw);
box-shadow: $shadow-raised;
opacity: 0;
animation: card-loop $duration $easing infinite;
}
.card:popover-open {
position: fixed;
top: 150px;
left: 50%;
}
05Arrow tail
How it works
A small triangular tail under the bubble makes it obvious which element it points to. The entry spring pivots around that same tail — scale overshoots to 1.12, then a damped rotation wobble of 2.6°→-1.5°→0.6° settles it.
Where it fits
The tail is a pseudo-element clipped into a triangle with clip-path rather than a rotated square, which keeps the bubble's background and its tail perfectly in sync during the fade — a good fit for a hint beside a form field or a label on a chart data point.

<main class="stage">
<div class="tip-wrap">
<span class="field-icon" aria-describedby="tip05">?</span>
<span class="tooltip is-demo" role="tooltip" id="tip05">Use at least 8 characters</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-out;
$color: $subject-cream; // 아이콘·말풍선 색
@include stage($stage-ink);
.tip-wrap { position: relative; }
.field-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: $color;
color: $stage-ink;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
}
.tooltip {
position: absolute;
bottom: calc(100% + 14px); // 화살표 8px + 여유 6px
left: 50%;
transform-origin: bottom center; // 규칙8: 화살표(아래변 중심) 축 — 흔들림의 회전 중심
transform: translate(-50%, 4px);
opacity: 0;
background: $color;
color: $stage-ink;
padding: 8px 14px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
&::after { // 꼬리: clip-path 로 삼각형만 남긴다
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 8px;
background: $color;
clip-path: polygon(50% 100%, 0 0, 100% 0);
}
}
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
// 등장 스프링 한 겹 — 스케일 .85 → 1.12 오버슛 → 감쇠 반동, 회전은 화살표 축으로 2.6° → -1.5° → .6° 감쇠
@keyframes tip-loop {
0% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
12% { opacity: 1; transform: translate(-50%, 0) scale(1.12) rotate(2.6deg); }
22% { opacity: 1; transform: translate(-50%, 0) scale(.95) rotate(-1.5deg); }
30% { opacity: 1; transform: translate(-50%, 0) scale(1.03) rotate(.6deg); }
36%, 55% { opacity: 1; transform: translate(-50%, 0) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
100% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo { animation: none; opacity: 1; transform: translate(-50%, 0) scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tip-wrap {
position: relative;
}
.field-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
}
.tooltip {
position: absolute;
bottom: calc(100% + 14px);
left: 50%;
transform-origin: bottom center;
transform: translate(-50%, 4px);
opacity: 0;
background: #fff7e6;
color: #17141a;
padding: 8px 14px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 8px;
background: #fff7e6;
clip-path: polygon(50% 100%, 0 0, 100% 0);
}
.tooltip.is-demo {
animation: tip-loop 2s ease-out infinite;
}
@keyframes tip-loop {
0% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: translate(-50%, 0) scale(1.12) rotate(2.6deg);
}
22% {
opacity: 1;
transform: translate(-50%, 0) scale(0.95) rotate(-1.5deg);
}
30% {
opacity: 1;
transform: translate(-50%, 0) scale(1.03) rotate(0.6deg);
}
36%, 55% {
opacity: 1;
transform: translate(-50%, 0) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: translate(-50%, 0) scale(1) rotate(0deg);
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ArrowTooltip.module.scss";
export type ArrowTooltipProps = {
text: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ArrowTooltip({
text,
duration = 200,
easing = "ease-out",
color = "#fff7e6",
}: ArrowTooltipProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.wrap} style={vars}>
<span
className={styles.icon}
tabIndex={0}
aria-describedby="arrow-tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
?
</span>
<span id="arrow-tooltip" role="tooltip" className={styles.tooltip} data-open={open}>
{text}
</span>
</span>
);
}05 · Arrow tail — Measured area 4.799% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.tooltip {
position: absolute;
bottom: calc(100% + 14px);
left: 50%;
transform: translate(-50%, 4px);
opacity: 0;
&::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 8px;
clip-path: polygon(50% 100%, 0 0, 100% 0);
}
}
06Delayed show on hover
How it works
The tooltip waits a beat before appearing, so it stays hidden during a quick pass of the pointer. Once it does appear, scale overshoots to 1.12 before a damped rotation wobble of 2.4°→-1.4°→0.5° around the bottom edge settles it.
Where it fits
The keyframes spend the first fifth of the loop at zero opacity before jumping to fully visible and holding there for a long stretch — the same shape a real animation-delay produces on hover — which suits a dense icon toolbar or the same icon repeated down every row of a list.

<main class="stage">
<div class="tip-wrap">
<button class="save-btn" type="button" aria-describedby="tip06">Save</button>
<span class="tooltip is-demo" role="tooltip" id="tip06">Changes save automatically</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기 (지연 포함)
$easing: $ease-out;
$color: $subject-ink; // 버튼 색 (노란 무대라 먹색)
@include stage($stage-yellow);
.tip-wrap { position: relative; }
.save-btn {
border: 0;
border-radius: $r-control;
padding: 12px 26px;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: $stage-yellow;
background: $color;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center; // 규칙8: 버튼 쪽 아래변 축 — 흔들림의 회전 중심
transform: translateX(-50%) scale(.85);
opacity: 0;
background: $stage-ink;
color: $stage-yellow;
padding: 8px 14px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
max-width: clamp(140px, 42vw, 220px);
white-space: nowrap; // 한글은 글자마다 줄바꿈 후보라, 없으면 세로로 한 글자씩 쪼개진다
text-align: center;
pointer-events: none;
}
// 마우스를 올려도 곧바로 뜨지 않는다 — 25%(≈500ms) 까지 숨겨 두고, 뜬 뒤엔 길게 유지한다
// 지연 뒤 등장에 스프링 한 겹 — 스케일 .85 → 1.12 오버슛 → 감쇠 반동, 회전은 아래변 축으로 2.4° → -1.4° → .5° 감쇠
@keyframes delay-loop {
0%, 20% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
28% { opacity: 1; transform: translateX(-50%) scale(1.12) rotate(2.4deg); }
37% { opacity: 1; transform: translateX(-50%) scale(.95) rotate(-1.4deg); }
45% { opacity: 1; transform: translateX(-50%) scale(1.02) rotate(.5deg); }
51%, 80% { opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
100% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
}
.tooltip.is-demo {
animation: delay-loop $duration $easing infinite;
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo { animation: none; opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tip-wrap {
position: relative;
}
.save-btn {
border: 0;
border-radius: 12px;
padding: 12px 26px;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #ffd23f;
background: #17141a;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center;
transform: translateX(-50%) scale(0.85);
opacity: 0;
background: #17141a;
color: #ffd23f;
padding: 8px 14px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
max-width: clamp(140px, 42vw, 220px);
white-space: nowrap;
text-align: center;
pointer-events: none;
}
@keyframes delay-loop {
0%, 20% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
28% {
opacity: 1;
transform: translateX(-50%) scale(1.12) rotate(2.4deg);
}
37% {
opacity: 1;
transform: translateX(-50%) scale(0.95) rotate(-1.4deg);
}
45% {
opacity: 1;
transform: translateX(-50%) scale(1.02) rotate(0.5deg);
}
51%, 80% {
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
100% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
}
.tooltip.is-demo {
animation: delay-loop 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
}import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./DelayTooltip.module.scss";
export type DelayTooltipProps = {
label: ReactNode;
text: string;
/** 호버 후 뜨기까지 기다리는 시간 (기본 400ms) */
delay?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function DelayTooltip({
label,
text,
delay = 400,
duration = 160,
easing = "ease-out",
color = "#17141a",
}: DelayTooltipProps) {
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const start = () => {
timer.current = setTimeout(() => setOpen(true), delay);
};
const cancel = () => {
if (timer.current) clearTimeout(timer.current);
setOpen(false);
};
return (
<span className={styles.wrap} style={vars}>
<button
type="button"
className={styles.btn}
aria-describedby="delay-tooltip"
onMouseEnter={start}
onMouseLeave={cancel}
onFocus={start}
onBlur={cancel}
>
{label}
</button>
<span id="delay-tooltip" role="tooltip" className={styles.tooltip} data-open={open}>
{text}
</span>
</span>
);
}06 · Delayed show on hover — Measured area 6.83% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
opacity: 0;
max-width: clamp(140px, 42vw, 220px);
white-space: nowrap;
text-align: center;
}
.tooltip.is-demo {
animation: delay-loop $duration $easing infinite;
}
@keyframes delay-loop {
0%, 20% { opacity: 0; }
40%, 80% { opacity: 1; }
100% { opacity: 0; }
}
07Show on keyboard focus
How it works
The same tooltip also appears when focus moves in with the Tab key, not only on mouse hover. Either trigger now plays the same one-shot spring — scale overshoots to 1.12, then a damped rotation wobble of 2.6°→-1.5°→0.6° settles it.
Where it fits
It pairs :focus-visible with :hover on the same sibling selector and ties the trigger to the bubble with aria-describedby, so a screen reader announces the same text a sighted mouse user would see — a fit for form label hints and any icon-only button keyboard users need to reach.

<main class="stage">
<div class="tip-wrap">
<button class="gear-btn" type="button" aria-describedby="tip07">Settings</button>
<span class="tooltip is-demo" role="tooltip" id="tip07">Also shows up on Tab focus</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-out;
$color: $subject-cream; // 버튼·툴팁 색 (주황 무대라 크림)
@include stage($stage-orange);
.tip-wrap { position: relative; }
.gear-btn {
border: 2px solid $color;
border-radius: $r-control;
padding: 10px 24px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: $subject-ink; // AA: 주황 무대 위 글자 5.50:1 (테두리는 크림 유지)
background: transparent;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center; // 규칙8: 버튼 쪽 아래변 축 — 흔들림의 회전 중심
transform: translateX(-50%) scale(.85);
opacity: 0;
background: $color;
color: $stage-orange;
padding: 8px 14px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
// 실사용: 마우스 호버뿐 아니라 :focus-visible 에도 같은 규칙이 붙는다 (버튼을 Tab 으로 옮겨도 뜬다)
.gear-btn:hover + .tooltip,
.gear-btn:focus-visible + .tooltip {
opacity: 1;
}
// 데모: 실제 포커스 없이 혼자 표시→숨김을 반복한다
// 등장에 스프링 한 겹 — 스케일 .85 → 1.12 오버슛 → 감쇠 반동, 회전은 아래변 축으로 2.6° → -1.5° → .6° 감쇠
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
@keyframes tip-loop {
0% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
12% { opacity: 1; transform: translateX(-50%) scale(1.12) rotate(2.6deg); }
22% { opacity: 1; transform: translateX(-50%) scale(.95) rotate(-1.5deg); }
30% { opacity: 1; transform: translateX(-50%) scale(1.03) rotate(.6deg); }
36%, 55% { opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
100% { opacity: 0; transform: translateX(-50%) scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo { animation: none; opacity: 1; transform: translateX(-50%) scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tip-wrap {
position: relative;
}
.gear-btn {
border: 2px solid #fff7e6;
border-radius: 12px;
padding: 10px 24px;
font-weight: 600;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: transparent;
cursor: pointer;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center;
transform: translateX(-50%) scale(0.85);
opacity: 0;
background: #fff7e6;
color: #ff4d1f;
padding: 8px 14px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
white-space: nowrap;
pointer-events: none;
}
.gear-btn:hover + .tooltip,
.gear-btn:focus-visible + .tooltip {
opacity: 1;
}
.tooltip.is-demo {
animation: tip-loop 2s ease-out infinite;
}
@keyframes tip-loop {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: translateX(-50%) scale(1.12) rotate(2.6deg);
}
22% {
opacity: 1;
transform: translateX(-50%) scale(0.95) rotate(-1.5deg);
}
30% {
opacity: 1;
transform: translateX(-50%) scale(1.03) rotate(0.6deg);
}
36%, 55% {
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: translateX(-50%) scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: translateX(-50%) scale(1) rotate(0deg);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FocusTooltip.module.scss";
export type FocusTooltipProps = {
label: ReactNode;
text: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// :hover 와 :focus-visible 을 CSS 에서 같이 걸어 뒀다 — Tab 으로 옮겨도 마우스 호버와 같은 툴팁이 뜬다.
export default function FocusTooltip({
label,
text,
duration = 200,
easing = "ease-out",
color = "#fff7e6",
}: FocusTooltipProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.wrap} style={vars}>
<button type="button" className={styles.btn} aria-describedby="focus-tooltip">
{label}
</button>
<span id="focus-tooltip" role="tooltip" className={styles.tooltip}>
{text}
</span>
</span>
);
}07 · Show on keyboard focus — Measured area 5.953% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
.gear-btn:hover + .tooltip,
.gear-btn:focus-visible + .tooltip {
opacity: 1;
}
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
@keyframes tip-loop {
0%, 100% { opacity: 0; }
20%, 55% { opacity: 1; }
80% { opacity: 0; }
}
08Chat-app style bubble
How it works
A rounded chat-bubble shape with a tail makes the tooltip feel like a message. It now overshoots to scale 1.14 before a damped rotation wobble of 3°→-1.7°→0.7° around that tail corner settles it.
Where it fits
Three corners share one border-radius while the fourth stays square, which alone reads as a speech-bubble tail before the clipped pseudo-element even joins it — reach for it in a chatbot's opening greeting or an onboarding guide callout.

<main class="stage">
<div class="chat-wrap">
<div class="avatar" aria-hidden="true">AI</div>
<p class="bubble is-demo" role="status">Hi! How can I help?</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 숨김→표시→숨김 한 주기
$easing: $ease-pop; // 등장 바운스
$color: $subject-blue; // 말풍선 색
@include stage($stage-paper);
.chat-wrap {
display: flex;
align-items: flex-end;
gap: 10px;
}
.avatar {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: $stage-ink;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
flex: none;
}
.bubble {
position: relative;
margin: 0;
max-width: clamp(160px, 46vw, 240px);
background: $color;
color: #fff;
padding: 12px 16px;
border-radius: 18px 18px 18px 4px; // 왼쪽 아래만 각져 꼬리처럼 보인다
font-size: clamp(12px, 3.4vw, 14px);
opacity: 0;
transform-origin: bottom left; // 규칙8: 꼬리 bottom left 고정 팝
transform: scale(.85);
&::after { // 말풍선 꼬리
content: "";
position: absolute;
left: -6px;
bottom: 0;
width: 12px;
height: 12px;
background: $color;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
}
.bubble.is-demo {
animation: bubble-loop $duration $easing infinite;
}
// 등장 스프링 한 겹 — 스케일 .85 → 1.14 오버슛 → 감쇠 반동(.93 → 1.02), 회전은 꼬리 축(bottom left)으로 3° → -1.7° → .7° 감쇠
@keyframes bubble-loop {
0% { opacity: 0; transform: scale(.85) rotate(0deg); }
12% { opacity: 1; transform: scale(1.14) rotate(3deg); }
22% { opacity: 1; transform: scale(.93) rotate(-1.7deg); }
30% { opacity: 1; transform: scale(1.02) rotate(.7deg); }
36%, 55% { opacity: 1; transform: scale(1) rotate(0deg); }
80% { opacity: 0; transform: scale(.85) rotate(0deg); }
100% { opacity: 0; transform: scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo { animation: none; opacity: 1; transform: scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.chat-wrap {
display: flex;
align-items: flex-end;
gap: 10px;
}
.avatar {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #17141a;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
flex: none;
}
.bubble {
position: relative;
margin: 0;
max-width: clamp(160px, 46vw, 240px);
background: #2f6df6;
color: #fff;
padding: 12px 16px;
border-radius: 18px 18px 18px 4px;
font-size: clamp(12px, 3.4vw, 14px);
opacity: 0;
transform-origin: bottom left;
transform: scale(0.85);
}
.bubble::after {
content: "";
position: absolute;
left: -6px;
bottom: 0;
width: 12px;
height: 12px;
background: #2f6df6;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
.bubble.is-demo {
animation: bubble-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bubble-loop {
0% {
opacity: 0;
transform: scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: scale(1.14) rotate(3deg);
}
22% {
opacity: 1;
transform: scale(0.93) rotate(-1.7deg);
}
30% {
opacity: 1;
transform: scale(1.02) rotate(0.7deg);
}
36%, 55% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.bubble.is-demo {
animation: none;
opacity: 1;
transform: scale(1) rotate(0deg);
}
}import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChatBubble.module.scss";
export type ChatBubbleProps = {
avatar: string;
text: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ChatBubble({
avatar,
text,
duration = 260,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ChatBubbleProps) {
const [shown, setShown] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 마운트되면 한 번 등장한다 — 채팅창에 메시지가 도착하는 흐름
useEffect(() => {
const t = setTimeout(() => setShown(true), 60);
return () => clearTimeout(t);
}, []);
return (
<span className={styles.wrap} style={vars}>
<span className={styles.avatar} aria-hidden="true">{avatar}</span>
<p className={styles.bubble} data-open={shown}>{text}</p>
</span>
);
}08 · Chat-app style bubble — Measured area 9.751% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
.bubble {
position: relative;
border-radius: 18px 18px 18px 4px;
opacity: 0;
transform-origin: bottom left;
transform: scale(.85);
&::after {
content: "";
position: absolute;
left: -6px;
bottom: 0;
width: 12px;
height: 12px;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
}
.bubble.is-demo {
animation: bubble-loop $duration $easing infinite;
}
09Info pulse dot
How it works
A small dot gently pulses to signal there is more information worth checking. The bubble's own entry overshoots to scale 1.12 before a damped rotation wobble of 2.6°→-1.5°→0.6° around the bottom edge settles it.
Where it fits
The pulse itself lives on a ::before pseudo-element that scales up while fading out behind a solid dot, so the ring and the dot never need separate elements in the markup — it works well for a new-feature badge or a help icon in a settings screen.

<main class="stage">
<div class="tip-wrap">
<span class="badge" aria-describedby="tip09">New<i class="dot" aria-hidden="true"></i></span>
<span class="tooltip is-demo" role="tooltip" id="tip09">You can download your settlement history</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 점 펄스·툴팁 공통 주기
$easing: $ease-out;
$color: $subject-blue; // 점 색
@include stage($stage-ink);
.tip-wrap { position: relative; display: inline-block; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: $r-pill;
background: $subject-cream;
color: $stage-ink;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
&::before { // 은은하게 커졌다 작아지는 펄스 링
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
background: $color;
opacity: .5;
animation: pulse-ring $duration $easing infinite;
}
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center; // 규칙8: 버튼 쪽 아래변 축 — 흔들림의 회전 중심
transform: translate(-50%, 4px);
opacity: 0;
background: $subject-cream;
color: $stage-ink;
padding: 8px 14px;
border-radius: $r-control;
font-size: clamp(12px, 3.4vw, 14px);
max-width: clamp(150px, 44vw, 220px);
white-space: nowrap; // 한글은 글자마다 줄바꿈 후보라, 없으면 세로로 한 글자씩 쪼개진다
pointer-events: none;
}
.tooltip.is-demo {
animation: tip-loop $duration $easing infinite;
}
@keyframes pulse-ring {
0%, 100% { transform: scale(.6); opacity: .5; }
50% { transform: scale(1.6); opacity: 0; }
}
// 툴팁 등장에 스프링 한 겹 — 스케일 .85 → 1.12 오버슛 → 감쇠 반동, 회전은 아래변 축으로 2.6° → -1.5° → .6° 감쇠
@keyframes tip-loop {
0% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
12% { opacity: 1; transform: translate(-50%, 0) scale(1.12) rotate(2.6deg); }
22% { opacity: 1; transform: translate(-50%, 0) scale(.95) rotate(-1.5deg); }
30% { opacity: 1; transform: translate(-50%, 0) scale(1.03) rotate(.6deg); }
36%, 55% { opacity: 1; transform: translate(-50%, 0) scale(1) rotate(0deg); }
80% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
100% { opacity: 0; transform: translate(-50%, 4px) scale(.85) rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.dot::before { animation: none; opacity: .3; }
.tooltip.is-demo { animation: none; opacity: 1; transform: translate(-50%, 0) scale(1) rotate(0deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tip-wrap {
position: relative;
display: inline-block;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 14px;
line-height: 1;
font-family: inherit;
}
.dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 50%;
background: #2f6df6;
}
.dot::before {
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
background: #2f6df6;
opacity: 0.5;
animation: pulse-ring 2s ease-out infinite;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform-origin: bottom center;
transform: translate(-50%, 4px);
opacity: 0;
background: #fff7e6;
color: #17141a;
padding: 8px 14px;
border-radius: 12px;
font-size: clamp(12px, 3.4vw, 14px);
max-width: clamp(150px, 44vw, 220px);
white-space: nowrap;
pointer-events: none;
}
.tooltip.is-demo {
animation: tip-loop 2s ease-out infinite;
}
@keyframes pulse-ring {
0%, 100% {
transform: scale(0.6);
opacity: 0.5;
}
50% {
transform: scale(1.6);
opacity: 0;
}
}
@keyframes tip-loop {
0% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
12% {
opacity: 1;
transform: translate(-50%, 0) scale(1.12) rotate(2.6deg);
}
22% {
opacity: 1;
transform: translate(-50%, 0) scale(0.95) rotate(-1.5deg);
}
30% {
opacity: 1;
transform: translate(-50%, 0) scale(1.03) rotate(0.6deg);
}
36%, 55% {
opacity: 1;
transform: translate(-50%, 0) scale(1) rotate(0deg);
}
80% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
100% {
opacity: 0;
transform: translate(-50%, 4px) scale(0.85) rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.dot::before {
animation: none;
opacity: 0.3;
}
.tooltip.is-demo {
animation: none;
opacity: 1;
transform: translate(-50%, 0) scale(1) rotate(0deg);
}
}import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./PulseDotTooltip.module.scss";
export type PulseDotTooltipProps = {
label: ReactNode;
text: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PulseDotTooltip({
label,
text,
duration = 200,
easing = "ease-out",
color = "#2f6df6",
}: PulseDotTooltipProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<span className={styles.wrap} style={vars}>
<span
className={styles.badge}
tabIndex={0}
aria-describedby="pulse-tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{label}
<i className={styles.dot} aria-hidden="true" />
</span>
<span id="pulse-tooltip" role="tooltip" className={styles.tooltip} data-open={open}>
{text}
</span>
</span>
);
}09 · Info pulse dot — Measured area 7.869% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
.dot {
position: relative;
&::before {
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
opacity: .5;
animation: pulse-ring $duration $easing infinite;
}
}
@keyframes pulse-ring {
0%, 100% { transform: scale(.6); opacity: .5; }
50% { transform: scale(1.6); opacity: 0; }
}
Where it breaks — the traps
Two of these nine hide a real gotcha behind CSS that otherwise looks correct at a glance.
The top-layer trap
Item 04's card is written with a plain top: calc(100% + 12px), which measures fine while the card sits in normal flow — but the instant the Popover API promotes it into the top layer, its containing block switches from the trigger's wrapper to the viewport itself, so that same percentage suddenly counts down from the screen edge instead of the link, and a card meant to sit 12px under a trigger lands far off the bottom of the viewport with no visible motion at all; the fix on this demo is a separate :popover-open rule with a fixed pixel offset in place of the percentage.
The quiet fallback
Item 03 fails the opposite way: browsers without anchor positioning don't throw an error, they just ignore anchor-name, position-anchor, and position-try-fallbacks and quietly fall back to whatever plain position: absolute rule sits above the @supports block, so a page tested in only one browser can ship a tooltip that never flips anywhere else.
Both fixes in the zip
Grab all nine with both fixes already wired in — unzip the archive with uauwf4hn, typed exactly as it appears on this line, no more and no less. When the backdrop behind the bubble should do as much talking as the bubble itself, the layered colors of the aurora mesh gradient kit fill that ground.
Accessibility
Reduced motion
All nine already carry a prefers-reduced-motion: reduce rule in their SCSS, and the shared choice is that the tooltip settles on its final, visible state rather than vanishing outright — a bubble that disappeared entirely under reduced motion would leave a user with no way to tell it exists at all. 01, 03, 05, 06, and 07 drop the fade-and-move keyframes and hold opacity at 1 in the shown position; 02, 04, and 08 do the same but also fix the scale at 1 instead of freezing mid-pop. 09 is the one exception worth calling out on its own: the pulsing ring around the dot settles at a dim, static opacity instead of stopping mid-scale, because a ring frozen at full size would read as a solid halo rather than a badge worth noticing.
Screen readers
Every demo also keeps its trigger paired with aria-describedby and marks the bubble role="tooltip", so a screen reader carries the same text a fading bubble or a pulsing ring only hints at visually. MDN documents the prefers-reduced-motion media feature and the operating-system settings that switch it on.
@media (prefers-reduced-motion: reduce) {
.tooltip.is-demo,
.card,
.bubble.is-demo,
.dot::before {
animation: none !important;
}
}
More hover-and-focus patterns like these sit in the CSS category hub, and what actually ships inside a zip here — versus what doesn't — is laid out on the about page.
FAQ
Is a css tooltip bubble different from the browser's built-in title tooltip?
Yes. The native title attribute tooltip is drawn by the browser itself — it can't be styled, it shows only after a fixed hover delay the browser controls, and it stays invisible to keyboard and touch users entirely. A css tooltip bubble is markup and CSS you own, so it can carry a tail, a color, a fade, and — as items 03 and 07 above show — its own anchor logic and its own focus handling.
Does a css tooltip bubble work on touch screens, or only with a mouse?
Hover alone never fires on a touchscreen, so a bubble that only listens for :hover stays hidden there. Pairing the trigger with a tap handler that toggles the same class the demos use for .is-demo, or leaning on the :focus-visible pattern from item 07, gets the same bubble to open on a tap without writing a second version of it. Growing item 08's chat-style bubble into a conversation that keeps going is the move the CSS chat bubble animations take next.
Tooltip vs popover — do I need the Popover API, or is absolute positioning enough?
Plain position: absolute is enough whenever the tooltip only needs to sit inside its own card or section, which covers items 01, 02, 03, 05, 06, 07, 08, and 09 above. Reach for the Popover API, as item 04 does, only when the bubble has to escape a parent with overflow: hidden or sit visually above everything else on the page, since that is what promoting it into the top layer actually buys.









