07前後に分かれる風景パノラマ
空・山・草原の3枚がそれぞれ違う距離で動き、窓の外の景色のように見えます。
使いどころ旅行商品紹介のバナー、ゲーム紹介ページの背景
translateXfilterclip-path

<main class="stage">
<div class="tp is-demo" id="tp">
<div class="tp__window" id="window" role="img" aria-label="空・山・草原の3層がマウスに合わせて違う距離で動く風景">
<span class="tp__layer tp__layer--sky" aria-hidden="true"><span class="tp__sun"></span></span>
<span class="tp__layer tp__layer--hill" aria-hidden="true"><i class="tp__peak tp__peak--l"></i><i class="tp__peak tp__peak--r"></i></span>
<span class="tp__layer tp__layer--field" aria-hidden="true"></span>
<span class="tp__caption">済州島南部のオルム</span>
</div>
<p class="tp__hint">マウスを左右に動かすと層が分かれます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 앞뒤로 갈라지는 풍경 파노라마 — 겹마다 다른 이동 계수(--depth)를 두고 JS 는 포인터 x 를 -1~1 로 바꿔 --px 하나만 넘긴다.
// 배경(하늘)은 8px, 중경(산)은 22px, 전경(들판)은 44px 움직인다 — 가까울수록 많이 움직이는 것이 깊이로 읽힌다.
// 격자 미리보기(.is-demo)는 그 세 폭을 @keyframes 로 왕복시킨다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.tp { display: grid; justify-items: center; gap: $sp-3; width: 100%; }
.tp__window {
--px: 0;
position: relative; overflow: hidden;
width: clamp(232px, 60vw, 296px); height: clamp(104px, 28vw, 136px);
border-radius: $r-card;
border: 3px solid $color;
background: $subject-blue;
}
.tp__window:focus-visible { outline: 3px solid $color; outline-offset: $sp-2; }
.tp__layer {
position: absolute; left: -12%; right: -12%;
transform: translateX(calc(var(--px) * var(--depth)));
}
.tp__layer--sky {
--depth: 8px;
top: 0; height: 62%;
background: linear-gradient(180deg, $subject-blue 0%, rgba($subject-cream, .5) 100%);
}
.tp__sun {
position: absolute; left: 62%; top: 22%;
width: 26px; height: 26px;
border-radius: 50%;
background: $stage-yellow;
box-shadow: 0 0 20px rgba(255, 210, 63, .9);
}
// 산은 clip-path 삼각형 두 개 — 그라디언트로 그리면 반쪽 쐐기가 되어 능선으로 안 읽힌다
.tp__layer--hill {
--depth: 22px;
bottom: 26%; height: 48%;
}
.tp__peak {
position: absolute; bottom: 0; height: 100%;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.tp__peak--l { left: 14%; width: 34%; background: rgba($subject-ink, .78); }
.tp__peak--r { left: 46%; width: 44%; height: 82%; background: $subject-ink; }
.tp__layer--field {
--depth: 44px;
bottom: 0; height: 30%;
background: repeating-linear-gradient(96deg, $subject-ink 0 14px, rgba($subject-ink, .82) 14px 28px);
border-top: 3px solid $color;
}
.tp__caption {
position: absolute; left: $sp-3; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .92); color: $color;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px);
}
.tp__hint { margin: 0; color: $color; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: --px 가 -1 → 1 로 지나간 것과 같은 결과를 겹마다 직접 그린다
.tp.is-demo .tp__layer {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tp.is-demo .tp__layer--sky { animation-name: tpSky; }
.tp.is-demo .tp__layer--hill { animation-name: tpHill; }
.tp.is-demo .tp__layer--field { animation-name: tpField; }
@keyframes tpSky {
0%, 6% { transform: translateX(-8px); }
46%, 58% { transform: translateX(8px); }
94%, 100% { transform: translateX(-8px); }
}
@keyframes tpHill {
0%, 6% { transform: translateX(-22px); }
46%, 58% { transform: translateX(22px); }
94%, 100% { transform: translateX(-22px); }
}
@keyframes tpField {
0%, 6% { transform: translateX(-44px); }
46%, 58% { transform: translateX(44px); }
94%, 100% { transform: translateX(-44px); }
}
@media (max-width: 340px) {
.tp__caption { left: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__layer { animation: none !important; }
// 움직임 없이 겹이 갈라진 자리로 세운다
.tp.is-demo .tp__layer--sky { transform: translateX(4px) !important; }
.tp.is-demo .tp__layer--hill { transform: translateX(12px) !important; }
.tp.is-demo .tp__layer--field { transform: translateX(24px) !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);
}
.tp {
display: grid;
justify-items: center;
gap: 12px;
width: 100%;
}
.tp__window {
--px: 0;
position: relative;
overflow: hidden;
width: clamp(232px, 60vw, 296px);
height: clamp(104px, 28vw, 136px);
border-radius: 16px;
border: 3px solid #17141a;
background: #2f6df6;
}
.tp__window:focus-visible {
outline: 3px solid #17141a;
outline-offset: 8px;
}
.tp__layer {
position: absolute;
left: -12%;
right: -12%;
transform: translateX(calc(var(--px) * var(--depth)));
}
.tp__layer--sky {
--depth: 8px;
top: 0;
height: 62%;
background: linear-gradient(180deg, #2f6df6 0%, rgba(255, 247, 230, 0.5) 100%);
}
.tp__sun {
position: absolute;
left: 62%;
top: 22%;
width: 26px;
height: 26px;
border-radius: 50%;
background: #ffd23f;
box-shadow: 0 0 20px rgba(255, 210, 63, 0.9);
}
.tp__layer--hill {
--depth: 22px;
bottom: 26%;
height: 48%;
}
.tp__peak {
position: absolute;
bottom: 0;
height: 100%;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.tp__peak--l {
left: 14%;
width: 34%;
background: rgba(23, 20, 26, 0.78);
}
.tp__peak--r {
left: 46%;
width: 44%;
height: 82%;
background: #17141a;
}
.tp__layer--field {
--depth: 44px;
bottom: 0;
height: 30%;
background: repeating-linear-gradient(96deg, #17141a 0 14px, rgba(23, 20, 26, 0.82) 14px 28px);
border-top: 3px solid #17141a;
}
.tp__caption {
position: absolute;
left: 12px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.92);
color: #17141a;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
}
.tp__hint {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.tp.is-demo .tp__layer {
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tp.is-demo .tp__layer--sky {
animation-name: tpSky;
}
.tp.is-demo .tp__layer--hill {
animation-name: tpHill;
}
.tp.is-demo .tp__layer--field {
animation-name: tpField;
}
@keyframes tpSky {
0%, 6% {
transform: translateX(-8px);
}
46%, 58% {
transform: translateX(8px);
}
94%, 100% {
transform: translateX(-8px);
}
}
@keyframes tpHill {
0%, 6% {
transform: translateX(-22px);
}
46%, 58% {
transform: translateX(22px);
}
94%, 100% {
transform: translateX(-22px);
}
}
@keyframes tpField {
0%, 6% {
transform: translateX(-44px);
}
46%, 58% {
transform: translateX(44px);
}
94%, 100% {
transform: translateX(-44px);
}
}
@media (max-width: 340px) {
.tp__caption {
left: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo .tp__layer {
animation: none !important;
}
.tp.is-demo .tp__layer--sky {
transform: translateX(4px) !important;
}
.tp.is-demo .tp__layer--hill {
transform: translateX(12px) !important;
}
.tp.is-demo .tp__layer--field {
transform: translateX(24px) !important;
}
}var tp = document.getElementById('tp');
var frame = document.getElementById('window');
var aim = 0, now = 0, running = false;
// 겹마다 다른 계수는 CSS 가 --depth 로 갖고 있고, JS 는 -1~1 한 값(--px)만 넘긴다
function step() {
now += (aim - now) * 0.12;
frame.style.setProperty('--px', now.toFixed(3));
if (Math.abs(aim - now) < 0.002) { frame.style.setProperty('--px', aim.toFixed(3)); running = false; return; }
requestAnimationFrame(step);
}
function start() { if (!running) { running = true; requestAnimationFrame(step); } }
frame.addEventListener('pointermove', function (e) {
tp.classList.remove('is-demo');
var r = frame.getBoundingClientRect();
aim = Math.max(-1, Math.min(1, (e.clientX - r.left) / r.width * 2 - 1));
start();
});
frame.addEventListener('pointerleave', function () { aim = 0; start(); });
// 키보드 경로: 좌우 화살표로 같은 범위를 0.25씩 움직인다
frame.setAttribute('tabindex', '0');
frame.addEventListener('keydown', function (e) {
var move = e.key === 'ArrowLeft' ? -0.25 : e.key === 'ArrowRight' ? 0.25 : 0;
if (!move) { return; }
e.preventDefault();
tp.classList.remove('is-demo');
aim = Math.max(-1, Math.min(1, aim + move));
start();
});// TerrainPanorama — vanilla 판(run/290/demo/07-pointer-3d-terrain-panorama/index.html) 의 React 포트.
// 겹마다 다른 이동 계수(--depth)는 CSS 가 갖고 있고, 여기서는 포인터 x 를 -1~1 로 바꾼 값(--px) 하나만 넘긴다.
// 그 한 값이 requestAnimationFrame 루프에서 0.12 씩 목표로 다가가므로 겹이 관성 있게 따라온다.
// 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./TerrainPanorama.module.scss";
export type TerrainPanoramaProps = {
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 KeyEvt = { key: string; preventDefault(): void };
const WINDOW_LABEL = "하늘, 산, 들판 세 겹이 마우스를 따라 서로 다른 거리로 움직이는 풍경";
const CAPTION = "제주 남쪽 오름";
const HINT = "마우스를 좌우로 옮기면 겹이 갈라집니다";
function clampAim(value: number): number {
return Math.max(-1, Math.min(1, value));
}
export default function TerrainPanorama({
duration = 2000,
easing = "linear",
color = "#17141a",
}: TerrainPanoramaProps) {
const frameRef = useRef<HTMLDivElement | null>(null);
const aimRef = useRef<number>(0) as Box<number>;
const nowRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const rafRef = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 함수 선언이라 자기 이름을 안전하게 다시 부른다 — 프레임마다 목표까지 남은 거리의 12% 를 좁힌다
function step(): void {
const frame = frameRef.current;
if (!frame) {
runningRef.current = false;
return;
}
nowRef.current += (aimRef.current - nowRef.current) * 0.12;
frame.style.setProperty("--px", nowRef.current.toFixed(3));
if (Math.abs(aimRef.current - nowRef.current) < 0.002) {
frame.style.setProperty("--px", aimRef.current.toFixed(3));
runningRef.current = false;
return;
}
rafRef.current = requestAnimationFrame(step);
}
function start(): void {
if (!runningRef.current) {
runningRef.current = true;
rafRef.current = requestAnimationFrame(step);
}
}
useEffect(() => {
return () => {
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const frame = frameRef.current;
if (!frame) return;
setDemo(false);
const r = frame.getBoundingClientRect();
aimRef.current = clampAim(((event.clientX - r.left) / r.width) * 2 - 1);
start();
}
function onPointerLeave(): void {
aimRef.current = 0;
start();
}
// 키보드 경로: 좌우 화살표로 같은 범위를 0.25씩 움직인다
function onKeyDown(event: KeyEvt): void {
const move = event.key === "ArrowLeft" ? -0.25 : event.key === "ArrowRight" ? 0.25 : 0;
if (!move) return;
event.preventDefault();
setDemo(false);
aimRef.current = clampAim(aimRef.current + move);
start();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div
className={styles.window}
ref={frameRef}
role="img"
aria-label={WINDOW_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<span className={`${styles.layer} ${styles.layerSky}`} aria-hidden="true">
<span className={styles.sun}></span>
</span>
<span className={`${styles.layer} ${styles.layerHill}`} aria-hidden="true">
<i className={`${styles.peak} ${styles.peakL}`}></i>
<i className={`${styles.peak} ${styles.peakR}`}></i>
</span>
<span className={`${styles.layer} ${styles.layerField}`} aria-hidden="true"></span>
<span className={styles.caption}>{CAPTION}</span>
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}07 前後に分かれる風景パノラマ — 計測 累積面積 14.113% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · マウス 追従 アニメーション css で作る3D場面9選
このデモが属する記事
マウス 追従 アニメーション css で作る3D場面9選マウス追従アニメーションとは、ポインタの位置に反応して要素が回る CSS 3D 回転の場面です。「マウス 追従 アニメーション css」の9つの場面は perspective と preserve-3d、素の JavaScript のポイン この記事のデモをすべて見る


