02追ってくる背景 — 3枚の視差
遠い丘・近い丘・草の3枚が異なる比率でカーソル x を translate3d で追い、草は skewX でカーソル側へ倒れ、太陽の瞳もカーソルを追います。
使いどころランディングヒーローの背景、イラストシーン
translate3dskewXvar(--px)

<main class="stage">
<div class="bg is-demo" id="bg">
<div class="bg__scene" role="img" aria-label="丘と草の上に太陽が昇り、その目がカーソルを見る図">
<div class="bg__sky" aria-hidden="true"></div>
<div class="bg__sun" aria-hidden="true">
<span class="bg__eye"><span class="bg__pupil"></span></span>
<span class="bg__eye"><span class="bg__pupil"></span></span>
<span class="bg__mouth"></span>
</div>
<div class="bg__hill bg__hill--far" aria-hidden="true"></div>
<div class="bg__hill bg__hill--mid" aria-hidden="true"></div>
<div class="bg__grass" aria-hidden="true">
<span class="bg__blade"></span><span class="bg__blade"></span><span class="bg__blade"></span>
<span class="bg__blade"></span><span class="bg__blade"></span><span class="bg__blade"></span>
<span class="bg__blade"></span><span class="bg__blade"></span><span class="bg__blade"></span>
</div>
</div>
<p class="bg__hint">マウスを動かすと背景と草が追ってきます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 따라오는 배경 — 실제 시차는 JS 가 커서 가로 위치(--px, 0~1)를 lerp 로 부드럽게 만들어
// 멀리 언덕은 -10px, 가까운 언덕은 -22px, 풀은 -34px 씩 밀어 서로 다른 속도로 따라오게 한다.
// 풀잎 skewX 는 --px 하나를 공유해 커서 쪽으로 눕고, 해의 눈동자도 같은 값으로 커서를 본다.
// 격자 미리보기(.is-demo)는 커서가 좌우로 지나간 것과 같은 진폭을 @keyframes 로 왕복시킨다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.bg { display: grid; justify-items: center; gap: $sp-3; width: 100%; }
.bg__scene {
--px: 0.5;
position: relative; overflow: hidden;
width: 100%; height: clamp(110px, 32vh, 160px);
border: 3px solid $color; border-radius: $r-card;
background: $stage-yellow;
}
// 하늘 장식 — 반달 모양 구름 두 개 (그라디언트가 아니라 흰 원으로)
.bg__sky::before, .bg__sky::after {
content: ""; position: absolute; border-radius: 50%;
background: $stage-paper; opacity: .9;
}
.bg__sky::before { width: 34px; height: 34px; left: 12%; top: 16%; }
.bg__sky::after { width: 22px; height: 22px; left: 24%; top: 30%; }
// 해 — 눈 두 개가 커서를 쫓는다
.bg__sun {
position: absolute; right: 10%; top: 12%;
display: flex; align-items: center; gap: $sp-2;
width: clamp(44px, 12vw, 56px); height: clamp(44px, 12vw, 56px);
box-sizing: border-box;
background: $stage-paper; border: 3px solid $color; border-radius: 50%;
}
.bg__eye {
position: relative; overflow: hidden;
width: 10px; height: 10px;
background: $stage-paper; border: 2px solid $color; border-radius: 50%;
}
.bg__pupil {
position: absolute; left: 50%; top: 50%;
width: 4px; height: 4px; margin: -2px 0 0 -2px;
background: $color; border-radius: 50%;
}
.bg__mouth {
position: absolute; left: 50%; bottom: 22%;
width: 14px; height: 6px; margin-left: -7px;
border-bottom: 3px solid $color; border-radius: 0 0 $r-pill $r-pill;
}
// 언덕 두 겹 — 멀수록 연하게, 밀리는 폭은 JS 가 정한다
.bg__hill { position: absolute; left: -8%; right: -8%; border-radius: $r-pill $r-pill 0 0; }
.bg__hill--far {
bottom: 26%; height: 34%;
background: $subject-cream; opacity: .9;
}
.bg__hill--mid {
bottom: 12%; height: 30%;
background: $stage-orange;
border: 3px solid $color; border-bottom: 0;
}
// 풀 — 잎마다 --px 를 공유해 skewX 로 눕는다. 원점은 뿌리(아래)다 (붙는 지점이 있는 요소)
.bg__grass {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-evenly; align-items: flex-end;
height: 26%;
}
.bg__blade {
width: 7px; border-radius: $r-pill $r-pill 0 0;
background: $color;
transform-origin: 50% 100%;
transform: skewX(calc((0.5 - var(--px)) * 26deg));
}
.bg__blade:nth-child(odd) { height: 100%; }
.bg__blade:nth-child(even) { height: 74%; }
.bg__hint { margin: 0; color: $color; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: 커서가 왼쪽 끝 → 오른쪽 끝을 지나간 것과 같은 진폭을 겹마다 다르게 왕복한다
.bg.is-demo .bg__hill--far { animation: bgFar $duration $easing infinite; }
.bg.is-demo .bg__hill--mid { animation: bgMid $duration $easing infinite; }
.bg.is-demo .bg__grass { animation: bgNear $duration $easing infinite; }
.bg.is-demo .bg__blade { animation: bgLean $duration $easing infinite; }
.bg.is-demo .bg__pupil { animation: bgPeek $duration $easing infinite; }
@keyframes bgFar {
0%, 4% { transform: translate3d(5px, 0, 0); }
34% { transform: translate3d(-5px, 0, 0); }
44%, 50% { transform: translate3d(-5px, 0, 0); }
80% { transform: translate3d(5px, 0, 0); }
88%, 100% { transform: translate3d(5px, 0, 0); }
}
@keyframes bgMid {
0%, 4% { transform: translate3d(11px, 0, 0); }
34% { transform: translate3d(-11px, 0, 0); }
44%, 50% { transform: translate3d(-11px, 0, 0); }
80% { transform: translate3d(11px, 0, 0); }
88%, 100% { transform: translate3d(11px, 0, 0); }
}
@keyframes bgNear {
0%, 4% { transform: translate3d(17px, 0, 0); }
34% { transform: translate3d(-17px, 0, 0); }
44%, 50% { transform: translate3d(-17px, 0, 0); }
80% { transform: translate3d(17px, 0, 0); }
88%, 100% { transform: translate3d(17px, 0, 0); }
}
// 풀잎 눕음 — 커서가 오른쪽(0.5 → 1)에 있으면 왼쪽으로 눕는다
@keyframes bgLean {
0%, 4% { transform: skewX(-13deg); }
34% { transform: skewX(13deg); }
44%, 50% { transform: skewX(13deg); }
80% { transform: skewX(-13deg); }
88%, 100% { transform: skewX(-13deg); }
}
@keyframes bgPeek {
0%, 4% { transform: translate3d(-3.5px, 0, 0); }
34% { transform: translate3d(3.5px, 0, 0); }
44%, 50% { transform: translate3d(3.5px, 0, 0); }
80% { transform: translate3d(-3.5px, 0, 0); }
88%, 100% { transform: translate3d(-3.5px, 0, 0); }
}
@media (max-width: 340px) {
.bg { gap: $sp-2; }
.bg__scene { height: clamp(96px, 28vh, 130px); }
.bg__sun { right: 6%; }
}
@media (prefers-reduced-motion: reduce) {
.bg.is-demo .bg__hill--far,
.bg.is-demo .bg__hill--mid,
.bg.is-demo .bg__grass,
.bg.is-demo .bg__blade,
.bg.is-demo .bg__pupil { animation: none !important; }
// reduce 에서는 잎이 곧게 선 정경만 보인다
.bg.is-demo .bg__blade { transform: skewX(0deg) !important; }
}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);
}
.bg {
display: grid;
justify-items: center;
gap: 12px;
width: 100%;
}
.bg__scene {
--px: 0.5;
position: relative;
overflow: hidden;
width: 100%;
height: clamp(110px, 32vh, 160px);
border: 3px solid #17141a;
border-radius: 16px;
background: #ffd23f;
}
.bg__sky::before, .bg__sky::after {
content: "";
position: absolute;
border-radius: 50%;
background: #ffffff;
opacity: 0.9;
}
.bg__sky::before {
width: 34px;
height: 34px;
left: 12%;
top: 16%;
}
.bg__sky::after {
width: 22px;
height: 22px;
left: 24%;
top: 30%;
}
.bg__sun {
position: absolute;
right: 10%;
top: 12%;
display: flex;
align-items: center;
gap: 8px;
width: clamp(44px, 12vw, 56px);
height: clamp(44px, 12vw, 56px);
box-sizing: border-box;
background: #ffffff;
border: 3px solid #17141a;
border-radius: 50%;
}
.bg__eye {
position: relative;
overflow: hidden;
width: 10px;
height: 10px;
background: #ffffff;
border: 2px solid #17141a;
border-radius: 50%;
}
.bg__pupil {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 4px;
margin: -2px 0 0 -2px;
background: #17141a;
border-radius: 50%;
}
.bg__mouth {
position: absolute;
left: 50%;
bottom: 22%;
width: 14px;
height: 6px;
margin-left: -7px;
border-bottom: 3px solid #17141a;
border-radius: 0 0 999px 999px;
}
.bg__hill {
position: absolute;
left: -8%;
right: -8%;
border-radius: 999px 999px 0 0;
}
.bg__hill--far {
bottom: 26%;
height: 34%;
background: #fff7e6;
opacity: 0.9;
}
.bg__hill--mid {
bottom: 12%;
height: 30%;
background: #ff4d1f;
border: 3px solid #17141a;
border-bottom: 0;
}
.bg__grass {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-evenly;
align-items: flex-end;
height: 26%;
}
.bg__blade {
width: 7px;
border-radius: 999px 999px 0 0;
background: #17141a;
transform-origin: 50% 100%;
transform: skewX(calc((0.5 - var(--px)) * 26deg));
}
.bg__blade:nth-child(odd) {
height: 100%;
}
.bg__blade:nth-child(even) {
height: 74%;
}
.bg__hint {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.bg.is-demo .bg__hill--far {
animation: bgFar 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.bg.is-demo .bg__hill--mid {
animation: bgMid 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.bg.is-demo .bg__grass {
animation: bgNear 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.bg.is-demo .bg__blade {
animation: bgLean 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.bg.is-demo .bg__pupil {
animation: bgPeek 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bgFar {
0%, 4% {
transform: translate3d(5px, 0, 0);
}
34% {
transform: translate3d(-5px, 0, 0);
}
44%, 50% {
transform: translate3d(-5px, 0, 0);
}
80% {
transform: translate3d(5px, 0, 0);
}
88%, 100% {
transform: translate3d(5px, 0, 0);
}
}
@keyframes bgMid {
0%, 4% {
transform: translate3d(11px, 0, 0);
}
34% {
transform: translate3d(-11px, 0, 0);
}
44%, 50% {
transform: translate3d(-11px, 0, 0);
}
80% {
transform: translate3d(11px, 0, 0);
}
88%, 100% {
transform: translate3d(11px, 0, 0);
}
}
@keyframes bgNear {
0%, 4% {
transform: translate3d(17px, 0, 0);
}
34% {
transform: translate3d(-17px, 0, 0);
}
44%, 50% {
transform: translate3d(-17px, 0, 0);
}
80% {
transform: translate3d(17px, 0, 0);
}
88%, 100% {
transform: translate3d(17px, 0, 0);
}
}
@keyframes bgLean {
0%, 4% {
transform: skewX(-13deg);
}
34% {
transform: skewX(13deg);
}
44%, 50% {
transform: skewX(13deg);
}
80% {
transform: skewX(-13deg);
}
88%, 100% {
transform: skewX(-13deg);
}
}
@keyframes bgPeek {
0%, 4% {
transform: translate3d(-3.5px, 0, 0);
}
34% {
transform: translate3d(3.5px, 0, 0);
}
44%, 50% {
transform: translate3d(3.5px, 0, 0);
}
80% {
transform: translate3d(-3.5px, 0, 0);
}
88%, 100% {
transform: translate3d(-3.5px, 0, 0);
}
}
@media (max-width: 340px) {
.bg {
gap: 8px;
}
.bg__scene {
height: clamp(96px, 28vh, 130px);
}
.bg__sun {
right: 6%;
}
}
@media (prefers-reduced-motion: reduce) {
.bg.is-demo .bg__hill--far,
.bg.is-demo .bg__hill--mid,
.bg.is-demo .bg__grass,
.bg.is-demo .bg__blade,
.bg.is-demo .bg__pupil {
animation: none !important;
}
.bg.is-demo .bg__blade {
transform: skewX(0deg) !important;
}
}var root = document.getElementById('bg');
var scene = root.querySelector('.bg__scene');
var far = root.querySelector('.bg__hill--far');
var mid = root.querySelector('.bg__hill--mid');
var grass = root.querySelector('.bg__grass');
var pupils = Array.prototype.slice.call(root.querySelectorAll('.bg__pupil'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function lerp(a, b, k) { return a + (b - a) * k; }
// --px 는 0~1 사이 커서 가로 위치. 풀잎 skewX 가 이 값 하나를 공유해 같이 눕는다
var px = 0.5;
var pxNow = 0.5;
function tick() {
pxNow = lerp(pxNow, px, 0.08);
scene.style.setProperty('--px', pxNow.toFixed(4));
var dx = pxNow - 0.5;
// 멀수록 조금, 가까울수록 많이 밀린다 — 시차
far.style.transform = 'translate3d(' + (dx * -10).toFixed(1) + 'px,0,0)';
mid.style.transform = 'translate3d(' + (dx * -22).toFixed(1) + 'px,0,0)';
grass.style.transform = 'translate3d(' + (dx * -34).toFixed(1) + 'px,0,0)';
for (var i = 0; i < pupils.length; i++) {
pupils[i].style.transform = 'translate3d(' + (dx * 7).toFixed(1) + 'px,0,0)';
}
requestAnimationFrame(tick);
}
function setFromPointer(cx) {
var r = scene.getBoundingClientRect();
px = Math.min(1, Math.max(0, (cx - r.left) / r.width));
}
window.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
setFromPointer(e.clientX);
});
window.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
setFromPointer(e.clientX);
});
if (reduce) {
// reduce 에서는 시차 이동 없이 풀잎 눕음만 즉시 값으로 반영한다
window.addEventListener('pointermove', function (e) {
setFromPointer(e.clientX);
scene.style.setProperty('--px', px.toFixed(4));
});
} else {
requestAnimationFrame(tick);
}// BackgroundParallax — vanilla 판(run/079/demo/02-follow-background-parallax/index.html) 의 React 포트.
// 커서 가로 위치(--px, 0~1)를 lerp(0.08)로 부드럽게 만들어 멀리 언덕은 -10px · 가까운 언덕은 -22px ·
// 풀은 -34px 씩 밀어 서로 다른 속도로 따라오게 한다. 풀잎 skewX 는 --px 하나를 공유해 커서 쪽으로 눕고
// 해의 눈동자도 같은 값으로 커서를 본다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BackgroundParallax.module.scss";
export type BackgroundParallaxProps = {
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 };
const SCENE_LABEL = "언덕과 풀 위에 해가 뜨고 해의 눈이 커서를 바라보는 그림";
const HINT = "마우스를 움직이면 배경과 풀이 따라옵니다";
const BLADE_COUNT = 9;
// lerp: 매 프레임 남은 거리의 일부만 더 가는 선형 보간
function lerp(a: number, b: number, k: number): number {
return a + (b - a) * k;
}
export default function BackgroundParallax({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: BackgroundParallaxProps) {
const sceneRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement>;
const farRef = useRef<HTMLDivElement | null>(null);
const midRef = useRef<HTMLDivElement | null>(null);
const grassRef = useRef<HTMLDivElement | null>(null);
const pupilRef = useRef<(HTMLSpanElement | null)[]>(new Array(2).fill(null)) as Box<(HTMLSpanElement | null)[]>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function setPupil(index: number) {
return (el: HTMLSpanElement | null): void => {
pupilRef.current[index] = el;
};
}
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// --px 는 0~1 사이 커서 가로 위치. 풀잎 skewX 가 이 값 하나를 공유해 같이 눕는다
let px = 0.5;
let pxNow = 0.5;
function tick(): void {
pxNow = lerp(pxNow, px, 0.08);
scene.style.setProperty("--px", pxNow.toFixed(4));
const dx = pxNow - 0.5;
// 멀수록 조금, 가까울수록 많이 밀린다 — 시차
const far = farRef.current;
const mid = midRef.current;
const grass = grassRef.current;
if (far) far.style.transform = "translate3d(" + (dx * -10).toFixed(1) + "px,0,0)";
if (mid) mid.style.transform = "translate3d(" + (dx * -22).toFixed(1) + "px,0,0)";
if (grass) grass.style.transform = "translate3d(" + (dx * -34).toFixed(1) + "px,0,0)";
for (let i = 0; i < pupilRef.current.length; i++) {
const pupil = pupilRef.current[i];
if (pupil) pupil.style.transform = "translate3d(" + (dx * 7).toFixed(1) + "px,0,0)";
}
raf = requestAnimationFrame(tick);
}
function setFromPointer(cx: number): void {
const r = scene.getBoundingClientRect();
px = Math.min(1, Math.max(0, (cx - r.left) / r.width));
}
function onMove(event: PtrEvt): void {
setDemo(false);
setFromPointer(event.clientX);
if (reduce) {
// reduce 에서는 시차 이동 없이 풀잎 눕음만 즉시 값으로 반영한다
scene.style.setProperty("--px", px.toFixed(4));
}
}
function onDown(event: PtrEvt): void {
setDemo(false);
setFromPointer(event.clientX);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerdown", onDown);
let raf = 0;
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}>
<div className={styles.sky} aria-hidden="true"></div>
<div className={styles.sun} aria-hidden="true">
<span className={styles.eye}><span className={styles.pupil} ref={setPupil(0)}></span></span>
<span className={styles.eye}><span className={styles.pupil} ref={setPupil(1)}></span></span>
<span className={styles.mouth}></span>
</div>
<div className={styles.hill + " " + styles.hillFar} ref={farRef} aria-hidden="true"></div>
<div className={styles.hill + " " + styles.hillMid} ref={midRef} aria-hidden="true"></div>
<div className={styles.grass} ref={grassRef} aria-hidden="true">
{Array.from({ length: BLADE_COUNT }, (_, i) => (
<span className={styles.blade} key={i}></span>
))}
</div>
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}02 追ってくる背景 — 3枚の視差 — 計測 累積面積 6.548% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · マウス 追従 エフェクト 9選 — マウスストーカーの作り方
このデモが属する記事
マウス 追従 エフェクト 9選 — マウスストーカーの作り方マウス 追従 エフェクトとは、カーソルの座標を毎フレーム読んで要素を一拍遅れで追わせるインタラクションです。核は lerp 一行で、canvas 不要、transform だけなので軽く動きます。文字・光・背景・パーティクル・カードまで9種 この記事のデモをすべて見る


