04An Arrow That Keeps Pointing at the Anchor While the Body Shifts
Even when the popover body is pushed along an edge, only the arrow is interpolated toward the anchor center, so tail and body move independently.
Best forInput hints on narrow screens
ARROW_LERPEDGE_PADARROW_SIZESHIFT_MAX

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<i class="pm__line" data-r="line"></i>
<div class="pm__pop" data-r="pop"><i class="pm__arrow" data-r="arrow"></i><span class="pm__txt">Check the email address</span></div>
<button class="pm__anchor" data-r="anchor" type="button" aria-label="Anchor, move with left and right keys"></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 팝오버 몸통은 가장자리에 막혀 밀리지만 화살표 끝은 앵커 중심을 보간으로 쫓아가 항상 앵커를 가리킨다.
@include stage($stage-paper);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-ink, .07);
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: rgba($subject-ink, .72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.pm { touch-action: none; }
$accent: $subject-lilac;
.pm__line {
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 1px;
transform-origin: 0 0;
background: rgba($subject-ink, .3);
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-pink;
box-shadow: 0 0 0 4px rgba($subject-pink, .4), $shadow-press;
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
align-items: center;
width: 168px;
height: 56px;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $accent;
color: $subject-ink;
box-shadow: $shadow-raised;
z-index: 4;
}
.pm__arrow {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
border-radius: $r-xs;
background: $accent;
}
.pm__txt { position: relative; word-break: keep-all; }
@media (prefers-reduced-motion: reduce) {
.pm__anchor { cursor: default; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.pm {
touch-action: none;
}
.pm__line {
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 1px;
transform-origin: 0 0;
background: rgba(23, 20, 26, 0.3);
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: #ff9ebb;
box-shadow: 0 0 0 4px rgba(255, 158, 187, 0.4), 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
align-items: center;
width: 168px;
height: 56px;
padding: 8px 12px;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 4;
}
.pm__arrow {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
border-radius: 4px;
background: #b9a0ff;
}
.pm__txt {
position: relative;
word-break: keep-all;
}
@media (prefers-reduced-motion: reduce) {
.pm__anchor {
cursor: default;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo() {
if (!demo)
return;
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
live.setAttribute("aria-live", "polite");
}
if (reduce)
live.setAttribute("aria-live", "polite");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 몸통은 EDGE_PAD 에서 막혀 앵커보다 최대 SHIFT_MAX 만큼 덜 가고, 화살표 끝은 따로 ARROW_LERP 로 앵커 중심을 쫓는다. 꼬리와 몸통이 서로 다른 속도로 움직인다.
const ARROW_LERP = 0.15;
const EDGE_PAD = 8;
const ARROW_SIZE = 8;
const SHIFT_MAX = 72;
const BODY_W = 168;
const BODY_H = 56;
const BODY_LERP = 0.35;
const ANCHOR_R = 14;
const GAP = 12;
const SWEEP_MS = 2400;
const anchor = q("[data-r=anchor]");
const pop = q("[data-r=pop]");
const arrow = q("[data-r=arrow]");
const line = q("[data-r=line]");
let nx = reduce ? 0.94 : 0.5;
let bodyX = 0;
let tip = 0;
let placed = false;
let dragging = false;
let shift = 0;
function axOf(w) {
return clamp(nx * w, EDGE_PAD + BODY_W / 2 - SHIFT_MAX, w - EDGE_PAD - BODY_W / 2 + SHIFT_MAX);
}
function bodyTarget(w, ax) {
const desired = ax - BODY_W / 2;
const clamped = clamp(desired, EDGE_PAD, w - EDGE_PAD - BODY_W);
shift = clamp(clamped - desired, -SHIFT_MAX, SHIFT_MAX);
return desired + shift;
}
function tick(ms) {
if (demo)
nx = 0.5 + 0.47 * Math.sin((Math.max(0, ms - 200) / SWEEP_MS) * Math.PI * 2);
const w = root.clientWidth;
const ax = axOf(w);
if (!placed)
snap();
bodyX += (bodyTarget(w, ax) - bodyX) * BODY_LERP;
tip += (ax - tip) * ARROW_LERP;
}
function snap() {
const w = root.clientWidth;
const ax = axOf(w);
bodyX = bodyTarget(w, ax);
tip = ax;
placed = true;
}
function render() {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = axOf(w);
const ay = h * 0.3;
const by = ay + ANCHOR_R + GAP;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + bodyX.toFixed(1) + "px," + by.toFixed(1) + "px,0)";
const rel = clamp(tip - bodyX, ARROW_SIZE + 6, BODY_W - ARROW_SIZE - 6);
arrow.style.transform = "translate3d(" + (rel - ARROW_SIZE).toFixed(1) + "px," + (-ARROW_SIZE).toFixed(1) + "px,0) rotate(45deg)";
line.style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px," + (ay + ANCHOR_R).toFixed(1) + "px,0) scale(1," + (by - ay - ANCHOR_R).toFixed(1) + ")";
say("shift " + Math.round(shift) + " gap " + Math.round(Math.abs(tip - ax)));
}
function moveTo(e) {
nx = clamp(local(e).x / root.clientWidth, 0.04, 0.96);
sync();
}
on(root, "pointerdown", (e) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e) => {
if (dragging)
moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (dx === 0)
return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.04, 0.96);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// ArrowTracksAnchor - vanilla 판(04-pm-arrow-tracks-anchor) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ArrowTracksAnchor.module.scss";
export default function ArrowTracksAnchor() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
live.setAttribute("aria-live", "polite");
}
if (reduce) live.setAttribute("aria-live", "polite");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 몸통은 EDGE_PAD 에서 막혀 앵커보다 최대 SHIFT_MAX 만큼 덜 가고, 화살표 끝은 따로 ARROW_LERP 로 앵커 중심을 쫓는다. 꼬리와 몸통이 서로 다른 속도로 움직인다.
const ARROW_LERP = 0.15;
const EDGE_PAD = 8;
const ARROW_SIZE = 8;
const SHIFT_MAX = 72;
const BODY_W = 168;
const BODY_H = 56;
const BODY_LERP = 0.35;
const ANCHOR_R = 14;
const GAP = 12;
const SWEEP_MS = 2400;
const anchor = q<HTMLElement>("[data-r=anchor]");
const pop = q<HTMLElement>("[data-r=pop]");
const arrow = q<HTMLElement>("[data-r=arrow]");
const line = q<HTMLElement>("[data-r=line]");
let nx = reduce ? 0.94 : 0.5;
let bodyX = 0;
let tip = 0;
let placed = false;
let dragging = false;
let shift = 0;
function axOf(w: number): number {
return clamp(nx * w, EDGE_PAD + BODY_W / 2 - SHIFT_MAX, w - EDGE_PAD - BODY_W / 2 + SHIFT_MAX);
}
function bodyTarget(w: number, ax: number): number {
const desired = ax - BODY_W / 2;
const clamped = clamp(desired, EDGE_PAD, w - EDGE_PAD - BODY_W);
shift = clamp(clamped - desired, -SHIFT_MAX, SHIFT_MAX);
return desired + shift;
}
function tick(ms: number): void {
if (demo) nx = 0.5 + 0.47 * Math.sin((Math.max(0, ms - 200) / SWEEP_MS) * Math.PI * 2);
const w = root.clientWidth;
const ax = axOf(w);
if (!placed) snap();
bodyX += (bodyTarget(w, ax) - bodyX) * BODY_LERP;
tip += (ax - tip) * ARROW_LERP;
}
function snap(): void {
const w = root.clientWidth;
const ax = axOf(w);
bodyX = bodyTarget(w, ax);
tip = ax;
placed = true;
}
function render(): void {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = axOf(w);
const ay = h * 0.3;
const by = ay + ANCHOR_R + GAP;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + bodyX.toFixed(1) + "px," + by.toFixed(1) + "px,0)";
const rel = clamp(tip - bodyX, ARROW_SIZE + 6, BODY_W - ARROW_SIZE - 6);
arrow.style.transform = "translate3d(" + (rel - ARROW_SIZE).toFixed(1) + "px," + (-ARROW_SIZE).toFixed(1) + "px,0) rotate(45deg)";
line.style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px," + (ay + ANCHOR_R).toFixed(1) + "px,0) scale(1," + (by - ay - ANCHOR_R).toFixed(1) + ")";
say("shift " + Math.round(shift) + " gap " + Math.round(Math.abs(tip - ax)));
}
function moveTo(e: PointerEvent): void {
nx = clamp(local(e).x / root.clientWidth, 0.04, 0.96);
sync();
}
on(root, "pointerdown", (e: PointerEvent) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e: PointerEvent) => {
if (dragging) moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e: KeyboardEvent) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (dx === 0) return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.04, 0.96);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<i className={styles["pm__line"]} data-r="line"></i>
<div className={styles["pm__pop"]} data-r="pop"><i className={styles["pm__arrow"]} data-r="arrow"></i><span className={styles["pm__txt"]}>이메일 주소를 확인하세요</span></div>
<button className={styles["pm__anchor"]} data-r="anchor" type="button" aria-label="앵커 · 좌우 방향키로 옮기기"></button>
</div>
</main>
);
}04 An Arrow That Keeps Pointing at the Anchor While the Body Shifts — Measured area 36.272% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Popover: 9 Ways to Anchor, Flip and Spring
The post this demo lives in
CSS Popover: 9 Ways to Anchor, Flip and SpringA css popover can clip at screen edges, point its arrow the wrong way and overlap its neighbors — scenes 01, 04 and 09 f See all demos in this post
If you like this effect - similar demos
Interactions04 3D Gears That Mesh at Their Tooth Ratio While the Pointer Moves the ViewJavaScript 3D Animation: 9 Pointer-Led Models
Interactions05 A Puddle Dragged by the Magnet While Tension Keeps It RoundFerrofluid Simulation: 9 Canvas Scenes with Spikes
Interactions01 Product mockup that turns with the pointer3D Hover Effect CSS: 9 Pointer-Following Scenes