04Letters that follow — a text train
Split a word into letters and let each chase the one ahead with lerp: the letters queue up behind the cursor like a train, one beat apart.
Best forHero typography, event copy
pointermovewill-changetranslate3d

<main class="stage">
<div class="tt is-demo" id="tt">
<div class="tt__field" role="img" aria-label="The letters of HELLO queue up behind the cursor, one beat apart">
<span class="tt__ch">H</span>
<span class="tt__ch">E</span>
<span class="tt__ch">L</span>
<span class="tt__ch">L</span>
<span class="tt__ch">O</span>
</div>
<p class="tt__hint">Move the mouse and the letters follow in a line</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 따라 다니는 글씨들 — 첫 글자는 커서를, 뒤 글자는 앞 글자를 lerp 로 쫓게 한다(계수 0.22 → 0.07).
// 격자 미리보기(.is-demo)는 같은 물결 경로를 글자마다 animation-delay 로 한 박자씩 어긋나게 해
// 기차가 밀려오는 모양을 보여 준다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.tt { display: grid; justify-items: center; gap: $sp-3; width: 100%; }
.tt__field {
position: relative;
width: 100%; height: clamp(110px, 32vh, 160px);
border: 3px solid $color; border-radius: $r-card;
background: $subject-cream;
}
.tt__ch {
position: absolute; left: 0; top: 0;
width: 28px; height: 40px;
color: $color;
font-weight: 800; font-size: 28px; line-height: 40px;
text-align: center;
will-change: transform;
}
// 글자마다 색을 조금씩 달리해 줄이 얼마나 길어지는지 보기 쉽게 한다
.tt__ch:nth-child(1) { color: $color; }
.tt__ch:nth-child(2) { color: rgba($color, .84); }
.tt__ch:nth-child(3) { color: rgba($color, .72); }
.tt__ch:nth-child(4) { color: rgba($color, .72); }
.tt__ch:nth-child(5) { color: rgba($color, .84); }
.tt__hint { margin: 0; color: $color; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: 큰 물결을 글자가 차례로 지나간다. delay 로 한 박자씩 밀린다.
// 같은 경로를 쓰므로 글자마다 시작점(left/top)을 비틀어 덩리로 겹치지 않게 한다
.tt.is-demo .tt__ch {
animation-name: ttWave;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tt.is-demo .tt__ch:nth-child(1) { animation-delay: 0ms; }
.tt.is-demo .tt__ch:nth-child(2) { animation-delay: 110ms; left: 24px; top: 12px; }
.tt.is-demo .tt__ch:nth-child(3) { animation-delay: 220ms; left: 48px; top: 24px; }
.tt.is-demo .tt__ch:nth-child(4) { animation-delay: 330ms; left: 72px; top: 4px; }
.tt.is-demo .tt__ch:nth-child(5) { animation-delay: 440ms; left: 104px; top: 22px; }
@keyframes ttWave {
0% { transform: translate3d(36px, 96px, 0); }
38%, 46% { transform: translate3d(196px, 30px, 0); }
60%, 68% { transform: translate3d(120px, 72px, 0); }
86%, 100% { transform: translate3d(36px, 96px, 0); }
}
@media (max-width: 340px) {
.tt { gap: $sp-2; }
.tt__field { height: clamp(96px, 28vh, 130px); }
.tt__ch { width: 22px; height: 32px; font-size: 22px; line-height: 32px; }
.tt.is-demo .tt__ch { animation-name: ttWaveNarrow; }
}
@keyframes ttWaveNarrow {
0% { transform: translate3d(18px, 76px, 0); }
38%, 46% { transform: translate3d(140px, 26px, 0); }
60%, 68% { transform: translate3d(88px, 58px, 0); }
86%, 100% { transform: translate3d(18px, 76px, 0); }
}
@media (prefers-reduced-motion: reduce) {
.tt.is-demo .tt__ch { animation: none !important; }
// reduce 에서는 글자 다섯 개가 가로로 늘어선 정지 상태를 보인다
.tt.is-demo .tt__ch:nth-child(1) { transform: translate3d(64px, 56px, 0) !important; }
.tt.is-demo .tt__ch:nth-child(2) { transform: translate3d(100px, 56px, 0) !important; }
.tt.is-demo .tt__ch:nth-child(3) { transform: translate3d(136px, 56px, 0) !important; }
.tt.is-demo .tt__ch:nth-child(4) { transform: translate3d(172px, 56px, 0) !important; }
.tt.is-demo .tt__ch:nth-child(5) { transform: translate3d(208px, 56px, 0) !important; }
}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);
}
.tt {
display: grid;
justify-items: center;
gap: 12px;
width: 100%;
}
.tt__field {
position: relative;
width: 100%;
height: clamp(110px, 32vh, 160px);
border: 3px solid #17141a;
border-radius: 16px;
background: #fff7e6;
}
.tt__ch {
position: absolute;
left: 0;
top: 0;
width: 28px;
height: 40px;
color: #17141a;
font-weight: 800;
font-size: 28px;
line-height: 40px;
text-align: center;
will-change: transform;
}
.tt__ch:nth-child(1) {
color: #17141a;
}
.tt__ch:nth-child(2) {
color: rgba(23, 20, 26, 0.84);
}
.tt__ch:nth-child(3) {
color: rgba(23, 20, 26, 0.72);
}
.tt__ch:nth-child(4) {
color: rgba(23, 20, 26, 0.72);
}
.tt__ch:nth-child(5) {
color: rgba(23, 20, 26, 0.84);
}
.tt__hint {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.tt.is-demo .tt__ch {
animation-name: ttWave;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tt.is-demo .tt__ch:nth-child(1) {
animation-delay: 0ms;
}
.tt.is-demo .tt__ch:nth-child(2) {
animation-delay: 110ms;
left: 24px;
top: 12px;
}
.tt.is-demo .tt__ch:nth-child(3) {
animation-delay: 220ms;
left: 48px;
top: 24px;
}
.tt.is-demo .tt__ch:nth-child(4) {
animation-delay: 330ms;
left: 72px;
top: 4px;
}
.tt.is-demo .tt__ch:nth-child(5) {
animation-delay: 440ms;
left: 104px;
top: 22px;
}
@keyframes ttWave {
0% {
transform: translate3d(36px, 96px, 0);
}
38%, 46% {
transform: translate3d(196px, 30px, 0);
}
60%, 68% {
transform: translate3d(120px, 72px, 0);
}
86%, 100% {
transform: translate3d(36px, 96px, 0);
}
}
@media (max-width: 340px) {
.tt {
gap: 8px;
}
.tt__field {
height: clamp(96px, 28vh, 130px);
}
.tt__ch {
width: 22px;
height: 32px;
font-size: 22px;
line-height: 32px;
}
.tt.is-demo .tt__ch {
animation-name: ttWaveNarrow;
}
}
@keyframes ttWaveNarrow {
0% {
transform: translate3d(18px, 76px, 0);
}
38%, 46% {
transform: translate3d(140px, 26px, 0);
}
60%, 68% {
transform: translate3d(88px, 58px, 0);
}
86%, 100% {
transform: translate3d(18px, 76px, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.tt.is-demo .tt__ch {
animation: none !important;
}
.tt.is-demo .tt__ch:nth-child(1) {
transform: translate3d(64px, 56px, 0) !important;
}
.tt.is-demo .tt__ch:nth-child(2) {
transform: translate3d(100px, 56px, 0) !important;
}
.tt.is-demo .tt__ch:nth-child(3) {
transform: translate3d(136px, 56px, 0) !important;
}
.tt.is-demo .tt__ch:nth-child(4) {
transform: translate3d(172px, 56px, 0) !important;
}
.tt.is-demo .tt__ch:nth-child(5) {
transform: translate3d(208px, 56px, 0) !important;
}
}var root = document.getElementById('tt');
var field = root.querySelector('.tt__field');
var chars = Array.prototype.slice.call(root.querySelectorAll('.tt__ch'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function lerp(a, b, k) { return a + (b - a) * k; }
var target = { x: 0, y: 0 };
var spots = [];
function home() {
// 글자들은 처음에 가로로 늘어서 있다 — .is-demo 를 뗀 정적 상태의 모습
var r = field.getBoundingClientRect();
var gap = Math.min(44, (r.width - 60) / (chars.length - 1));
var startX = r.width / 2 - gap * (chars.length - 1) / 2;
target = { x: r.width / 2, y: r.height / 2 };
spots = [];
for (var i = 0; i < chars.length; i++) {
spots.push({ x: startX + gap * i, y: r.height / 2 + 26 });
}
}
function setTarget(cx, cy) {
var r = field.getBoundingClientRect();
target.x = cx - r.left;
target.y = cy - r.top;
}
function draw() {
for (var i = 0; i < chars.length; i++) {
chars[i].style.transform = 'translate3d(' + (spots[i].x - 14) + 'px,' + (spots[i].y - 20) + 'px,0)';
}
}
// 첫 글자는 커서를, 나머지는 앞 글자를 쫓는다 — 계수가 뒤로 갈수록 작아져 기차처럼 밀린다
var follow = [0.22, 0.16, 0.12, 0.09, 0.07];
function tick() {
spots[0].x = lerp(spots[0].x, target.x, follow[0]);
spots[0].y = lerp(spots[0].y, target.y - 34, follow[0]);
for (var i = 1; i < spots.length; i++) {
spots[i].x = lerp(spots[i].x, spots[i - 1].x, follow[i]);
spots[i].y = lerp(spots[i].y, spots[i - 1].y + 8, follow[i]);
}
draw();
requestAnimationFrame(tick);
}
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);
});
home();
draw();
if (!reduce) {
requestAnimationFrame(tick);
} else {
// reduce 에서는 글자가 늘어선 자리에 그대로 머문다
window.addEventListener('pointermove', function (e) {
setTarget(e.clientX, e.clientY);
for (var i = 0; i < spots.length; i++) {
spots[i].x = target.x - i * 34;
spots[i].y = target.y;
}
draw();
});
}// TextTrain — vanilla 판(run/079/demo/04-follow-text-train/index.html) 의 React 포트.
// 첫 글자는 커서를, 뒤 글자는 앞 글자를 lerp 로 쫓게 한다(계수 0.22 → 0.07) — 기차처럼 한 박자씩 밀려온다.
// 자리 계산·줄간격·reduce 동작은 vanilla 와 같은 값이다 (다시 타이핑하지 않는다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TextTrain.module.scss";
export type TextTrainProps = {
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 = "마우스를 움직이면 글자들이 줄지어 따라옵니다";
const CHARS = ["안", "녕", "하", "세", "요"];
// 첫 글자는 커서를, 나머지는 앞 글자를 쫓는다 — 계수가 뒤로 갈수록 작아져 기차처럼 밀린다
const FOLLOW = [0.22, 0.16, 0.12, 0.09, 0.07];
// lerp: 매 프레임 남은 거리의 일부만 더 가는 선형 보간
function lerp(a: number, b: number, k: number): number {
return a + (b - a) * k;
}
export default function TextTrain({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: TextTrainProps) {
const fieldRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement>;
const charRef = useRef<(HTMLSpanElement | null)[]>(new Array(CHARS.length).fill(null)) as Box<(HTMLSpanElement | null)[]>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function setChar(index: number) {
return (el: HTMLSpanElement | null): void => {
charRef.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 };
let spots = CHARS.map(() => ({ x: 0, y: 0 }));
function home(): void {
// 글자들은 처음에 가로로 늘어서 있다 — .is-demo 를 뗀 정적 상태의 모습
const r = field.getBoundingClientRect();
const gap = Math.min(44, (r.width - 60) / (CHARS.length - 1));
const startX = r.width / 2 - gap * (CHARS.length - 1) / 2;
target.x = r.width / 2;
target.y = r.height / 2;
spots = CHARS.map((_, i) => ({ x: startX + gap * i, y: r.height / 2 + 26 }));
}
function setTarget(cx: number, cy: number): void {
const r = field.getBoundingClientRect();
target.x = cx - r.left;
target.y = cy - r.top;
}
function draw(): void {
for (let i = 0; i < spots.length; i++) {
const ch = charRef.current[i];
if (ch) ch.style.transform = "translate3d(" + (spots[i].x - 14) + "px," + (spots[i].y - 20) + "px,0)";
}
}
function tick(): void {
spots[0].x = lerp(spots[0].x, target.x, FOLLOW[0]);
spots[0].y = lerp(spots[0].y, target.y - 34, FOLLOW[0]);
for (let i = 1; i < spots.length; i++) {
spots[i].x = lerp(spots[i].x, spots[i - 1].x, FOLLOW[i]);
spots[i].y = lerp(spots[i].y, spots[i - 1].y + 8, FOLLOW[i]);
}
draw();
raf = requestAnimationFrame(tick);
}
function onMove(event: PtrEvt): void {
setDemo(false);
setTarget(event.clientX, event.clientY);
if (reduce) {
// reduce 에서는 글자가 커서 뒤로 가로줄로 바로 붙는다
for (let i = 0; i < spots.length; i++) {
spots[i].x = target.x - i * 34;
spots[i].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;
home();
draw();
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}>
{CHARS.map((ch, i) => (
<span className={styles.ch} key={i} ref={setChar(i)}>{ch}</span>
))}
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}04 Letters that follow — a text train — Measured area 10.345% · 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


