03따라다니는 빛 — 스포트라이트
radial-gradient 빛 덩어리가 mix-blend-mode 로 어두운 무대 위를 밝히며 커서를 한 박자 늦게 쫓아옵니다.
쓰임다크 모드 히어로, 이벤트 문구 공개 연출
radial-gradientmix-blend-moderequestAnimationFrame

<main class="stage">
<div class="sl is-demo" id="sl">
<div class="sl__scene" role="img" aria-label="어두운 무대를 빛 덩어리가 늦게 따라다니며 글자와 별을 밝히는 그림">
<p class="sl__word">빛이 따라온다</p>
<span class="sl__star" style="left:8%; top:18%" aria-hidden="true"></span>
<span class="sl__star" style="left:16%; top:64%" aria-hidden="true"></span>
<span class="sl__star" style="left:83%; top:22%" aria-hidden="true"></span>
<span class="sl__star" style="left:90%; top:66%" aria-hidden="true"></span>
<span class="sl__star" style="left:70%; top:80%" aria-hidden="true"></span>
<span class="sl__star" style="left:28%; top:84%" aria-hidden="true"></span>
<span class="sl__light" id="slLight" aria-hidden="true"></span>
</div>
<p class="sl__hint">마우스를 움직이면 빛이 한 박자 늦게 따라옵니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 따라다니는 빛 — radial-gradient 빛 덩어리가 mix-blend-mode: screen 으로 어두운 무대를 덮으며
// 커서를 lerp(0.09)로 늦게 쫓는다. screen 은 밝은 쪽만 더 밝게 하므로 지나온 글자와 별이 드러난다.
// 격자 미리보기(.is-demo)는 빛이 문장을 왼쪽 끝에서 오른쪽 끝까지 훑고 돌아오는 길을 @keyframes 로 움직인다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.sl { display: grid; justify-items: center; gap: $sp-3; width: 100%; }
.sl__scene {
position: relative; overflow: hidden;
width: 100%; height: clamp(110px, 32vh, 160px);
border: 3px solid $color; border-radius: $r-card;
background: #2a272c;
}
.sl__word {
position: absolute; left: 50%; top: 50%;
width: max-content; margin: 0;
transform: translate(-50%, -50%);
color: rgba($color, .78);
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1.1;
letter-spacing: .04em;
}
.sl__star {
position: absolute;
width: 6px; height: 6px;
background: rgba($color, .55);
transform: rotate(45deg);
}
// 빛 덩어리 — 크고 부드러운 원형 그라디언트. z 순서는 글자 위
.sl__light {
position: absolute; left: 0; top: 0;
width: 220px; height: 220px;
background: radial-gradient(circle, rgba($subject-cream, .95) 0%, rgba($stage-yellow, .38) 38%, rgba($stage-yellow, 0) 68%);
mix-blend-mode: screen;
border-radius: 50%;
will-change: transform;
}
.sl__hint { margin: 0; color: $color; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: 빛이 문장을 왼쪽에서 오른쪽으로 훑고 돌아온다
.sl.is-demo .sl__light { animation: slSweep $duration $easing infinite; }
@keyframes slSweep {
0%, 6% { transform: translate3d(-96px, -46px, 0); }
44%, 54% { transform: translate3d(190px, -30px, 0); }
88%, 100% { transform: translate3d(-96px, -46px, 0); }
}
@media (max-width: 340px) {
.sl { gap: $sp-2; }
.sl__scene { height: clamp(96px, 28vh, 130px); }
.sl__word { font-size: clamp(16px, 5vw, 22px); }
.sl.is-demo .sl__light { animation-name: slSweepNarrow; }
}
@keyframes slSweepNarrow {
0%, 6% { transform: translate3d(-100px, -44px, 0); }
44%, 54% { transform: translate3d(160px, -30px, 0); }
88%, 100% { transform: translate3d(-100px, -44px, 0); }
}
@media (prefers-reduced-motion: reduce) {
.sl.is-demo .sl__light { animation: none !important; }
// reduce 에서는 빛이 문장 가운데에 고정되어 한 번에 다 보인다
.sl.is-demo .sl__light { transform: translate3d(60px, -40px, 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);
}
.sl {
display: grid;
justify-items: center;
gap: 12px;
width: 100%;
}
.sl__scene {
position: relative;
overflow: hidden;
width: 100%;
height: clamp(110px, 32vh, 160px);
border: 3px solid #fff7e6;
border-radius: 16px;
background: #2a272c;
}
.sl__word {
position: absolute;
left: 50%;
top: 50%;
width: max-content;
margin: 0;
transform: translate(-50%, -50%);
color: rgba(255, 247, 230, 0.78);
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1.1;
letter-spacing: 0.04em;
}
.sl__star {
position: absolute;
width: 6px;
height: 6px;
background: rgba(255, 247, 230, 0.55);
transform: rotate(45deg);
}
.sl__light {
position: absolute;
left: 0;
top: 0;
width: 220px;
height: 220px;
background: radial-gradient(circle, rgba(255, 247, 230, 0.95) 0%, rgba(255, 210, 63, 0.38) 38%, rgba(255, 210, 63, 0) 68%);
mix-blend-mode: screen;
border-radius: 50%;
will-change: transform;
}
.sl__hint {
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.sl.is-demo .sl__light {
animation: slSweep 2s ease-out infinite;
}
@keyframes slSweep {
0%, 6% {
transform: translate3d(-96px, -46px, 0);
}
44%, 54% {
transform: translate3d(190px, -30px, 0);
}
88%, 100% {
transform: translate3d(-96px, -46px, 0);
}
}
@media (max-width: 340px) {
.sl {
gap: 8px;
}
.sl__scene {
height: clamp(96px, 28vh, 130px);
}
.sl__word {
font-size: clamp(16px, 5vw, 22px);
}
.sl.is-demo .sl__light {
animation-name: slSweepNarrow;
}
}
@keyframes slSweepNarrow {
0%, 6% {
transform: translate3d(-100px, -44px, 0);
}
44%, 54% {
transform: translate3d(160px, -30px, 0);
}
88%, 100% {
transform: translate3d(-100px, -44px, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.sl.is-demo .sl__light {
animation: none !important;
}
.sl.is-demo .sl__light {
transform: translate3d(60px, -40px, 0) !important;
}
}var root = document.getElementById('sl');
var scene = root.querySelector('.sl__scene');
var light = document.getElementById('slLight');
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 pos = { x: 0, y: 0 };
function setTarget(cx, cy) {
var r = scene.getBoundingClientRect();
target.x = cx - r.left;
target.y = cy - r.top;
}
function draw() {
// 빛 덩어리는 자기 중심이 좌표에 오도록 절반만큼 뺀다
light.style.transform = 'translate3d(' + (pos.x - 110) + 'px,' + (pos.y - 110) + 'px,0)';
}
function tick() {
pos.x = lerp(pos.x, target.x, 0.09);
pos.y = lerp(pos.y, target.y, 0.09);
draw();
requestAnimationFrame(tick);
}
function start() {
var r = scene.getBoundingClientRect();
target = { x: r.width / 2, y: r.height / 2 };
pos = { x: r.width / 2, y: r.height / 2 };
}
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) {
// reduce 에서는 빛이 한가운데에 머문다
window.addEventListener('pointermove', function (e) { setTarget(e.clientX, e.clientY); pos = { x: target.x, y: target.y }; draw(); });
} else {
requestAnimationFrame(tick);
}// SpotlightLight — vanilla 판(run/079/demo/03-follow-spotlight-light/index.html) 의 React 포트.
// radial-gradient 빛 덩어리가 mix-blend-mode: screen 으로 어두운 무대를 덮으며 커서를 lerp(0.09)로 늦게 쫓는다.
// screen 은 밝은 쪽만 더 밝게 하므로 지나온 글자와 별이 드러난다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpotlightLight.module.scss";
export type SpotlightLightProps = {
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 SCENE_LABEL = "어두운 무대를 빛 덩어리가 늦게 따라다니며 글자와 별을 밝히는 그림";
const WORD = "빛이 따라온다";
const HINT = "마우스를 움직이면 빛이 한 박자 늦게 따라옵니다";
// 별 여섯 개의 자리 — vanilla index.html 의 인라인 스타일과 같은 값
const STARS: { left: string; top: string }[] = [
{ left: "8%", top: "18%" },
{ left: "16%", top: "64%" },
{ left: "83%", top: "22%" },
{ left: "90%", top: "66%" },
{ left: "70%", top: "80%" },
{ left: "28%", top: "84%" },
];
// lerp: 매 프레임 남은 거리의 일부만 더 가는 선형 보간
function lerp(a: number, b: number, k: number): number {
return a + (b - a) * k;
}
export default function SpotlightLight({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: SpotlightLightProps) {
const sceneRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement>;
const lightRef = useRef<HTMLSpanElement | null>(null);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const target = { x: 0, y: 0 };
const pos = { x: 0, y: 0 };
function setTarget(cx: number, cy: number): void {
const r = scene.getBoundingClientRect();
target.x = cx - r.left;
target.y = cy - r.top;
}
function draw(): void {
const light = lightRef.current;
// 빛 덩어리는 자기 중심이 좌표에 오도록 절반만큼 뺀다
if (light) light.style.transform = "translate3d(" + (pos.x - 110) + "px," + (pos.y - 110) + "px,0)";
}
function tick(): void {
pos.x = lerp(pos.x, target.x, 0.09);
pos.y = lerp(pos.y, target.y, 0.09);
draw();
raf = requestAnimationFrame(tick);
}
function start(): void {
const r = scene.getBoundingClientRect();
target.x = r.width / 2;
target.y = r.height / 2;
pos.x = r.width / 2;
pos.y = r.height / 2;
}
function onMove(event: PtrEvt): void {
setDemo(false);
setTarget(event.clientX, event.clientY);
if (reduce) {
// reduce 에서는 빛이 한가운데에 머문다
pos.x = target.x;
pos.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.scene} ref={sceneRef} role="img" aria-label={SCENE_LABEL}>
<p className={styles.word}>{WORD}</p>
{STARS.map((star, i) => (
<span className={styles.star} style={{ left: star.left, top: star.top }} aria-hidden="true" key={i}></span>
))}
<span className={styles.light} ref={lightRef} aria-hidden="true"></span>
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}03 따라다니는 빛 — 스포트라이트 — 측정 누적면적 29.695% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 마우스 따라다니는 효과 9종 — 커서를 쫓는 글씨·빛·파티클
이 데모가 속한 글
마우스 따라다니는 효과 9종 — 커서를 쫓는 글씨·빛·파티클마우스 따라다니는 효과는 커서 좌표를 매 프레임 읽어서 요소가 한 박자 늦게 쫓아오게 만드는 인터랙션입니다. 핵심은 lerp(선형 보간) 한 줄이고, 캔버스 없이 transform 한 줄로 만들어 무겁지 않습니다. 이 글의 데모 전부 보기

