08마우스 이동 패럴랙스
커서를 움직인 방향으로 레이어들이 저마다 다른 폭으로 따라와 입체감을 만든다.
쓰임제품 쇼케이스, 포트폴리오 히어로
pointermove레이어별 배율translate

<main class="stage is-demo">
<div class="mousepar">
<div class="mousepar__layer mousepar__layer--a" style="--depth:.3" aria-hidden="true"></div>
<div class="mousepar__layer mousepar__layer--b" style="--depth:.6" aria-hidden="true"></div>
<div class="mousepar__layer mousepar__layer--c" style="--depth:1" aria-hidden="true"></div>
<p class="mousepar__label">마우스를 따라온다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$amp: 22px; // 커서 정규화값 1당 이동량
$duration: $dur-loop; // 데모 왕복 주기
$color: $subject-blue; // 레이어 색 (paper 무대라 blue)
@include stage($stage-paper);
.mousepar {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.mousepar__layer {
position: absolute;
width: 46px;
height: 46px;
border-radius: 50%;
background: rgba($color, .5);
transition: transform 120ms $ease-out;
// ── 실제 사용 코드: pointermove 로 받은 --mx·--my 에 레이어별 --depth 를 곱해 이동한다 ──
&--a { left: 18%; top: 24%; transform: translate(calc(var(--mx, 0) * var(--depth) * #{$amp}), calc(var(--my, 0) * var(--depth) * #{$amp})); }
&--b { left: 62%; top: 54%; transform: translate(calc(var(--mx, 0) * var(--depth) * #{$amp}), calc(var(--my, 0) * var(--depth) * #{$amp})); }
&--c { left: 40%; top: 68%; transform: translate(calc(var(--mx, 0) * var(--depth) * #{$amp}), calc(var(--my, 0) * var(--depth) * #{$amp})); }
}
.mousepar__label {
position: relative;
z-index: 1;
margin: 0;
padding: 12px 20px;
border-radius: $r-pill;
background: $stage-ink;
color: #fff;
font: 700 clamp(13px, 4.2vw, 16px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 데모(.is-demo): 커서 대신 2초 루프로 흉내 ────────
.stage.is-demo .mousepar__layer--a { animation: loop-a $duration ease-in-out infinite; }
.stage.is-demo .mousepar__layer--b { animation: loop-b $duration ease-in-out infinite; }
.stage.is-demo .mousepar__layer--c { animation: loop-c $duration ease-in-out infinite; }
@keyframes loop-a { 0%, 100% { transform: translate(-10px, -6px); } 50% { transform: translate(10px, 6px); } }
@keyframes loop-b { 0%, 100% { transform: translate(16px, 10px); } 50% { transform: translate(-16px, -10px); } }
@keyframes loop-c { 0%, 100% { transform: translate(-24px, -14px); } 50% { transform: translate(24px, 14px); } }
@media (prefers-reduced-motion: reduce) {
.mousepar__layer,
.stage.is-demo .mousepar__layer--a, .stage.is-demo .mousepar__layer--b, .stage.is-demo .mousepar__layer--c {
animation: none !important;
transform: none !important;
transition: none;
}
}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);
}
.mousepar {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.mousepar__layer {
position: absolute;
width: 46px;
height: 46px;
border-radius: 50%;
background: rgba(47, 109, 246, 0.5);
transition: transform 120ms ease-out;
}
.mousepar__layer--a {
left: 18%;
top: 24%;
transform: translate(calc(var(--mx, 0) * var(--depth) * 22px), calc(var(--my, 0) * var(--depth) * 22px));
}
.mousepar__layer--b {
left: 62%;
top: 54%;
transform: translate(calc(var(--mx, 0) * var(--depth) * 22px), calc(var(--my, 0) * var(--depth) * 22px));
}
.mousepar__layer--c {
left: 40%;
top: 68%;
transform: translate(calc(var(--mx, 0) * var(--depth) * 22px), calc(var(--my, 0) * var(--depth) * 22px));
}
.mousepar__label {
position: relative;
z-index: 1;
margin: 0;
padding: 12px 20px;
border-radius: 999px;
background: #17141a;
color: #fff;
font: 700 clamp(13px, 4.2vw, 16px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.stage.is-demo .mousepar__layer--a {
animation: loop-a 2s ease-in-out infinite;
}
.stage.is-demo .mousepar__layer--b {
animation: loop-b 2s ease-in-out infinite;
}
.stage.is-demo .mousepar__layer--c {
animation: loop-c 2s ease-in-out infinite;
}
@keyframes loop-a {
0%, 100% {
transform: translate(-10px, -6px);
}
50% {
transform: translate(10px, 6px);
}
}
@keyframes loop-b {
0%, 100% {
transform: translate(16px, 10px);
}
50% {
transform: translate(-16px, -10px);
}
}
@keyframes loop-c {
0%, 100% {
transform: translate(-24px, -14px);
}
50% {
transform: translate(24px, 14px);
}
}
@media (prefers-reduced-motion: reduce) {
.mousepar__layer,
.stage.is-demo .mousepar__layer--a, .stage.is-demo .mousepar__layer--b, .stage.is-demo .mousepar__layer--c {
animation: none !important;
transform: none !important;
transition: none;
}
}// 실제 사용: 커서 위치를 -0.5~0.5 로 정규화해 CSS 변수로 넘긴다. 레이어는 --depth 로 배율만 다르게 받는다.
var el = document.querySelector('.mousepar');
el.addEventListener('pointermove', function (e) {
var r = el.getBoundingClientRect();
el.style.setProperty('--mx', ((e.clientX - r.left) / r.width - 0.5).toFixed(3));
el.style.setProperty('--my', ((e.clientY - r.top) / r.height - 0.5).toFixed(3));
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./MouseParallax.module.scss";
export type MouseParallaxProps = {
children: ReactNode;
/** 커서 정규화값 1당 이동량 (기본 22px) */
amp?: number;
/** 레이어 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--amp"?: string; "--color"?: string; "--mx"?: string; "--my"?: string };
export default function MouseParallax({ children, amp = 22, color = "#2f6df6", className = "" }: MouseParallaxProps) {
const ref = useRef<HTMLDivElement>(null);
const handle = useCallback((e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
ref.current?.style.setProperty("--mx", `${(e.clientX - r.left) / r.width - 0.5}`);
ref.current?.style.setProperty("--my", `${(e.clientY - r.top) / r.height - 0.5}`);
}, []);
const vars: Vars = { "--amp": `${amp}px`, "--color": color };
return (
<div ref={ref} className={`${styles.wrap} ${className}`} style={vars} onPointerMove={handle}>
<div className={styles.layer} style={{ "--depth": ".3", left: "18%", top: "24%" } as Vars} aria-hidden="true" />
<div className={styles.layer} style={{ "--depth": ".6", left: "62%", top: "54%" } as Vars} aria-hidden="true" />
<div className={styles.layer} style={{ "--depth": "1", left: "40%", top: "68%" } as Vars} aria-hidden="true" />
<p className={styles.label}>{children}</p>
</div>
);
}08 마우스 이동 패럴랙스 — 측정 누적면적 6.447% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 패럴랙스 스크롤 9종 — AOS·GSAP 없이, JS 0줄
이 데모가 속한 글
CSS 패럴랙스 스크롤 9종 — AOS·GSAP 없이, JS 0줄CSS 패럴랙스 스크롤 아홉 개를 모았습니다. 레이어마다 이동 속도를 다르게 주는 원리 하나로 배경이 내용보다 느리게 움직이는 착시를 만듭니다. 격자에서는 아홉 개가 2초 간격으로 자동 반복되니 먼저 눈으로 훑고 마 이 글의 데모 전부 보기


