01Follow basics — the lagged chase
One lerp line moves the element a fraction of the gap each frame, so it trails the cursor by a beat; three ghosts expose that lag as a frame difference.
Best forThe base of every chase effect — badges, tooltips
pointermoverequestAnimationFrametranslate3d

<main class="stage">
<div class="lb is-demo" id="lb">
<div class="lb__field" role="img" aria-label="A blue dot and three ghosts chase a small cursor ring, one beat behind">
<span class="lb__ghost lb__ghost--3" aria-hidden="true"></span>
<span class="lb__ghost lb__ghost--2" aria-hidden="true"></span>
<span class="lb__ghost lb__ghost--1" aria-hidden="true"></span>
<span class="lb__dot" id="lbDot" aria-hidden="true"></span>
<span class="lb__cursor" id="lbCursor" aria-hidden="true"></span>
</div>
<p class="lb__hint">Move the mouse and the dot trails a beat behind</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 따라다니기 기본 — 실제 추적은 JS 가 남은 거리의 16% 만 매 프레임 더 가는 lerp 로 하고,
// 잔상 세 개는 점을 다시 쫓게 해 지연을 프레임 차이로 보여 준다.
// 격자 미리보기(.is-demo)는 커서 고리가 무대를 가로질렀다 되돌아오는 길을 @keyframes 로 움직이고,
// 점·잔상은 같은 길을 뒤처진 백분율로 따라간다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.lb { display: grid; justify-items: center; gap: $sp-4; width: 100%; }
.lb__field {
position: relative;
width: 100%; height: clamp(120px, 34vh, 180px);
}
// 커서를 뜻하는 고리 — 실제 포인터 자리를 표시한다
.lb__cursor {
position: absolute; left: 0; top: 0;
width: 18px; height: 18px; box-sizing: border-box;
border: 3px solid $color; border-radius: 50%;
will-change: transform;
}
.lb__dot {
position: absolute; left: 0; top: 0;
width: 14px; height: 14px;
background: $subject-blue-light; border-radius: 50%;
will-change: transform;
}
// 잔상 — 점이 지나온 자리를 알파로만 표시한다 (글자가 아니라 도형이라 알파를 쓴다)
.lb__ghost {
position: absolute; left: 0; top: 0;
width: 10px; height: 10px; border-radius: 50%;
background: $color;
will-change: transform;
}
.lb__ghost--1 { opacity: .55; }
.lb__ghost--2 { opacity: .38; }
.lb__ghost--3 { opacity: .24; }
.lb__hint { margin: 0; color: $color; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: 커서 고리가 오른쪽 끝까지 갔다가 아래 왼쪽으로 돌아온다.
// 점과 잔상은 같은 경로를 뒤처진 키프레임 위치로 따라가 지연이 그대로 보인다.
.lb.is-demo .lb__cursor,
.lb.is-demo .lb__dot,
.lb.is-demo .lb__ghost {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lb.is-demo .lb__cursor { animation-name: lbCursor; }
.lb.is-demo .lb__dot { animation-name: lbDot; }
.lb.is-demo .lb__ghost--1 { animation-name: lbG1; }
.lb.is-demo .lb__ghost--2 { animation-name: lbG2; }
.lb.is-demo .lb__ghost--3 { animation-name: lbG3; }
@keyframes lbCursor {
0%, 4% { transform: translate3d(24px, 20px, 0); }
40%, 48% { transform: translate3d(240px, 26px, 0); }
58%, 64% { transform: translate3d(236px, 86px, 0); }
90%, 100% { transform: translate3d(24px, 20px, 0); }
}
@keyframes lbDot {
0%, 12% { transform: translate3d(24px, 20px, 0); }
46%, 54% { transform: translate3d(206px, 40px, 0); }
64%, 72% { transform: translate3d(192px, 72px, 0); }
94%, 100% { transform: translate3d(24px, 20px, 0); }
}
@keyframes lbG1 {
0%, 20% { transform: translate3d(24px, 20px, 0); }
52%, 60% { transform: translate3d(172px, 52px, 0); }
72%, 80% { transform: translate3d(156px, 60px, 0); }
96%, 100% { transform: translate3d(24px, 20px, 0); }
}
@keyframes lbG2 {
0%, 28% { transform: translate3d(24px, 20px, 0); }
58%, 66% { transform: translate3d(138px, 62px, 0); }
78%, 84% { transform: translate3d(126px, 46px, 0); }
98%, 100% { transform: translate3d(24px, 20px, 0); }
}
@keyframes lbG3 {
0%, 36% { transform: translate3d(24px, 20px, 0); }
64%, 72% { transform: translate3d(104px, 70px, 0); }
82%, 88% { transform: translate3d(96px, 34px, 0); }
98%, 100% { transform: translate3d(24px, 20px, 0); }
}
@media (max-width: 340px) {
.lb { gap: $sp-2; }
.lb__field { height: clamp(100px, 30vh, 140px); }
}
@media (prefers-reduced-motion: reduce) {
.lb.is-demo .lb__cursor,
.lb.is-demo .lb__dot,
.lb.is-demo .lb__ghost { animation: none !important; }
// reduce 에서는 네 요소가 가운데 한 줄로 늘어선 정지 상태를 보인다
.lb.is-demo .lb__cursor { transform: translate3d(60px, 20px, 0) !important; }
.lb.is-demo .lb__dot { transform: translate3d(96px, 24px, 0) !important; }
.lb.is-demo .lb__ghost--1 { transform: translate3d(132px, 28px, 0) !important; }
.lb.is-demo .lb__ghost--2 { transform: translate3d(168px, 32px, 0) !important; }
.lb.is-demo .lb__ghost--3 { transform: translate3d(204px, 36px, 0) !important; }
}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);
}
.lb {
display: grid;
justify-items: center;
gap: 16px;
width: 100%;
}
.lb__field {
position: relative;
width: 100%;
height: clamp(120px, 34vh, 180px);
}
.lb__cursor {
position: absolute;
left: 0;
top: 0;
width: 18px;
height: 18px;
box-sizing: border-box;
border: 3px solid #fff7e6;
border-radius: 50%;
will-change: transform;
}
.lb__dot {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
background: #5b8cf8;
border-radius: 50%;
will-change: transform;
}
.lb__ghost {
position: absolute;
left: 0;
top: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff7e6;
will-change: transform;
}
.lb__ghost--1 {
opacity: 0.55;
}
.lb__ghost--2 {
opacity: 0.38;
}
.lb__ghost--3 {
opacity: 0.24;
}
.lb__hint {
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.lb.is-demo .lb__cursor,
.lb.is-demo .lb__dot,
.lb.is-demo .lb__ghost {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lb.is-demo .lb__cursor {
animation-name: lbCursor;
}
.lb.is-demo .lb__dot {
animation-name: lbDot;
}
.lb.is-demo .lb__ghost--1 {
animation-name: lbG1;
}
.lb.is-demo .lb__ghost--2 {
animation-name: lbG2;
}
.lb.is-demo .lb__ghost--3 {
animation-name: lbG3;
}
@keyframes lbCursor {
0%, 4% {
transform: translate3d(24px, 20px, 0);
}
40%, 48% {
transform: translate3d(240px, 26px, 0);
}
58%, 64% {
transform: translate3d(236px, 86px, 0);
}
90%, 100% {
transform: translate3d(24px, 20px, 0);
}
}
@keyframes lbDot {
0%, 12% {
transform: translate3d(24px, 20px, 0);
}
46%, 54% {
transform: translate3d(206px, 40px, 0);
}
64%, 72% {
transform: translate3d(192px, 72px, 0);
}
94%, 100% {
transform: translate3d(24px, 20px, 0);
}
}
@keyframes lbG1 {
0%, 20% {
transform: translate3d(24px, 20px, 0);
}
52%, 60% {
transform: translate3d(172px, 52px, 0);
}
72%, 80% {
transform: translate3d(156px, 60px, 0);
}
96%, 100% {
transform: translate3d(24px, 20px, 0);
}
}
@keyframes lbG2 {
0%, 28% {
transform: translate3d(24px, 20px, 0);
}
58%, 66% {
transform: translate3d(138px, 62px, 0);
}
78%, 84% {
transform: translate3d(126px, 46px, 0);
}
98%, 100% {
transform: translate3d(24px, 20px, 0);
}
}
@keyframes lbG3 {
0%, 36% {
transform: translate3d(24px, 20px, 0);
}
64%, 72% {
transform: translate3d(104px, 70px, 0);
}
82%, 88% {
transform: translate3d(96px, 34px, 0);
}
98%, 100% {
transform: translate3d(24px, 20px, 0);
}
}
@media (max-width: 340px) {
.lb {
gap: 8px;
}
.lb__field {
height: clamp(100px, 30vh, 140px);
}
}
@media (prefers-reduced-motion: reduce) {
.lb.is-demo .lb__cursor,
.lb.is-demo .lb__dot,
.lb.is-demo .lb__ghost {
animation: none !important;
}
.lb.is-demo .lb__cursor {
transform: translate3d(60px, 20px, 0) !important;
}
.lb.is-demo .lb__dot {
transform: translate3d(96px, 24px, 0) !important;
}
.lb.is-demo .lb__ghost--1 {
transform: translate3d(132px, 28px, 0) !important;
}
.lb.is-demo .lb__ghost--2 {
transform: translate3d(168px, 32px, 0) !important;
}
.lb.is-demo .lb__ghost--3 {
transform: translate3d(204px, 36px, 0) !important;
}
}var root = document.getElementById('lb');
var field = root.querySelector('.lb__field');
var cursorEl = document.getElementById('lbCursor');
var dotEl = document.getElementById('lbDot');
var ghosts = Array.prototype.slice.call(root.querySelectorAll('.lb__ghost')).reverse();
// ghosts[0] 이 점 바로 뒤, ghosts[2] 가 가장 느리다 (DOM 역순으로 담았다)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// lerp: 매 프레임 남은 거리의 일부만 더 가는 선형 보간 — factor 가 작을수록 한 박자 늦게 따라온다
function lerp(a, b, k) { return a + (b - a) * k; }
var target = { x: 0, y: 0 };
var dot = { x: 0, y: 0 };
var trail = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
var factors = [0.10, 0.065, 0.04];
function center() {
var r = field.getBoundingClientRect();
return { x: r.width / 2, y: r.height / 2 };
}
function setTarget(cx, cy) {
var r = field.getBoundingClientRect();
target.x = cx - r.left;
target.y = cy - r.top;
}
function draw() {
cursorEl.style.transform = 'translate3d(' + (target.x - 9) + 'px,' + (target.y - 9) + 'px,0)';
dotEl.style.transform = 'translate3d(' + (dot.x - 7) + 'px,' + (dot.y - 7) + 'px,0)';
for (var i = 0; i < trail.length; i++) {
ghosts[i].style.transform = 'translate3d(' + (trail[i].x - 5) + 'px,' + (trail[i].y - 5) + 'px,0)';
}
}
function tick() {
dot.x = lerp(dot.x, target.x, 0.16);
dot.y = lerp(dot.y, target.y, 0.16);
var prev = dot;
for (var i = 0; i < trail.length; i++) {
trail[i].x = lerp(trail[i].x, prev.x, factors[i]);
trail[i].y = lerp(trail[i].y, prev.y, factors[i]);
prev = trail[i];
}
draw();
requestAnimationFrame(tick);
}
function start() {
var c = center();
target = { x: c.x, y: c.y };
dot = { x: c.x, y: c.y };
trail = [{ x: c.x, y: c.y }, { x: c.x, y: c.y }, { x: c.x, y: c.y }];
}
window.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
setTarget(e.clientX, e.clientY);
});
// 터치 폴백: 손가락을 대면 그 지점을 쫓게 한다
window.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
setTarget(e.clientX, e.clientY);
});
start();
if (reduce) {
// 움직임을 줄이는 설정에서는 쫓기 없이 즉시 따라붙는다
window.addEventListener('pointermove', function (e) { setTarget(e.clientX, e.clientY); dot = { x: target.x, y: target.y }; draw(); });
} else {
requestAnimationFrame(tick);
}// LerpBasics — vanilla 판(run/079/demo/01-follow-lerp-basics/index.html) 의 React 포트.
// 점이 커서를 lerp(0.16)로 쫓고, 잔상 세 개는 다시 점을 쫓게 해 지연을 프레임 차이로 보여 준다.
// 계수(0.16 · 0.10/0.065/0.04)와 잔상 순서는 vanilla 와 같은 값이다 (다시 타이핑하지 않는다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LerpBasics.module.scss";
export type LerpBasicsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 전역 DOM 타입에 기대지 않는다 — 포인터 이벤트에서 실제로 읽는 것만 적는다
type PtrEvt = { clientX: number; clientY: number };
const FIELD_LABEL = "작은 커서 고리를 파란 점과 잔상 세 개가 한 박자 늦게 쫓는 그림";
const HINT = "마우스를 움직이면 점이 한 박자 늦게 따라옵니다";
// ghosts[0] 이 점 바로 뒤, ghosts[2] 가 가장 느리다 — 잔상 DOM 은 역순으로 섰다
const TRAIL_FACTORS = [0.10, 0.065, 0.04];
// lerp: 매 프레임 남은 거리의 일부만 더 가는 선형 보간 — factor 가 작을수록 한 박자 늦게 따라온다
function lerp(a: number, b: number, k: number): number {
return a + (b - a) * k;
}
export default function LerpBasics({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: LerpBasicsProps) {
const fieldRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement>;
const cursorRef = useRef<HTMLSpanElement | null>(null);
const dotRef = useRef<HTMLSpanElement | null>(null);
const ghostRef = useRef<(HTMLSpanElement | null)[]>(new Array(3).fill(null)) as Box<(HTMLSpanElement | null)[]>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function setGhost(index: number) {
return (el: HTMLSpanElement | null): void => {
ghostRef.current[index] = el;
};
}
useEffect(() => {
const field = fieldRef.current;
if (!field) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const target = { x: 0, y: 0 };
const dot = { x: 0, y: 0 };
const trail = [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
];
function setTarget(cx: number, cy: number): void {
const r = field.getBoundingClientRect();
target.x = cx - r.left;
target.y = cy - r.top;
}
function draw(): void {
const cursor = cursorRef.current;
const dotEl = dotRef.current;
if (cursor) cursor.style.transform = "translate3d(" + (target.x - 9) + "px," + (target.y - 9) + "px,0)";
if (dotEl) dotEl.style.transform = "translate3d(" + (dot.x - 7) + "px," + (dot.y - 7) + "px,0)";
for (let i = 0; i < trail.length; i++) {
const ghost = ghostRef.current[i];
if (ghost) ghost.style.transform = "translate3d(" + (trail[i].x - 5) + "px," + (trail[i].y - 5) + "px,0)";
}
}
function tick(): void {
dot.x = lerp(dot.x, target.x, 0.16);
dot.y = lerp(dot.y, target.y, 0.16);
let prev = dot;
for (let i = 0; i < trail.length; i++) {
trail[i].x = lerp(trail[i].x, prev.x, TRAIL_FACTORS[i]);
trail[i].y = lerp(trail[i].y, prev.y, TRAIL_FACTORS[i]);
prev = trail[i];
}
draw();
raf = requestAnimationFrame(tick);
}
function start(): void {
const r = field.getBoundingClientRect();
const c = { x: r.width / 2, y: r.height / 2 };
target.x = c.x;
target.y = c.y;
dot.x = c.x;
dot.y = c.y;
for (let i = 0; i < trail.length; i++) {
trail[i].x = c.x;
trail[i].y = c.y;
}
}
function onMove(event: PtrEvt): void {
setDemo(false);
setTarget(event.clientX, event.clientY);
if (reduce) {
// 움직임을 줄이는 설정에서는 쫓기 없이 즉시 따라붙는다
dot.x = target.x;
dot.y = target.y;
draw();
}
}
// 터치 폴백: 손가락을 대면 그 지점을 쫓게 한다
function onDown(event: PtrEvt): void {
setDemo(false);
setTarget(event.clientX, event.clientY);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerdown", onDown);
let raf = 0;
start();
if (!reduce) raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerdown", onDown);
cancelAnimationFrame(raf);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.field} ref={fieldRef} role="img" aria-label={FIELD_LABEL}>
<span className={styles.ghost + " " + styles.ghost3} aria-hidden="true" ref={setGhost(2)}></span>
<span className={styles.ghost + " " + styles.ghost2} aria-hidden="true" ref={setGhost(1)}></span>
<span className={styles.ghost + " " + styles.ghost1} aria-hidden="true" ref={setGhost(0)}></span>
<span className={styles.dot} aria-hidden="true" ref={dotRef}></span>
<span className={styles.cursor} aria-hidden="true" ref={cursorRef}></span>
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}01 Follow basics — the lagged chase — Measured area 3.684% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Mouse Follow Effect CSS — 9 Cursor Chasers
The post this demo lives in
Mouse Follow Effect CSS — 9 Cursor ChasersA mouse follow effect css reads the pointer each frame and lets an element trail it by a beat — the core is one lerp lin See all demos in this post
If you like this effect - similar demos
Interactions05 Pointer parallax — the math behind a diorama that gently followsIsometric CSS: 9 True-Projection 3D Dioramas
Interactions04 Character whose eyes chase the cursor3D Hover Effect CSS: 9 Pointer-Following Scenes
Interactions07 Debounced search — a search that fires once a wait value finishes counting down from the last inputMicro Interactions: 9 Essential UI Patterns