css 버튼 입체감 9가지 — 클릭 효과까지 커스텀 코드
css 버튼 입체감을 만드는 계산은 늘 같은 질문에서 출발합니다. 커서가 버튼 위 어디쯤 있는지를 각도 값 하나로 바꾸면 평면이 어떻게 입체로 보일까?
01 pointer-tilt-3d-cursor-rotate
02 pointer-tilt-3d-specular-sweep
03 pointer-tilt-3d-shadow-stack
05 pointer-tilt-3d-edge-bevel
06 pointer-tilt-3d-press-release-spring
07 pointer-tilt-3d-grid-parallax
08 pointer-tilt-3d-rim-glow
09 pointer-tilt-3d-idle-drift
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 커서 좌표 값이 회전시키는 버튼
- 02 광원 각도 값이 훑고 지나가는 버튼 하이라이트
- 03 기울기 값이 쌓는 그림자 층 버튼
- 04 깊이 값이 아이콘만 따로 밀어내는 버튼
- 05 각도 값이 테두리를 밝히는 버튼
- 06 눌림 깊이 값이 스프링으로 되돌아오는 버튼
- 07 기울기 값마다 어긋나는 격자무늬 버튼
- 08 기울기 크기 값이 세기를 정하는 테두리 광
- 09 포인터가 없을 때 값이 스스로 흔드는 버튼
버튼 아홉 개, 값 하나가 자리를 바꿔가며 만드는 입체감
아홉 개 모두 출발점은 같습니다. getBoundingClientRect() 로 버튼 안에서 커서 좌표를 -1~1 범위로 정규화하고, 그 값을 perspective·transform-style: preserve-3d 가 걸린 장면 위에서 rotateX·rotateY 로 바꿔 씁니다. 다른 점은 같은 정규화 좌표 값을 어디에 한 번 더 흘려보내느냐입니다 — 01 은 광택 위치로, 03 은 그림자 층 수로, 08 은 테두리 번짐 반경으로 흘려보냅니다.
포인터가 없을 때는 아홉 개 전부 Math.random 없이 고정 시드 사인 곡선으로 좌표를 합성해 자동 시연을 돌립니다. 실 방문자의 포인터가 잡히는 순간 hasPointer 플래그가 켜지면서 합성 좌표는 실제 좌표로 조용히 넘어갑니다.
01커서 좌표 값이 기울기 각도로 바뀌는 버튼
정규화된 커서 좌표를 MAX_DEG 만큼 곱해 rotateX·rotateY 로 바로 씁니다. 같은 좌표 값이 광택 위치와 그림자 번짐 폭도 함께 정합니다.

<main class="stage">
<div class="cr is-demo" id="cr">
<p class="cr__hint"><span>커서를 버튼 위에서 움직이면 좌표 값이 rotateX·rotateY 를 계산합니다</span></p>
<div class="cr__scene" id="scene">
<button class="cr__btn" id="btn" type="button" aria-label="지금 시작하기 — 커서를 따라 기울어지는 버튼">
<span class="cr__sheen" id="sheen" aria-hidden="true"></span>
<span class="cr__label">지금 시작하기</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.cr {
display: grid;
gap: $sp-4;
justify-items: center;
}
.cr__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-cream, .78);
}
.cr__scene {
perspective: 640px;
padding: $sp-6;
}
.cr__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
letter-spacing: .01em;
transform-style: preserve-3d;
box-shadow: $shadow-raised;
overflow: hidden;
will-change: transform;
}
.cr__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.cr__sheen {
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: soft-light;
}
.cr__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.cr__btn { transition: none; }
}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);
}
.cr {
display: grid;
gap: 16px;
justify-items: center;
}
.cr__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(255, 247, 230, 0.78);
}
.cr__scene {
perspective: 640px;
padding: 24px;
}
.cr__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.01em;
transform-style: preserve-3d;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
will-change: transform;
}
.cr__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.cr__sheen {
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: soft-light;
}
.cr__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.cr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('cr');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var sheen = document.getElementById('sheen');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MAX_DEG = 14; // 커서가 버튼 가장자리에 닿았을 때의 최대 회전각
var LERP = 0.16; // 관성 lerp 계수 — 값이 목표를 따라잡는 속도
var targetNX = 0, targetNY = 0;
var curNX = 0, curNY = 0;
var hasPointer = false;
var clickTimer = null;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 포인터가 없을 때(자동 시연·헤드리스 측정) 같은 계산을 돌리기 위한 합성 좌표 — 두 축의 위상을 어긋낸 리사주 궤적
function syntheticTarget(t) {
return [Math.sin(t * 1.15), Math.sin(t * 0.82 + 1.05)];
}
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var sx = 50 + nx * 34, sy = 50 + ny * 34;
sheen.style.background = 'radial-gradient(circle at ' + sx.toFixed(1) + '% ' + sy.toFixed(1) + '%, rgba(255,247,230,.5), rgba(255,247,230,0) 58%)';
var depth = Math.min(1, Math.sqrt(nx * nx + ny * ny));
btn.style.boxShadow = '0 ' + (10 + depth * 16).toFixed(1) + 'px ' + (18 + depth * 22).toFixed(1) + 'px rgba(8,6,12,' + (0.30 + depth * 0.16).toFixed(2) + ')';
}
var t0 = null;
function loop(now) {
if (t0 === null) { t0 = now; }
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNX = 0.92; targetNY = -0.62;
if (reduce) { render(targetNX, targetNY); }
clearTimeout(clickTimer);
clickTimer = setTimeout(function () { hasPointer = false; targetNX = 0; targetNY = 0; if (reduce) { render(targetNX, targetNY); } }, 260);
});
btn.addEventListener('keydown', function (e) {
var step = 0.24;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) { return; }
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(0.5, -0.34);
} else {
requestAnimationFrame(loop);
}
})();// CursorRotateButton — vanilla 판(run/393/demo/01-pointer-tilt-3d-cursor-rotate/index.html) 의 React 포트.
// 버튼 안 커서 좌표를 -1~1 로 정규화해 목표값(target)을 만들고, 관성 lerp 로 현재값(cur)이 따라간다.
// 포인터가 없을 때(자동 시연)는 위상이 어긋난 두 사인파(리사주 궤적)를 목표값으로 써 같은 계산을 돌린다.
// 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./CursorRotateButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const MAX_DEG = 14;
const LERP = 0.16;
const LABEL = "지금 시작하기 — 커서를 따라 기울어지는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 1.15), Math.sin(t * 0.82 + 1.05)];
}
export default function CursorRotateButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const sheenRef = useRef<HTMLSpanElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const clickTimerRef = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
const sheen = sheenRef.current;
if (!btn || !sheen) return;
const rotY = nx * MAX_DEG;
const rotX = -ny * MAX_DEG;
btn.style.transform = "rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg)";
const sx = 50 + nx * 34;
const sy = 50 + ny * 34;
sheen.style.background =
"radial-gradient(circle at " + sx.toFixed(1) + "% " + sy.toFixed(1) + "%, rgba(255,247,230,.5), rgba(255,247,230,0) 58%)";
const depth = Math.min(1, Math.sqrt(nx * nx + ny * ny));
btn.style.boxShadow =
"0 " + (10 + depth * 16).toFixed(1) + "px " + (18 + depth * 22).toFixed(1) + "px rgba(8,6,12," + (0.3 + depth * 0.16).toFixed(2) + ")";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.5, -0.34);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = 0.92;
targetYRef.current = -0.62;
if (reduce) render(targetXRef.current, targetYRef.current);
window.clearTimeout(clickTimerRef.current);
clickTimerRef.current = window.setTimeout(() => {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
if (reduce) render(targetXRef.current, targetYRef.current);
}, 260);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.24;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.cr, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>커서를 버튼 위에서 움직이면 좌표 값이 rotateX·rotateY 를 계산합니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.sheen} ref={sheenRef} aria-hidden="true" />
<span className={styles.label}>지금 시작하기</span>
</button>
</div>
</div>
</main>
);
}01 · 커서 좌표 값이 회전시키는 버튼 — 측정 누적면적 3.144% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var sx = 50 + nx * 34, sy = 50 + ny * 34;
sheen.style.background = 'radial-gradient(circle at ' + sx.toFixed(1) + '% ' + sy.toFixed(1) + '%, rgba(255,247,230,.5), rgba(255,247,230,0) 58%)';
var depth = Math.min(1, Math.sqrt(nx * nx + ny * ny));
btn.style.boxShadow = '0 ' + (10 + depth * 16).toFixed(1) + 'px ' + (18 + depth * 22).toFixed(1) + 'px rgba(8,6,12,' + (0.30 + depth * 0.16).toFixed(2) + ')';
}
var t0 = null;
function loop(now) {
if (t0 === null) { t0 = now; }
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
css 버튼 마우스 오버 효과 중에서도 가장 단순한 이 축은, curNX·curNY 가 목표 좌표를 향해 LERP 비율만큼만 다가가는 관성 값이라 커서가 멈춘 지점과 화면 기울기 사이에 매 프레임 약간의 지연을 남깁니다.
02기울기 값이 광원 각도로 바뀌어 훑고 지나가는 버튼
정규화 좌표를 극좌표(angle·mag)로 바꿔 conic-gradient 광택 줄기의 시작 각도로 씁니다. 기울기 방향으로 바닥 그림자가 옮겨가고, 배경에는 반사광 레이어가 한 겹 더 깔립니다.

<main class="stage">
<div class="ss is-demo" id="ss">
<p class="ss__hint"><span>포인터 각도를 계산해 광택 줄기가 그 방향으로 흐릅니다</span></p>
<div class="ss__scene" id="scene">
<span class="ss__glow" id="glow" aria-hidden="true"></span>
<span class="ss__floor" id="floor" aria-hidden="true"></span>
<button class="ss__btn" id="btn" type="button" aria-label="프리미엄 결제 — 포인터 각도로 광택이 흐르는 버튼">
<span class="ss__sweep" id="sweep" aria-hidden="true"></span>
<span class="ss__label">프리미엄 결제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.ss {
display: grid;
gap: $sp-4;
justify-items: center;
}
.ss__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-ink, .78);
}
.ss__scene {
position: relative;
perspective: 640px;
padding: $sp-6;
}
.ss__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-6 $sp-12;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: $shadow-raised;
overflow: hidden;
will-change: transform;
}
.ss__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.ss__sweep {
position: absolute;
inset: -58%;
pointer-events: none;
}
// 배경 반사광 — 각도·세기에 따라 위치만 이동(% 좌표, transform 없음 → 폭 넘침 없음)
.ss__glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
}
// 기울기에 연동된 바닥 그림자 — 버튼 면적만으로는 안 보이던 움직임을 면으로 키운다
.ss__floor {
position: absolute;
left: 50%;
bottom: 2px;
width: clamp(150px, 58vw, 280px);
height: clamp(48px, 18.6vw, 90px);
margin-left: clamp(-140px, -29vw, -75px);
border-radius: 50%;
background: radial-gradient(closest-side, rgba($subject-ink, .92), rgba($subject-ink, 0) 72%);
filter: blur(6px);
pointer-events: none;
}
.ss__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.ss__btn { transition: none; }
}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);
}
.ss {
display: grid;
gap: 16px;
justify-items: center;
}
.ss__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(23, 20, 26, 0.78);
}
.ss__scene {
position: relative;
perspective: 640px;
padding: 24px;
}
.ss__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 24px 48px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
will-change: transform;
}
.ss__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.ss__sweep {
position: absolute;
inset: -58%;
pointer-events: none;
}
.ss__glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
}
.ss__floor {
position: absolute;
left: 50%;
bottom: 2px;
width: clamp(150px, 58vw, 280px);
height: clamp(48px, 18.6vw, 90px);
margin-left: clamp(-140px, -29vw, -75px);
border-radius: 50%;
background: radial-gradient(closest-side, rgba(23, 20, 26, 0.92), rgba(23, 20, 26, 0) 72%);
filter: blur(6px);
pointer-events: none;
}
.ss__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.ss__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ss');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var sweep = document.getElementById('sweep');
var floor = document.getElementById('floor');
var glow = document.getElementById('glow');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SWEEP_WIDTH = 46; // 광택 줄기의 각도 폭(deg) — 불변
var LERP = 0.2;
var targetAngle = 0, curAngle = 0;
var targetMag = 0, curMag = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 합성 포인터 각도 — 원을 그리며 도는 위상(자동 시연 · 헤드리스 측정)
// 진폭을 넓혀 자동 시연에서도 기울기가 가장자리(최대 각)까지 실제로 닿게 한다(경로 상수만 조정)
function syntheticAngle(t) { return t * 74; }
function syntheticMag(t) { return Math.min(1, 0.6 + 0.55 * Math.sin(t * 1.3)); }
function shortestDelta(from, to) {
var d = (to - from) % 360;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
}
function render(angle, mag) {
var rotY = Math.cos(angle * Math.PI / 180) * mag * 10;
var rotX = Math.sin(angle * Math.PI / 180) * mag * -10;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var a0 = (angle - SWEEP_WIDTH / 2).toFixed(1);
var a1 = angle.toFixed(1);
var a2 = (angle + SWEEP_WIDTH / 2).toFixed(1);
// 대비·폭(요소 크기)을 키워 광택 줄기가 버튼 면 전체를 가로지르게 — SWEEP_WIDTH(각도 폭) 값 자체는 그대로
var alpha = (0.46 + mag * 0.54).toFixed(2);
sweep.style.background = 'conic-gradient(from ' + a0 + 'deg at 50% 50%, rgba(23,20,26,0), rgba(23,20,26,' + alpha + ') ' + (SWEEP_WIDTH / 2).toFixed(1) + 'deg, rgba(23,20,26,0) ' + SWEEP_WIDTH.toFixed(1) + 'deg)';
sweep.style.opacity = String(0.8 + mag * 0.4);
// 기울기에 연동된 바닥 그림자 — 버튼이 기운 방향으로 그림자가 옮겨가며 면적을 만든다
var floorShift = Math.cos(angle * Math.PI / 180) * mag * 34;
var floorScale = 1 + mag * 0.28;
floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scaleX(' + floorScale.toFixed(2) + ')';
floor.style.opacity = (0.08 + mag * 0.88).toFixed(2);
// 배경에 반사광 레이어 — % 좌표만 써서 이동시키므로 폭 넘침 위험이 없다(transform 미사용)
var gx = (50 + Math.cos(angle * Math.PI / 180) * mag * 38).toFixed(0);
var gy = (50 + Math.sin(angle * Math.PI / 180) * mag * 38).toFixed(0);
glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.18 + mag * 0.5).toFixed(2) + '), rgba(255,255,255,0) 60%)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
targetAngle = syntheticAngle(t) % 360;
targetMag = syntheticMag(t);
}
curAngle += shortestDelta(curAngle, targetAngle) * LERP;
curMag += (targetMag - curMag) * LERP;
render(curAngle, curMag);
requestAnimationFrame(loop);
}
function angleFromEvent(e) {
var r = scene.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var dx = e.clientX - cx, dy = e.clientY - cy;
var ang = Math.atan2(dy, dx) * 180 / Math.PI;
var mag = clamp(Math.sqrt(dx * dx + dy * dy) / (r.width / 2), 0, 1);
return [ang, mag];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var a = angleFromEvent(e);
targetAngle = a[0]; targetMag = a[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetMag = 0.15;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetMag = 1;
if (reduce) { render(targetAngle, targetMag); }
setTimeout(function () { targetMag = hasPointer ? 0.5 : 0.15; if (reduce) { render(targetAngle, targetMag); } }, 220);
});
btn.addEventListener('keydown', function (e) {
var step = 18;
if (e.key === 'ArrowLeft') { e.preventDefault(); root.classList.remove('is-demo'); hasPointer = true; targetAngle -= step; }
else if (e.key === 'ArrowRight') { e.preventDefault(); root.classList.remove('is-demo'); hasPointer = true; targetAngle += step; }
else { return; }
if (reduce) { render(targetAngle, targetMag); }
});
if (reduce) {
render(45, 0.4);
} else {
requestAnimationFrame(loop);
}
})();// SpecularSweepButton — vanilla 판(run/393/demo/02-pointer-tilt-3d-specular-sweep/index.html) 의 React 포트.
// 포인터와 중심의 각도(atan2)·거리 비율을 계산해 conic-gradient 광택 줄기의 각도·세기로 바꾼다.
// 각도는 최단 경로(shortest delta)로 lerp 해 360→0 경계에서 튀지 않는다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./SpecularSweepButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const SWEEP_WIDTH = 46; // 불변
const LERP = 0.2;
const LABEL = "프리미엄 결제 — 포인터 각도로 광택이 흐르는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticAngle(t: number): number {
return t * 74;
}
// 진폭을 넓혀 자동 시연에서도 기울기가 가장자리(최대 각)까지 실제로 닿게 한다(경로 상수만 조정)
function syntheticMag(t: number): number {
return Math.min(1, 0.6 + 0.55 * Math.sin(t * 1.3));
}
function shortestDelta(from: number, to: number): number {
let d = (to - from) % 360;
if (d > 180) d -= 360;
if (d < -180) d += 360;
return d;
}
export default function SpecularSweepButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const sweepRef = useRef<HTMLSpanElement | null>(null);
const floorRef = useRef<HTMLSpanElement | null>(null);
const glowRef = useRef<HTMLSpanElement | null>(null);
const targetAngleRef = useRef<number>(0) as Box<number>;
const curAngleRef = useRef<number>(0) as Box<number>;
const targetMagRef = useRef<number>(0) as Box<number>;
const curMagRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const clickTimerRef = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(angle: number, mag: number): void {
const btn = btnRef.current;
const sweep = sweepRef.current;
const floor = floorRef.current;
const glow = glowRef.current;
if (!btn || !sweep || !floor || !glow) return;
const rotY = Math.cos((angle * Math.PI) / 180) * mag * 10;
const rotX = Math.sin((angle * Math.PI) / 180) * mag * -10;
btn.style.transform = "rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg)";
const a0 = (angle - SWEEP_WIDTH / 2).toFixed(1);
const alpha = (0.46 + mag * 0.54).toFixed(2);
sweep.style.background =
"conic-gradient(from " + a0 + "deg at 50% 50%, rgba(23,20,26,0), rgba(23,20,26," + alpha + ") " + (SWEEP_WIDTH / 2).toFixed(1) + "deg, rgba(23,20,26,0) " + SWEEP_WIDTH.toFixed(1) + "deg)";
sweep.style.opacity = String(0.8 + mag * 0.4);
const floorShift = Math.cos((angle * Math.PI) / 180) * mag * 34;
const floorScale = 1 + mag * 0.28;
floor.style.transform = "translateX(" + floorShift.toFixed(1) + "px) scaleX(" + floorScale.toFixed(2) + ")";
floor.style.opacity = (0.08 + mag * 0.88).toFixed(2);
const gx = (50 + Math.cos((angle * Math.PI) / 180) * mag * 38).toFixed(0);
const gy = (50 + Math.sin((angle * Math.PI) / 180) * mag * 38).toFixed(0);
glow.style.background = "radial-gradient(circle at " + gx + "% " + gy + "%, rgba(255,255,255," + (0.18 + mag * 0.5).toFixed(2) + "), rgba(255,255,255,0) 60%)";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(45, 0.4);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
targetAngleRef.current = syntheticAngle(t) % 360;
targetMagRef.current = syntheticMag(t);
}
curAngleRef.current += shortestDelta(curAngleRef.current, targetAngleRef.current) * LERP;
curMagRef.current += (targetMagRef.current - curMagRef.current) * LERP;
render(curAngleRef.current, curMagRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function angleFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const ang = (Math.atan2(dy, dx) * 180) / Math.PI;
const mag = clamp(Math.sqrt(dx * dx + dy * dy) / (r.width / 2), 0, 1);
return [ang, mag];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const a = angleFromEvent(event);
targetAngleRef.current = a[0];
targetMagRef.current = a[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetMagRef.current = 0.15;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetMagRef.current = 1;
if (reduce) render(targetAngleRef.current, targetMagRef.current);
window.clearTimeout(clickTimerRef.current);
clickTimerRef.current = window.setTimeout(() => {
targetMagRef.current = hasPointerRef.current ? 0.5 : 0.15;
if (reduce) render(targetAngleRef.current, targetMagRef.current);
}, 220);
}
function onKeyDown(event: KeyEvt): void {
const step = 18;
if (event.key === "ArrowLeft") {
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetAngleRef.current -= step;
} else if (event.key === "ArrowRight") {
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetAngleRef.current += step;
} else {
return;
}
if (reduce) render(targetAngleRef.current, targetMagRef.current);
}
const cls = [styles.ss, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>포인터 각도를 계산해 광택 줄기가 그 방향으로 흐릅니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<span className={styles.glow} ref={glowRef} aria-hidden="true" />
<span className={styles.floor} ref={floorRef} aria-hidden="true" />
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.sweep} ref={sweepRef} aria-hidden="true" />
<span className={styles.label}>프리미엄 결제</span>
</button>
</div>
</div>
</main>
);
}02 · 광원 각도 값이 훑고 지나가는 버튼 하이라이트 — 측정 누적면적 12.364% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(angle, mag) {
var rotY = Math.cos(angle * Math.PI / 180) * mag * 10;
var rotX = Math.sin(angle * Math.PI / 180) * mag * -10;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var a0 = (angle - SWEEP_WIDTH / 2).toFixed(1);
var a1 = angle.toFixed(1);
var a2 = (angle + SWEEP_WIDTH / 2).toFixed(1);
// 대비·폭(요소 크기)을 키워 광택 줄기가 버튼 면 전체를 가로지르게 — SWEEP_WIDTH(각도 폭) 값 자체는 그대로
var alpha = (0.46 + mag * 0.54).toFixed(2);
sweep.style.background = 'conic-gradient(from ' + a0 + 'deg at 50% 50%, rgba(23,20,26,0), rgba(23,20,26,' + alpha + ') ' + (SWEEP_WIDTH / 2).toFixed(1) + 'deg, rgba(23,20,26,0) ' + SWEEP_WIDTH.toFixed(1) + 'deg)';
sweep.style.opacity = String(0.8 + mag * 0.4);
// 기울기에 연동된 바닥 그림자 — 버튼이 기운 방향으로 그림자가 옮겨가며 면적을 만든다
var floorShift = Math.cos(angle * Math.PI / 180) * mag * 34;
var floorScale = 1 + mag * 0.28;
floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scaleX(' + floorScale.toFixed(2) + ')';
floor.style.opacity = (0.08 + mag * 0.88).toFixed(2);
// 배경에 반사광 레이어 — % 좌표만 써서 이동시키므로 폭 넘침 위험이 없다(transform 미사용)
var gx = (50 + Math.cos(angle * Math.PI / 180) * mag * 38).toFixed(0);
var gy = (50 + Math.sin(angle * Math.PI / 180) * mag * 38).toFixed(0);
glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.18 + mag * 0.5).toFixed(2) + '), rgba(255,255,255,0) 60%)';
}
누적면적이 12.4% 로 아홉 개 중 두 번째로 높은 이유는, 광택 줄기·바닥 그림자·반사광 레이어 세 겹이 같은 angle·mag 값 하나로 동시에 움직이기 때문입니다.
03기울기 값이 그림자 층 세 겹으로 쌓이는 버튼
정규화 좌표의 반대 방향으로 for 반복문이 그림자 세 겹을 쌓습니다. 층마다 오프셋·번짐·투명도가 조금씩 벌어져 두께감을 만듭니다.

<main class="stage">
<div class="sk is-demo" id="sk">
<p class="sk__hint"><span>기운 방향의 반대쪽으로 그림자 세 겹이 어긋나 쌓입니다</span></p>
<div class="sk__scene" id="scene">
<button class="sk__btn" id="btn" type="button" aria-label="지금 구매하기 — 기울기에 따라 그림자가 쌓이는 버튼">
<span class="sk__label">지금 구매하기</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.sk {
display: grid;
gap: $sp-4;
justify-items: center;
}
.sk__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: $subject-ink;
}
.sk__scene {
perspective: 640px;
padding: $sp-6;
}
.sk__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
will-change: transform;
}
.sk__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.sk__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.sk__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sk {
display: grid;
gap: 16px;
justify-items: center;
}
.sk__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: #17141a;
}
.sk__scene {
perspective: 640px;
padding: 24px;
}
.sk__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
will-change: transform;
}
.sk__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.sk__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.sk__btn {
transition: none;
}
}(function () {
var root = document.getElementById('sk');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MAX_DEG = 12;
var LERP = 0.15;
var targetNX = 0, targetNY = 0;
var curNX = 0, curNY = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function syntheticTarget(t) { return [Math.sin(t * 0.95 + 0.4), Math.sin(t * 1.35)]; }
// 그림자 3겹 — 기운 반대 방향으로 오프셋이 커지고, 층마다 흐림·투명도가 달라 깊이가 읽힌다
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var ox = -nx, oy = -ny;
var layers = [];
for (var i = 1; i <= 3; i++) {
var d = i * 7;
var blur = i * 9;
var alpha = (0.22 - i * 0.03).toFixed(2);
layers.push((ox * d).toFixed(1) + 'px ' + (oy * d + i * 3).toFixed(1) + 'px ' + blur + 'px rgba(23,20,26,' + alpha + ')');
}
btn.style.boxShadow = layers.join(', ');
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNX = -0.8; targetNY = 0.5;
if (reduce) { render(targetNX, targetNY); }
setTimeout(function () { if (!hasPointer) { targetNX = 0; targetNY = 0; if (reduce) { render(targetNX, targetNY); } } }, 240);
});
btn.addEventListener('keydown', function (e) {
var step = 0.22;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) return;
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(-0.4, 0.3);
} else {
requestAnimationFrame(loop);
}
})();// ShadowStackButton — vanilla 판(run/393/demo/03-pointer-tilt-3d-shadow-stack/index.html) 의 React 포트.
// 정규화된 포인터 좌표의 반대 방향으로 그림자 3겹을 서로 다른 거리·흐림·투명도로 쌓아 깊이를 만든다.
// 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ShadowStackButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const MAX_DEG = 12;
const LERP = 0.15;
const LABEL = "지금 구매하기 — 기울기에 따라 그림자가 쌓이는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 0.95 + 0.4), Math.sin(t * 1.35)];
}
export default function ShadowStackButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
if (!btn) return;
const rotY = nx * MAX_DEG;
const rotX = -ny * MAX_DEG;
btn.style.transform = "rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg)";
const ox = -nx;
const oy = -ny;
const layers: string[] = [];
for (let i = 1; i <= 3; i++) {
const d = i * 7;
const blur = i * 9;
const alpha = (0.22 - i * 0.03).toFixed(2);
layers.push((ox * d).toFixed(1) + "px " + (oy * d + i * 3).toFixed(1) + "px " + blur + "px rgba(23,20,26," + alpha + ")");
}
btn.style.boxShadow = layers.join(", ");
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(-0.4, 0.3);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = -0.8;
targetYRef.current = 0.5;
if (reduce) render(targetXRef.current, targetYRef.current);
window.setTimeout(() => {
if (!hasPointerRef.current) {
targetXRef.current = 0;
targetYRef.current = 0;
if (reduce) render(targetXRef.current, targetYRef.current);
}
}, 240);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.22;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.sk, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>기운 방향의 반대쪽으로 그림자 세 겹이 어긋나 쌓입니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.label}>지금 구매하기</span>
</button>
</div>
</div>
</main>
);
}03 · 기울기 값이 쌓는 그림자 층 버튼 — 측정 누적면적 6.126% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var ox = -nx, oy = -ny;
var layers = [];
for (var i = 1; i <= 3; i++) {
var d = i * 7;
var blur = i * 9;
var alpha = (0.22 - i * 0.03).toFixed(2);
layers.push((ox * d).toFixed(1) + 'px ' + (oy * d + i * 3).toFixed(1) + 'px ' + blur + 'px rgba(23,20,26,' + alpha + ')');
}
btn.style.boxShadow = layers.join(', ');
}
층마다 alpha 가 0.22 - i * 0.03 으로 옅어지므로, 가장 안쪽 층이 가장 짙고 바깥으로 갈수록 흐려지는 순서가 저절로 지켜집니다.
04깊이 값이 아이콘과 글자를 따로 밀어내는 버튼
기울기는 8도로 작게 두고, 같은 정규화 좌표를 ICON_DEPTH·LABEL_DEPTH 두 상수에 각각 곱해 아이콘과 글자를 서로 다른 거리로 translate3d 시킵니다.
<main class="stage">
<div class="ip is-demo" id="ip">
<p class="ip__hint"><span>같은 좌표를 층마다 다른 배수로 곱해 아이콘이 글자보다 더 크게 움직입니다</span></p>
<div class="ip__scene" id="scene">
<button class="ip__btn" id="btn" type="button" aria-label="다운로드 — 아이콘이 앞층에서 시차로 움직이는 버튼">
<span class="ip__icon" id="icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M184 120v61.65a8 8 0 0 1-2.34 5.65l-34.35 34.35a8 8 0 0 1-13.57-4.53L128 176Zm-48-48H74.35a8 8 0 0 0-5.65 2.34l-34.35 34.35a8 8 0 0 0 4.53 13.57L80 128ZM40 216c37.65 0 50.69-19.69 54.56-28.18l-26.38-26.38C59.69 165.31 40 178.35 40 216" opacity=".35"/><path d="M223.85 47.12a16 16 0 0 0-15-15c-12.58-.75-44.73.4-71.41 27.07L132.69 64H74.36A15.9 15.9 0 0 0 63 68.68L28.7 103a16 16 0 0 0 9.07 27.16l38.47 5.37l44.21 44.21l5.37 38.49a15.94 15.94 0 0 0 10.78 12.92a16.1 16.1 0 0 0 5.1.83a15.9 15.9 0 0 0 11.3-4.68l34.32-34.3a15.9 15.9 0 0 0 4.68-11.36v-58.33l4.77-4.77c26.68-26.68 27.83-58.83 27.08-71.42M74.36 80h42.33l-39.53 39.52L40 114.34Zm74.41-9.45a76.65 76.65 0 0 1 59.11-22.47a76.46 76.46 0 0 1-22.42 59.16L128 164.68L91.32 128ZM176 181.64L141.67 216l-5.19-37.17L176 139.31Zm-74.16 9.5C97.34 201 82.29 224 40 224a8 8 0 0 1-8-8c0-42.29 23-57.34 32.86-61.85a8 8 0 0 1 6.64 14.56c-6.43 2.93-20.62 12.36-23.12 38.91c26.55-2.5 36-16.69 38.91-23.12a8 8 0 1 1 14.56 6.64Z"/></g></svg>
</span>
<span class="ip__label" id="label">다운로드</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.ip {
display: grid;
gap: $sp-4;
justify-items: center;
}
.ip__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-ink, .72);
}
.ip__scene {
perspective: 640px;
padding: $sp-6;
}
.ip__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: $shadow-raised;
overflow: hidden;
will-change: transform;
}
.ip__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.ip__icon,
.ip__label {
display: inline-flex;
align-items: center;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.ip__btn { 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);
}
.ip {
display: grid;
gap: 16px;
justify-items: center;
}
.ip__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(23, 20, 26, 0.72);
}
.ip__scene {
perspective: 640px;
padding: 24px;
}
.ip__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
will-change: transform;
}
.ip__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.ip__icon,
.ip__label {
display: inline-flex;
align-items: center;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.ip__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ip');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var icon = document.getElementById('icon');
var label = document.getElementById('label');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LERP = 0.17;
var ICON_DEPTH = 22; // 아이콘 층 배수 — 앞에 떠 있어 더 크게 움직인다
var LABEL_DEPTH = 8; // 글자 층 배수 — 뒤에 있어 적게 움직인다
var targetNX = 0, targetNY = 0;
var curNX = 0, curNY = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function syntheticTarget(t) { return [Math.sin(t * 1.05), Math.sin(t * 0.7 + 2.1)]; }
function render(nx, ny) {
btn.style.transform = 'rotateX(' + (-ny * 8).toFixed(2) + 'deg) rotateY(' + (nx * 8).toFixed(2) + 'deg)';
icon.style.transform = 'translate3d(' + (nx * ICON_DEPTH).toFixed(1) + 'px, ' + (ny * ICON_DEPTH).toFixed(1) + 'px, 0)';
label.style.transform = 'translate3d(' + (nx * LABEL_DEPTH).toFixed(1) + 'px, ' + (ny * LABEL_DEPTH).toFixed(1) + 'px, 0)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNY = -1;
if (reduce) { render(targetNX, targetNY); }
setTimeout(function () { if (!hasPointer) { targetNX = 0; targetNY = 0; if (reduce) { render(targetNX, targetNY); } } }, 260);
});
btn.addEventListener('keydown', function (e) {
var step = 0.24;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) return;
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(0.3, -0.5);
} else {
requestAnimationFrame(loop);
}
})();// IconParallaxButton — vanilla 판(run/393/demo/04-pointer-tilt-3d-icon-parallax/index.html) 의 React 포트.
// 같은 정규화 좌표를 아이콘 층과 글자 층에 서로 다른 배수로 곱해 두 층이 다른 속도로 움직이는
// 시차(parallax) 를 만든다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./IconParallaxButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const ICON_DEPTH = 22;
const LABEL_DEPTH = 8;
const LERP = 0.17;
const LABEL = "다운로드 — 아이콘이 앞층에서 시차로 움직이는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 1.05), Math.sin(t * 0.7 + 2.1)];
}
export default function IconParallaxButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const iconRef = useRef<HTMLSpanElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
const icon = iconRef.current;
const label = labelRef.current;
if (!btn || !icon || !label) return;
btn.style.transform = "rotateX(" + (-ny * 8).toFixed(2) + "deg) rotateY(" + (nx * 8).toFixed(2) + "deg)";
icon.style.transform = "translate3d(" + (nx * ICON_DEPTH).toFixed(1) + "px, " + (ny * ICON_DEPTH).toFixed(1) + "px, 0)";
label.style.transform = "translate3d(" + (nx * LABEL_DEPTH).toFixed(1) + "px, " + (ny * LABEL_DEPTH).toFixed(1) + "px, 0)";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.3, -0.5);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetYRef.current = -1;
if (reduce) render(targetXRef.current, targetYRef.current);
window.setTimeout(() => {
if (!hasPointerRef.current) {
targetXRef.current = 0;
targetYRef.current = 0;
if (reduce) render(targetXRef.current, targetYRef.current);
}
}, 260);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.24;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.ip, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>같은 좌표를 층마다 다른 배수로 곱해 아이콘이 글자보다 더 크게 움직입니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.icon} ref={iconRef} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="22" height="22">
<g fill="currentColor">
<path
d="M184 120v61.65a8 8 0 0 1-2.34 5.65l-34.35 34.35a8 8 0 0 1-13.57-4.53L128 176Zm-48-48H74.35a8 8 0 0 0-5.65 2.34l-34.35 34.35a8 8 0 0 0 4.53 13.57L80 128ZM40 216c37.65 0 50.69-19.69 54.56-28.18l-26.38-26.38C59.69 165.31 40 178.35 40 216"
opacity=".35"
/>
<path d="M223.85 47.12a16 16 0 0 0-15-15c-12.58-.75-44.73.4-71.41 27.07L132.69 64H74.36A15.9 15.9 0 0 0 63 68.68L28.7 103a16 16 0 0 0 9.07 27.16l38.47 5.37l44.21 44.21l5.37 38.49a15.94 15.94 0 0 0 10.78 12.92a16.1 16.1 0 0 0 5.1.83a15.9 15.9 0 0 0 11.3-4.68l34.32-34.3a15.9 15.9 0 0 0 4.68-11.36v-58.33l4.77-4.77c26.68-26.68 27.83-58.83 27.08-71.42M74.36 80h42.33l-39.53 39.52L40 114.34Zm74.41-9.45a76.65 76.65 0 0 1 59.11-22.47a76.46 76.46 0 0 1-22.42 59.16L128 164.68L91.32 128ZM176 181.64L141.67 216l-5.19-37.17L176 139.31Zm-74.16 9.5C97.34 201 82.29 224 40 224a8 8 0 0 1-8-8c0-42.29 23-57.34 32.86-61.85a8 8 0 0 1 6.64 14.56c-6.43 2.93-20.62 12.36-23.12 38.91c26.55-2.5 36-16.69 38.91-23.12a8 8 0 1 1 14.56 6.64Z" />
</g>
</svg>
</span>
<span className={styles.label} ref={labelRef}>
다운로드
</span>
</button>
</div>
</div>
</main>
);
}04 · 깊이 값이 아이콘만 따로 밀어내는 버튼 — 측정 누적면적 2.401% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function syntheticTarget(t) { return [Math.sin(t * 1.05), Math.sin(t * 0.7 + 2.1)]; }
function render(nx, ny) {
btn.style.transform = 'rotateX(' + (-ny * 8).toFixed(2) + 'deg) rotateY(' + (nx * 8).toFixed(2) + 'deg)';
icon.style.transform = 'translate3d(' + (nx * ICON_DEPTH).toFixed(1) + 'px, ' + (ny * ICON_DEPTH).toFixed(1) + 'px, 0)';
label.style.transform = 'translate3d(' + (nx * LABEL_DEPTH).toFixed(1) + 'px, ' + (ny * LABEL_DEPTH).toFixed(1) + 'px, 0)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
ICON_DEPTH 가 LABEL_DEPTH 보다 커야 아이콘이 글자보다 더 크게 밀리면서 두 층이 어긋나 보입니다. 값이 같으면 아이콘과 글자가 한 판처럼 붙어 움직여 패럴랙스가 사라집니다.
05기울기 각도 값이 테두리 베벨을 밝히는 버튼
정규화 좌표를 atan2 로 각도(ang)와 크기(mag)로 나눠, 각도는 conic-gradient 베벨의 시작점으로, 크기는 폭·투명도·측면 두께로 씁니다.

<main class="stage">
<div class="eb is-demo" id="eb">
<p class="eb__hint"><span>기울기 크기를 각도로 바꿔 얇은 테두리 띠가 그 방향을 따라 밝아집니다</span></p>
<div class="eb__scene" id="scene">
<span class="eb__floor" id="floor" aria-hidden="true"></span>
<div class="eb__wrap" id="wrap">
<button class="eb__btn" id="btn" type="button" aria-label="자세히 보기 — 기울기 크기로 테두리가 밝아지는 버튼">
<span class="eb__bevel" id="bevel" aria-hidden="true"></span>
<span class="eb__label">자세히 보기</span>
</button>
<span class="eb__side" id="side" aria-hidden="true"></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.eb {
display: grid;
gap: $sp-4;
justify-items: center;
}
.eb__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-cream, .78);
}
.eb__scene {
position: relative;
perspective: 640px;
padding: $sp-6;
}
// 바닥 그림자 — 기울기 방향으로 옮겨가며 면적을 만든다
.eb__floor {
position: absolute;
left: 50%;
bottom: 4px;
width: clamp(140px, 52vw, 250px);
height: clamp(42px, 15.4vw, 74px);
margin-left: clamp(-125px, -26vw, -70px);
border-radius: 50%;
background: radial-gradient(closest-side, rgba($subject-blue-light, .7), rgba($subject-blue-light, 0) 72%);
filter: blur(10px);
pointer-events: none;
}
.eb__wrap {
position: relative;
display: inline-block;
}
.eb__btn {
position: relative;
border: 1px solid rgba($subject-cream, .16);
cursor: pointer;
padding: $sp-6 $sp-12;
border-radius: $r-control;
background: rgba(255, 247, 230, .06);
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: $shadow-raised;
overflow: hidden;
will-change: transform;
}
.eb__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
.eb__bevel {
position: absolute;
inset: -2px;
border-radius: inherit;
padding: 4px;
pointer-events: none;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
// 측면 두께 — 버튼 아래 얇은 띠가 기운 크기만큼 길어져 옆면(두께)처럼 읽힌다
.eb__side {
position: absolute;
left: 8%;
right: 8%;
bottom: -4px;
height: 10px;
border-radius: 0 0 $r-control $r-control;
background: linear-gradient(180deg, rgba($subject-blue-light, .6), rgba($subject-blue-deep, 0));
transform-origin: top center;
pointer-events: none;
}
.eb__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.eb__btn { transition: none; }
}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);
}
.eb {
display: grid;
gap: 16px;
justify-items: center;
}
.eb__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(255, 247, 230, 0.78);
}
.eb__scene {
position: relative;
perspective: 640px;
padding: 24px;
}
.eb__floor {
position: absolute;
left: 50%;
bottom: 4px;
width: clamp(140px, 52vw, 250px);
height: clamp(42px, 15.4vw, 74px);
margin-left: clamp(-125px, -26vw, -70px);
border-radius: 50%;
background: radial-gradient(closest-side, rgba(91, 140, 248, 0.7), rgba(91, 140, 248, 0) 72%);
filter: blur(10px);
pointer-events: none;
}
.eb__wrap {
position: relative;
display: inline-block;
}
.eb__btn {
position: relative;
border: 1px solid rgba(255, 247, 230, 0.16);
cursor: pointer;
padding: 24px 48px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
will-change: transform;
}
.eb__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.eb__bevel {
position: absolute;
inset: -2px;
border-radius: inherit;
padding: 4px;
pointer-events: none;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
.eb__side {
position: absolute;
left: 8%;
right: 8%;
bottom: -4px;
height: 10px;
border-radius: 0 0 12px 12px;
background: linear-gradient(180deg, rgba(91, 140, 248, 0.6), rgba(10, 79, 232, 0));
transform-origin: top center;
pointer-events: none;
}
.eb__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.eb__btn {
transition: none;
}
}(function () {
var root = document.getElementById('eb');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var bevel = document.getElementById('bevel');
var floor = document.getElementById('floor');
var side = document.getElementById('side');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MAX_DEG = 13; // 불변
var LERP = 0.16;
var targetNX = 0, targetNY = 0;
var curNX = 0, curNY = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function syntheticTarget(t) { return [Math.sin(t * 1.2 + 0.6), Math.sin(t * 0.88)]; }
// 기울기 벡터의 각도·크기 → conic-gradient 회전각·광 폭. 테두리 광의 위치는 기운 방향과 같다
// 폭·대비를 키워 테두리 띠가 버튼 변 전체를 가로지르게 한다 — MAX_DEG(기울기 각) 값은 그대로
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
var ang = Math.atan2(ny, nx) * 180 / Math.PI + 90;
var width = 70 + mag * 110;
var alpha = (0.38 + mag * 0.6).toFixed(2);
bevel.style.background = 'conic-gradient(from ' + ang.toFixed(1) + 'deg, rgba(91,140,248,0), rgba(91,140,248,' + alpha + ') ' + (width / 2).toFixed(1) + 'deg, rgba(91,140,248,0) ' + width.toFixed(1) + 'deg)';
// 바닥 그림자 — 기운 방향으로 옮겨간다
var floorShift = nx * 46;
floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scale(' + (1 + mag * 0.4).toFixed(2) + ')';
floor.style.opacity = (0.14 + mag * 0.7).toFixed(2);
// 측면 두께 — 기운 크기만큼 버튼 아래 「옆면」이 길어져 3D 두께로 읽힌다(MAX_DEG 불변, 순수 표현 한 겹)
side.style.transform = 'scaleY(' + (0.4 + mag * 2.6).toFixed(2) + ')';
side.style.opacity = (0.25 + mag * 0.7).toFixed(2);
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNX = 0.9; targetNY = 0.9;
if (reduce) { render(targetNX, targetNY); }
setTimeout(function () { if (!hasPointer) { targetNX = 0; targetNY = 0; if (reduce) { render(targetNX, targetNY); } } }, 240);
});
btn.addEventListener('keydown', function (e) {
var step = 0.24;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) return;
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(0.5, 0.5);
} else {
requestAnimationFrame(loop);
}
})();// EdgeBevelButton — vanilla 판(run/393/demo/05-pointer-tilt-3d-edge-bevel/index.html) 의 React 포트.
// 기울기 벡터의 각도(atan2)·크기를 conic-gradient 의 회전각·광 폭·투명도로 바꿔 테두리 띠가
// 기운 방향을 따라 밝아지게 한다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./EdgeBevelButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const MAX_DEG = 13; // 불변
const LERP = 0.16;
const LABEL = "자세히 보기 — 기울기 크기로 테두리가 밝아지는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 1.2 + 0.6), Math.sin(t * 0.88)];
}
export default function EdgeBevelButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const bevelRef = useRef<HTMLSpanElement | null>(null);
const floorRef = useRef<HTMLSpanElement | null>(null);
const sideRef = useRef<HTMLSpanElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
const bevel = bevelRef.current;
const floor = floorRef.current;
const side = sideRef.current;
if (!btn || !bevel || !floor || !side) return;
const rotY = nx * MAX_DEG;
const rotX = -ny * MAX_DEG;
btn.style.transform = "rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg)";
const mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
const ang = (Math.atan2(ny, nx) * 180) / Math.PI + 90;
const width = 70 + mag * 110;
const alpha = (0.38 + mag * 0.6).toFixed(2);
bevel.style.background =
"conic-gradient(from " + ang.toFixed(1) + "deg, rgba(91,140,248,0), rgba(91,140,248," + alpha + ") " + (width / 2).toFixed(1) + "deg, rgba(91,140,248,0) " + width.toFixed(1) + "deg)";
const floorShift = nx * 46;
floor.style.transform = "translateX(" + floorShift.toFixed(1) + "px) scale(" + (1 + mag * 0.4).toFixed(2) + ")";
floor.style.opacity = (0.14 + mag * 0.7).toFixed(2);
side.style.transform = "scaleY(" + (0.4 + mag * 2.6).toFixed(2) + ")";
side.style.opacity = (0.25 + mag * 0.7).toFixed(2);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.5, 0.5);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = 0.9;
targetYRef.current = 0.9;
if (reduce) render(targetXRef.current, targetYRef.current);
window.setTimeout(() => {
if (!hasPointerRef.current) {
targetXRef.current = 0;
targetYRef.current = 0;
if (reduce) render(targetXRef.current, targetYRef.current);
}
}, 240);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.24;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.eb, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>기울기 크기를 각도로 바꿔 얇은 테두리 띠가 그 방향을 따라 밝아집니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<span className={styles.floor} ref={floorRef} aria-hidden="true" />
<div className={styles.wrap}>
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.bevel} ref={bevelRef} aria-hidden="true" />
<span className={styles.label}>자세히 보기</span>
</button>
<span className={styles.side} ref={sideRef} aria-hidden="true" />
</div>
</div>
</div>
</main>
);
}05 · 각도 값이 테두리를 밝히는 버튼 — 측정 누적면적 13.927% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
var ang = Math.atan2(ny, nx) * 180 / Math.PI + 90;
var width = 70 + mag * 110;
var alpha = (0.38 + mag * 0.6).toFixed(2);
bevel.style.background = 'conic-gradient(from ' + ang.toFixed(1) + 'deg, rgba(91,140,248,0), rgba(91,140,248,' + alpha + ') ' + (width / 2).toFixed(1) + 'deg, rgba(91,140,248,0) ' + width.toFixed(1) + 'deg)';
// 바닥 그림자 — 기운 방향으로 옮겨간다
var floorShift = nx * 46;
floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scale(' + (1 + mag * 0.4).toFixed(2) + ')';
floor.style.opacity = (0.14 + mag * 0.7).toFixed(2);
// 측면 두께 — 기운 크기만큼 버튼 아래 「옆면」이 길어져 3D 두께로 읽힌다(MAX_DEG 불변, 순수 표현 한 겹)
side.style.transform = 'scaleY(' + (0.4 + mag * 2.6).toFixed(2) + ')';
side.style.opacity = (0.25 + mag * 0.7).toFixed(2);
}
측면 두께 층의 주석에 적힌 대로, side 요소는 MAX_DEG 자체를 건드리지 않고 scaleY 만 늘립니다. 회전각은 그대로 두고 표현 한 겹만 얹었다는 뜻입니다.
06눌림 깊이 값이 스프링 감쇠로 튕겨 돌아오는 버튼
기울기는 관성 lerp 그대로 두고, z축에는 별도로 스프링 물리를 얹었습니다. 실제 누름은 pointerdown·pointerup·keyup 세 이벤트로, 포인터가 없을 때는 합성 홀드로 판정합니다.

<main class="stage">
<div class="pj is-demo" id="pj">
<p class="pj__hint"><span>누르고 있던 시간이 눌림 깊이가 되어, 놓으면 그 깊이만큼 스프링 감쇠가 달라집니다</span></p>
<div class="pj__scene" id="scene">
<button class="pj__btn" id="btn" type="button" aria-label="제출하기 — 누른 깊이만큼 스프링으로 튕겨 돌아오는 버튼">
<span class="pj__label">제출하기</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pj {
display: grid;
gap: $sp-4;
justify-items: center;
}
.pj__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-ink, .78);
}
.pj__scene {
perspective: 640px;
padding: $sp-6;
}
.pj__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-10;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
will-change: transform;
}
.pj__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.pj__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.pj__btn { transition: none; }
}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);
}
.pj {
display: grid;
gap: 16px;
justify-items: center;
}
.pj__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(23, 20, 26, 0.78);
}
.pj__scene {
perspective: 640px;
padding: 24px;
}
.pj__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 40px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
will-change: transform;
}
.pj__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.pj__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.pj__btn {
transition: none;
}
}(function () {
var root = document.getElementById('pj');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var PERIOD = 2.4; // 합성 시연 한 주기(초)
var STIFFNESS = 0.28;
var DAMP_BASE = 0.60; // 감쇠 하한 — 얕게 누르면 빨리 정착
var DAMP_DEPTH_RANGE = 0.32; // 눌림 깊이가 더할수록 감쇠가 늘어 더 크게 튄다
var PRESS_DEPTH_CAP = 480; // 이 시간(ms) 이상 누르면 깊이 1
var MAX_DEG = 6;
var LERP = 0.16;
var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
var hasPointer = false;
var pressedReal = false;
var wasHeld = false;
var pressStartNow = 0;
var damping = DAMP_BASE;
var aimZ = 0, curZ = 0, velZ = 0;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function syntheticTarget(t) { return [Math.sin(t * 0.7 + 1.1), Math.sin(t * 0.5)]; }
// 눌리는 지속시간이 다른 합성 사이클을 반복해 눌림 깊이 차이를 보여준다(고정 시드)
function syntheticHold(t) {
var cycle = Math.floor(t / PERIOD);
var phase = t - cycle * PERIOD;
var holdFrac = 0.14 + 0.18 * (0.5 + 0.5 * Math.sin(cycle * 1.7));
return phase < PERIOD * holdFrac;
}
function render(nx, ny, z) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg) translateZ(' + z.toFixed(2) + 'px)';
var shadowScale = clamp(1 + z / 60, 0.35, 1.25);
btn.style.boxShadow = '0 ' + (14 * shadowScale).toFixed(1) + 'px ' + (24 * shadowScale).toFixed(1) + 'px rgba(23,20,26,.30)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
var heldSynthetic = false;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
heldSynthetic = syntheticHold(t);
}
var held = pressedReal || heldSynthetic;
if (held && !wasHeld) { pressStartNow = now; }
if (!held && wasHeld) {
var heldMs = now - pressStartNow;
var depth = clamp(heldMs / PRESS_DEPTH_CAP, 0, 1);
damping = DAMP_BASE + depth * DAMP_DEPTH_RANGE;
}
wasHeld = held;
aimZ = held ? -14 : 0;
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
velZ = (velZ + (aimZ - curZ) * STIFFNESS) * damping;
curZ += velZ;
render(curNX, curNY, curZ);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
pressedReal = false;
});
btn.addEventListener('pointerdown', function () {
root.classList.remove('is-demo');
hasPointer = true;
pressedReal = true;
if (reduce) { render(targetNX, targetNY, -14); }
});
btn.addEventListener('pointerup', function () {
pressedReal = false;
if (reduce) { render(targetNX, targetNY, 0); }
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
});
btn.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
root.classList.remove('is-demo');
hasPointer = true;
pressedReal = true;
if (reduce) { render(targetNX, targetNY, -14); }
}
});
btn.addEventListener('keyup', function (e) {
if (e.key === ' ' || e.key === 'Enter') {
pressedReal = false;
if (reduce) { render(targetNX, targetNY, 0); }
}
});
if (reduce) {
render(0.3, 0.2, -14);
} else {
requestAnimationFrame(loop);
}
})();// PressSpringButton — vanilla 판(run/393/demo/06-pointer-tilt-3d-press-release-spring/index.html) 의 React 포트.
// 누르고 있던 시간을 눌림 깊이(0~1)로 바꾸고, 놓는 순간 그 깊이로 스프링 감쇠(damping)를 정해
// velocity·damping 한 식으로 관성과 오버슛을 만든다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./PressSpringButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const PERIOD = 2.4;
const STIFFNESS = 0.28;
const DAMP_BASE = 0.6;
const DAMP_DEPTH_RANGE = 0.32;
const PRESS_DEPTH_CAP = 480;
const MAX_DEG = 6;
const LERP = 0.16;
const LABEL = "제출하기 — 누른 깊이만큼 스프링으로 튕겨 돌아오는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 0.7 + 1.1), Math.sin(t * 0.5)];
}
function syntheticHold(t: number): boolean {
const cycle = Math.floor(t / PERIOD);
const phase = t - cycle * PERIOD;
const holdFrac = 0.14 + 0.18 * (0.5 + 0.5 * Math.sin(cycle * 1.7));
return phase < PERIOD * holdFrac;
}
export default function PressSpringButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const pressedRealRef = useRef<boolean>(false) as Box<boolean>;
const wasHeldRef = useRef<boolean>(false) as Box<boolean>;
const pressStartRef = useRef<number>(0) as Box<number>;
const dampingRef = useRef<number>(DAMP_BASE) as Box<number>;
const aimZRef = useRef<number>(0) as Box<number>;
const curZRef = useRef<number>(0) as Box<number>;
const velZRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number, z: number): void {
const btn = btnRef.current;
if (!btn) return;
const rotY = nx * MAX_DEG;
const rotX = -ny * MAX_DEG;
btn.style.transform = "rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg) translateZ(" + z.toFixed(2) + "px)";
const shadowScale = clamp(1 + z / 60, 0.35, 1.25);
btn.style.boxShadow = "0 " + (14 * shadowScale).toFixed(1) + "px " + (24 * shadowScale).toFixed(1) + "px rgba(23,20,26,.30)";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.3, 0.2, -14);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
let heldSynthetic = false;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
heldSynthetic = syntheticHold(t);
}
const held = pressedRealRef.current || heldSynthetic;
if (held && !wasHeldRef.current) {
pressStartRef.current = now;
}
if (!held && wasHeldRef.current) {
const heldMs = now - pressStartRef.current;
const depth = clamp(heldMs / PRESS_DEPTH_CAP, 0, 1);
dampingRef.current = DAMP_BASE + depth * DAMP_DEPTH_RANGE;
}
wasHeldRef.current = held;
aimZRef.current = held ? -14 : 0;
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
velZRef.current = (velZRef.current + (aimZRef.current - curZRef.current) * STIFFNESS) * dampingRef.current;
curZRef.current += velZRef.current;
render(curXRef.current, curYRef.current, curZRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
pressedRealRef.current = false;
}
function onPointerDown(): void {
setDemo(false);
hasPointerRef.current = true;
pressedRealRef.current = true;
if (reduce) render(targetXRef.current, targetYRef.current, -14);
}
function onPointerUp(): void {
pressedRealRef.current = false;
if (reduce) render(targetXRef.current, targetYRef.current, 0);
}
function onKeyDown(event: KeyEvt): void {
if (event.key === " " || event.key === "Enter") {
setDemo(false);
hasPointerRef.current = true;
pressedRealRef.current = true;
if (reduce) render(targetXRef.current, targetYRef.current, -14);
}
}
function onKeyUp(event: KeyEvt): void {
if (event.key === " " || event.key === "Enter") {
pressedRealRef.current = false;
if (reduce) render(targetXRef.current, targetYRef.current, 0);
}
}
const cls = [styles.pj, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>누르고 있던 시간이 눌림 깊이가 되어, 놓으면 그 깊이만큼 스프링 감쇠가 달라집니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button
className={styles.btn}
ref={btnRef}
type="button"
aria-label={LABEL}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
<span className={styles.label}>제출하기</span>
</button>
</div>
</div>
</main>
);
}06 · 눌림 깊이 값이 스프링으로 되돌아오는 버튼 — 측정 누적면적 3.7% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
var heldSynthetic = false;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
heldSynthetic = syntheticHold(t);
}
var held = pressedReal || heldSynthetic;
if (held && !wasHeld) { pressStartNow = now; }
if (!held && wasHeld) {
var heldMs = now - pressStartNow;
var depth = clamp(heldMs / PRESS_DEPTH_CAP, 0, 1);
damping = DAMP_BASE + depth * DAMP_DEPTH_RANGE;
}
wasHeld = held;
aimZ = held ? -14 : 0;
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
velZ = (velZ + (aimZ - curZ) * STIFFNESS) * damping;
curZ += velZ;
render(curNX, curNY, curZ);
requestAnimationFrame(loop);
css 버튼 눌림 효과를 스프링으로 표현할 때 핵심은 damping 값으로, 오래 누를수록 heldMs 가 커지고 그 값이 DAMP_BASE 에 더해져 감쇠가 커지므로 짧게 누르면 통통 튕기고 길게 누르면 무겁게 가라앉습니다.
07기울기 값마다 배경 격자가 어긋나 밀리는 버튼
정규화 좌표를 FAR_DEPTH·NEAR_DEPTH 두 값에 각각 곱해 두 겹의 배경 격자를 서로 반대 방향·다른 폭으로 backgroundPosition 이동시킵니다.

<main class="stage">
<div class="gp is-demo" id="gp">
<p class="gp__hint"><span>같은 좌표를 배수 둘로 곱해 안쪽 격자와 바깥 격자가 서로 다른 속도로 어긋납니다</span></p>
<div class="gp__scene" id="scene">
<button class="gp__btn" id="btn" type="button" aria-label="컬렉션 보기 — 격자 두 겹이 시차로 어긋나는 버튼">
<span class="gp__grid gp__grid--far" id="gridFar" aria-hidden="true"></span>
<span class="gp__grid gp__grid--near" id="gridNear" aria-hidden="true"></span>
<span class="gp__label">컬렉션 보기</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.gp {
display: grid;
gap: $sp-4;
justify-items: center;
}
.gp__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: $subject-ink;
}
.gp__scene {
perspective: 640px;
padding: $sp-6;
}
.gp__btn {
position: relative;
border: 1px solid rgba($subject-ink, .14);
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: rgba(255, 247, 230, .82);
color: $subject-ink;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: $shadow-raised;
overflow: hidden;
will-change: transform;
}
.gp__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
.gp__grid {
position: absolute;
inset: -30%;
pointer-events: none;
background-repeat: repeat;
background-size: 18px 18px;
}
.gp__grid--far {
background-image: radial-gradient(rgba(23, 20, 26, .1) 1px, transparent 1px);
opacity: .5;
}
.gp__grid--near {
background-image: radial-gradient(rgba(23, 20, 26, .18) 1.4px, transparent 1.4px);
background-size: 12px 12px;
}
.gp__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.gp__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gp {
display: grid;
gap: 16px;
justify-items: center;
}
.gp__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: #17141a;
}
.gp__scene {
perspective: 640px;
padding: 24px;
}
.gp__btn {
position: relative;
border: 1px solid rgba(23, 20, 26, 0.14);
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.82);
color: #17141a;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
will-change: transform;
}
.gp__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.gp__grid {
position: absolute;
inset: -30%;
pointer-events: none;
background-repeat: repeat;
background-size: 18px 18px;
}
.gp__grid--far {
background-image: radial-gradient(rgba(23, 20, 26, 0.1) 1px, transparent 1px);
opacity: 0.5;
}
.gp__grid--near {
background-image: radial-gradient(rgba(23, 20, 26, 0.18) 1.4px, transparent 1.4px);
background-size: 12px 12px;
}
.gp__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.gp__btn {
transition: none;
}
}(function () {
var root = document.getElementById('gp');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var gridFar = document.getElementById('gridFar');
var gridNear = document.getElementById('gridNear');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MAX_DEG = 9;
var LERP = 0.16;
var FAR_DEPTH = 10;
var NEAR_DEPTH = 26;
var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function syntheticTarget(t) { return [Math.sin(t * 0.9), Math.sin(t * 1.4 + 1.6)]; }
function render(nx, ny) {
btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
gridFar.style.backgroundPosition = (nx * FAR_DEPTH).toFixed(1) + 'px ' + (ny * FAR_DEPTH).toFixed(1) + 'px';
gridNear.style.backgroundPosition = (-nx * NEAR_DEPTH).toFixed(1) + 'px ' + (-ny * NEAR_DEPTH).toFixed(1) + 'px';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNX = -0.85; targetNY = -0.4;
if (reduce) { render(targetNX, targetNY); }
setTimeout(function () { if (!hasPointer) { targetNX = 0; targetNY = 0; if (reduce) { render(targetNX, targetNY); } } }, 240);
});
btn.addEventListener('keydown', function (e) {
var step = 0.24;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) return;
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(0.4, -0.3);
} else {
requestAnimationFrame(loop);
}
})();// GridParallaxButton — vanilla 판(run/393/demo/07-pointer-tilt-3d-grid-parallax/index.html) 의 React 포트.
// 같은 정규화 좌표를 두 격자 층에 서로 다른 배수·부호로 곱해 배경 위치를 옮긴다 — 두 층이
// 다른 속도로 어긋나며 시차(parallax) 깊이를 만든다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./GridParallaxButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const MAX_DEG = 9;
const LERP = 0.16;
const FAR_DEPTH = 10;
const NEAR_DEPTH = 26;
const LABEL = "컬렉션 보기 — 격자 두 겹이 시차로 어긋나는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 0.9), Math.sin(t * 1.4 + 1.6)];
}
export default function GridParallaxButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const gridFarRef = useRef<HTMLSpanElement | null>(null);
const gridNearRef = useRef<HTMLSpanElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
const gridFar = gridFarRef.current;
const gridNear = gridNearRef.current;
if (!btn || !gridFar || !gridNear) return;
btn.style.transform = "rotateX(" + (-ny * MAX_DEG).toFixed(2) + "deg) rotateY(" + (nx * MAX_DEG).toFixed(2) + "deg)";
gridFar.style.backgroundPosition = (nx * FAR_DEPTH).toFixed(1) + "px " + (ny * FAR_DEPTH).toFixed(1) + "px";
gridNear.style.backgroundPosition = (-nx * NEAR_DEPTH).toFixed(1) + "px " + (-ny * NEAR_DEPTH).toFixed(1) + "px";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.4, -0.3);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = -0.85;
targetYRef.current = -0.4;
if (reduce) render(targetXRef.current, targetYRef.current);
window.setTimeout(() => {
if (!hasPointerRef.current) {
targetXRef.current = 0;
targetYRef.current = 0;
if (reduce) render(targetXRef.current, targetYRef.current);
}
}, 240);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.24;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.gp, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>같은 좌표를 배수 둘로 곱해 안쪽 격자와 바깥 격자가 서로 다른 속도로 어긋납니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={`${styles.grid} ${styles.gridFar}`} ref={gridFarRef} aria-hidden="true" />
<span className={`${styles.grid} ${styles.gridNear}`} ref={gridNearRef} aria-hidden="true" />
<span className={styles.label}>컬렉션 보기</span>
</button>
</div>
</div>
</main>
);
}07 · 기울기 값마다 어긋나는 격자무늬 버튼 — 측정 누적면적 4.097% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(nx, ny) {
btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
gridFar.style.backgroundPosition = (nx * FAR_DEPTH).toFixed(1) + 'px ' + (ny * FAR_DEPTH).toFixed(1) + 'px';
gridNear.style.backgroundPosition = (-nx * NEAR_DEPTH).toFixed(1) + 'px ' + (-ny * NEAR_DEPTH).toFixed(1) + 'px';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
gridNear 는 부호를 반대로 뒤집어 이동합니다. 먼 격자와 가까운 격자가 서로 엇갈려 밀리는 그 차이만으로 깊이가 읽힙니다.
08기울기 크기 값이 테두리 광의 번짐 반경을 정하는 버튼
회전은 04·07 과 같은 단순 매핑이고, 정규화 좌표의 크기(mag)만 따로 뽑아 box-shadow 의 번짐 반경(blur)·퍼짐(spread)·투명도에 씁니다.

<main class="stage">
<div class="rg is-demo" id="rg">
<p class="rg__hint"><span>기울기 크기(방향과 무관)만 골라 둘레 전체의 광 반경·밝기로 바꿉니다</span></p>
<div class="rg__scene" id="scene">
<button class="rg__btn" id="btn" type="button" aria-label="지금 플레이 — 기울기 크기로 둘레가 밝아지는 버튼">
<span class="rg__label">지금 플레이</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.rg {
display: grid;
gap: $sp-4;
justify-items: center;
}
.rg__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-ink, .72);
}
.rg__scene {
perspective: 640px;
padding: $sp-6;
}
.rg__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
will-change: transform, box-shadow;
}
.rg__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.rg__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.rg__btn { 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);
}
.rg {
display: grid;
gap: 16px;
justify-items: center;
}
.rg__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(23, 20, 26, 0.72);
}
.rg__scene {
perspective: 640px;
padding: 24px;
}
.rg__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
will-change: transform, box-shadow;
}
.rg__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.rg__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.rg__btn {
transition: none;
}
}(function () {
var root = document.getElementById('rg');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MAX_DEG = 10;
var LERP = 0.16;
var BLUR_BASE = 6;
var BLUR_RANGE = 34;
var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function syntheticTarget(t) { return [Math.sin(t * 1.1 + 0.3), Math.sin(t * 1.6)]; }
// 방향은 회전에만 쓰고, 둘레 광은 크기(magnitude)만 읽는다 — 05 의 방향성 광과 다른 값이다
function render(nx, ny) {
btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
var blur = BLUR_BASE + mag * BLUR_RANGE;
var spread = 1 + mag * 5;
var alpha = (0.28 + mag * 0.5).toFixed(2);
btn.style.boxShadow = '0 0 ' + blur.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(91,140,248,' + alpha + '), 0 12px 20px rgba(23,20,26,.28)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetNX = 0; targetNY = 0;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNX = 0.95; targetNY = 0.3;
if (reduce) { render(targetNX, targetNY); }
setTimeout(function () { if (!hasPointer) { targetNX = 0; targetNY = 0; if (reduce) { render(targetNX, targetNY); } } }, 240);
});
btn.addEventListener('keydown', function (e) {
var step = 0.24;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) return;
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(0.5, 0.4);
} else {
requestAnimationFrame(loop);
}
})();// RimGlowButton — vanilla 판(run/393/demo/08-pointer-tilt-3d-rim-glow/index.html) 의 React 포트.
// 기울기 벡터에서 방향은 회전에만 쓰고, 크기(magnitude)만 따로 읽어 둘레 광의 반경·번짐·밝기로
// 바꾼다(05 의 방향성 테두리 광과는 다른 값). 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./RimGlowButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const MAX_DEG = 10;
const LERP = 0.16;
const BLUR_BASE = 6;
const BLUR_RANGE = 34;
const LABEL = "지금 플레이 — 기울기 크기로 둘레가 밝아지는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticTarget(t: number): [number, number] {
return [Math.sin(t * 1.1 + 0.3), Math.sin(t * 1.6)];
}
export default function RimGlowButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
if (!btn) return;
btn.style.transform = "rotateX(" + (-ny * MAX_DEG).toFixed(2) + "deg) rotateY(" + (nx * MAX_DEG).toFixed(2) + "deg)";
const mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
const blur = BLUR_BASE + mag * BLUR_RANGE;
const spread = 1 + mag * 5;
const alpha = (0.28 + mag * 0.5).toFixed(2);
btn.style.boxShadow = "0 0 " + blur.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(91,140,248," + alpha + "), 0 12px 20px rgba(23,20,26,.28)";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.5, 0.4);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const s = syntheticTarget(t);
targetXRef.current = s[0];
targetYRef.current = s[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetXRef.current = 0;
targetYRef.current = 0;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = 0.95;
targetYRef.current = 0.3;
if (reduce) render(targetXRef.current, targetYRef.current);
window.setTimeout(() => {
if (!hasPointerRef.current) {
targetXRef.current = 0;
targetYRef.current = 0;
if (reduce) render(targetXRef.current, targetYRef.current);
}
}, 240);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.24;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.rg, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>기울기 크기(방향과 무관)만 골라 둘레 전체의 광 반경·밝기로 바꿉니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.label}>지금 플레이</span>
</button>
</div>
</div>
</main>
);
}08 · 기울기 크기 값이 세기를 정하는 테두리 광 — 측정 누적면적 10.337% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(nx, ny) {
btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
var blur = BLUR_BASE + mag * BLUR_RANGE;
var spread = 1 + mag * 5;
var alpha = (0.28 + mag * 0.5).toFixed(2);
btn.style.boxShadow = '0 0 ' + blur.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(91,140,248,' + alpha + '), 0 12px 20px rgba(23,20,26,.28)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var s = syntheticTarget(t);
targetNX = s[0]; targetNY = s[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
blur 값이 BLUR_BASE + mag * BLUR_RANGE 하나뿐이라, 기울기가 클수록 빛이 넓게 번지고 정면을 보면 광이 옅게 맺히는 두 상태가 같은 식 하나로 이어집니다.
09포인터가 없으면 값이 사인 곡선으로 스스로 흔드는 버튼
idleDrift(t) 는 서로 다른 주기의 사인파 두 개를 더해 불규칙하게 보이는 좌표를 고정 시드로 만듭니다. 렌더 함수는 같은 좌표로 들림·그림자·하이라이트 세 층을 짝지어 움직입니다.

<main class="stage">
<div class="id is-demo" id="idw">
<p class="id__hint"><span>주파수가 다른 사인파 둘을 더해 손을 떼도 버튼이 스스로 천천히 떠돕니다</span></p>
<div class="id__scene" id="scene">
<span class="id__floor" id="floor" aria-hidden="true"></span>
<button class="id__btn" id="btn" type="button" aria-label="무료로 시작하기 — 가만히 둬도 떠도는 버튼">
<span class="id__glow" id="glow" aria-hidden="true"></span>
<span class="id__label">무료로 시작하기</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.id {
display: grid;
gap: $sp-4;
justify-items: center;
}
.id__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba($subject-cream, .78);
}
.id__scene {
position: relative;
perspective: 640px;
padding: $sp-6;
}
// 바닥 그림자 — 기운 반대쪽으로 옮겨가며 면적을 만든다
.id__floor {
position: absolute;
left: 50%;
bottom: 2px;
width: clamp(130px, 46vw, 220px);
height: clamp(38px, 13.4vw, 64px);
margin-left: clamp(-110px, -23vw, -65px);
border-radius: 50%;
background: radial-gradient(closest-side, rgba($stage-ink, .72), rgba($stage-ink, 0) 72%);
filter: blur(10px);
pointer-events: none;
}
.id__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-5 $sp-12;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
overflow: hidden;
will-change: transform, box-shadow;
}
.id__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
// 하이라이트 — 그림자와 짝으로 기운 반대쪽에서 밝아진다
.id__glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
.id__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.id__btn { transition: none; }
}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);
}
.id {
display: grid;
gap: 16px;
justify-items: center;
}
.id__hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
text-align: center;
max-width: 260px;
color: rgba(255, 247, 230, 0.78);
}
.id__scene {
position: relative;
perspective: 640px;
padding: 24px;
}
.id__floor {
position: absolute;
left: 50%;
bottom: 2px;
width: clamp(130px, 46vw, 220px);
height: clamp(38px, 13.4vw, 64px);
margin-left: clamp(-110px, -23vw, -65px);
border-radius: 50%;
background: radial-gradient(closest-side, rgba(23, 20, 26, 0.72), rgba(23, 20, 26, 0) 72%);
filter: blur(10px);
pointer-events: none;
}
.id__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 20px 48px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
overflow: hidden;
will-change: transform, box-shadow;
}
.id__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.id__glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
.id__label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.id__btn {
transition: none;
}
}(function () {
var root = document.getElementById('idw');
var scene = document.getElementById('scene');
var btn = document.getElementById('btn');
var floor = document.getElementById('floor');
var glow = document.getElementById('glow');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var MAX_DEG = 8; // 불변
var LERP = 0.12;
var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
var hasPointer = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 유휴 드리프트 — 주파수·위상이 다른 사인파 둘을 더한 합성 곡선(고정 시드). 포인터가 있으면 좌표로 덮인다
// 진폭 상수를 키워 자동 시연에서도 가장자리(±1, MAX_DEG 전체)까지 실제로 닿게 한다
function idleDrift(t) {
var x = 0.85 * Math.sin(t * 0.55) + 0.55 * Math.sin(t * 1.31 + 0.8);
var y = 0.76 * Math.sin(t * 0.41 + 1.3) + 0.48 * Math.sin(t * 0.97 + 2.4);
return [clamp(x, -1, 1), clamp(y, -1, 1)];
}
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
var mag = Math.min(1, Math.sqrt(nx * nx + ny * ny));
var lift = 1 - mag * 0.4;
btn.style.transform = 'translateY(' + ((1 - lift) * -6).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var blur = 16 + Math.abs(nx) * 10 + Math.abs(ny) * 10;
btn.style.boxShadow = '0 ' + (10 * lift).toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(8,6,12,.34)';
// 바닥 그림자 — 기운 반대쪽으로 떨어져 나가며 면적을 만든다
floor.style.transform = 'translate(' + (nx * 38).toFixed(1) + 'px, ' + (ny * 16).toFixed(1) + 'px) scale(' + (1 + mag * 0.32).toFixed(2) + ')';
floor.style.opacity = (0.16 + mag * 0.55).toFixed(2);
// 하이라이트 — 기운 반대쪽(들린 쪽)에서 밝아져 그림자와 짝으로 움직인다
var gx = (50 - nx * 32).toFixed(0);
var gy = (50 - ny * 32).toFixed(0);
glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.16 + mag * 0.42).toFixed(2) + '), rgba(255,255,255,0) 62%)';
}
var t0 = null;
function loop(now) {
if (t0 === null) t0 = now;
var t = (now - t0) / 1000;
if (!hasPointer && !reduce) {
var d = idleDrift(t);
targetNX = d[0]; targetNY = d[1];
}
curNX += (targetNX - curNX) * LERP;
curNY += (targetNY - curNY) * LERP;
render(curNX, curNY);
requestAnimationFrame(loop);
}
function pointFromEvent(e) {
var r = scene.getBoundingClientRect();
var nx = ((e.clientX - r.left) / r.width) * 2 - 1;
var ny = ((e.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var p = pointFromEvent(e);
targetNX = p[0]; targetNY = p[1];
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
});
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
hasPointer = true;
targetNX = 0; targetNY = -0.9;
if (reduce) { render(targetNX, targetNY); }
setTimeout(function () { hasPointer = false; }, 260);
});
btn.addEventListener('keydown', function (e) {
var step = 0.22;
var moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
var m = moves[e.key];
if (!m) return;
e.preventDefault();
root.classList.remove('is-demo');
hasPointer = true;
targetNX = clamp(targetNX + m[0], -1, 1);
targetNY = clamp(targetNY + m[1], -1, 1);
if (reduce) { render(targetNX, targetNY); }
});
if (reduce) {
render(0.2, -0.2);
} else {
requestAnimationFrame(loop);
}
})();// IdleDriftButton — vanilla 판(run/393/demo/09-pointer-tilt-3d-idle-drift/index.html) 의 React 포트.
// 주파수·위상이 다른 사인파 둘을 더한 합성 곡선(고정 시드)이 유휴 드리프트를 만들고,
// 포인터가 있으면 그 좌표가 목표값을 덮어쓴다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./IdleDriftButton.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const MAX_DEG = 8; // 불변
const LERP = 0.12;
const LABEL = "무료로 시작하기 — 가만히 둬도 떠도는 버튼";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
// 진폭 상수를 키워 자동 시연에서도 가장자리(±1, MAX_DEG 전체)까지 실제로 닿게 한다
function idleDrift(t: number): [number, number] {
const x = 0.85 * Math.sin(t * 0.55) + 0.55 * Math.sin(t * 1.31 + 0.8);
const y = 0.76 * Math.sin(t * 0.41 + 1.3) + 0.48 * Math.sin(t * 0.97 + 2.4);
return [clamp(x, -1, 1), clamp(y, -1, 1)];
}
export default function IdleDriftButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const floorRef = useRef<HTMLSpanElement | null>(null);
const glowRef = useRef<HTMLSpanElement | null>(null);
const targetXRef = useRef<number>(0) as Box<number>;
const targetYRef = useRef<number>(0) as Box<number>;
const curXRef = useRef<number>(0) as Box<number>;
const curYRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(nx: number, ny: number): void {
const btn = btnRef.current;
const floor = floorRef.current;
const glow = glowRef.current;
if (!btn || !floor || !glow) return;
const rotY = nx * MAX_DEG;
const rotX = -ny * MAX_DEG;
const mag = Math.min(1, Math.sqrt(nx * nx + ny * ny));
const lift = 1 - mag * 0.4;
btn.style.transform = "translateY(" + ((1 - lift) * -6).toFixed(2) + "px) rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg)";
const blur = 16 + Math.abs(nx) * 10 + Math.abs(ny) * 10;
btn.style.boxShadow = "0 " + (10 * lift).toFixed(1) + "px " + blur.toFixed(1) + "px rgba(8,6,12,.34)";
floor.style.transform = "translate(" + (nx * 38).toFixed(1) + "px, " + (ny * 16).toFixed(1) + "px) scale(" + (1 + mag * 0.32).toFixed(2) + ")";
floor.style.opacity = (0.16 + mag * 0.55).toFixed(2);
const gx = (50 - nx * 32).toFixed(0);
const gy = (50 - ny * 32).toFixed(0);
glow.style.background = "radial-gradient(circle at " + gx + "% " + gy + "%, rgba(255,255,255," + (0.16 + mag * 0.42).toFixed(2) + "), rgba(255,255,255,0) 62%)";
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0.2, -0.2);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const d = idleDrift(t);
targetXRef.current = d[0];
targetYRef.current = d[1];
}
curXRef.current += (targetXRef.current - curXRef.current) * LERP;
curYRef.current += (targetYRef.current - curYRef.current) * LERP;
render(curXRef.current, curYRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function pointFromEvent(event: ReactPointerEvent<HTMLDivElement>): [number, number] {
const scene = sceneRef.current;
if (!scene) return [0, 0];
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
return [clamp(nx, -1, 1), clamp(ny, -1, 1)];
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const p = pointFromEvent(event);
targetXRef.current = p[0];
targetYRef.current = p[1];
}
function onPointerLeave(): void {
hasPointerRef.current = false;
}
function onClick(): void {
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = 0;
targetYRef.current = -0.9;
if (reduce) render(targetXRef.current, targetYRef.current);
window.setTimeout(() => {
hasPointerRef.current = false;
}, 260);
}
function onKeyDown(event: KeyEvt): void {
const step = 0.22;
const moves: { [key: string]: [number, number] } = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const m = moves[event.key];
if (!m) return;
event.preventDefault();
setDemo(false);
hasPointerRef.current = true;
targetXRef.current = clamp(targetXRef.current + m[0], -1, 1);
targetYRef.current = clamp(targetYRef.current + m[1], -1, 1);
if (reduce) render(targetXRef.current, targetYRef.current);
}
const cls = [styles.id, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.hint}>
<span>주파수가 다른 사인파 둘을 더해 손을 떼도 버튼이 스스로 천천히 떠돕니다</span>
</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<span className={styles.floor} ref={floorRef} aria-hidden="true" />
<button className={styles.btn} ref={btnRef} type="button" aria-label={LABEL} onClick={onClick} onKeyDown={onKeyDown}>
<span className={styles.glow} ref={glowRef} aria-hidden="true" />
<span className={styles.label}>무료로 시작하기</span>
</button>
</div>
</div>
</main>
);
}09 · 포인터가 없을 때 값이 스스로 흔드는 버튼 — 측정 누적면적 6.923% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function idleDrift(t) {
var x = 0.85 * Math.sin(t * 0.55) + 0.55 * Math.sin(t * 1.31 + 0.8);
var y = 0.76 * Math.sin(t * 0.41 + 1.3) + 0.48 * Math.sin(t * 0.97 + 2.4);
return [clamp(x, -1, 1), clamp(y, -1, 1)];
}
function render(nx, ny) {
var rotY = nx * MAX_DEG;
var rotX = -ny * MAX_DEG;
var mag = Math.min(1, Math.sqrt(nx * nx + ny * ny));
var lift = 1 - mag * 0.4;
btn.style.transform = 'translateY(' + ((1 - lift) * -6).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
var blur = 16 + Math.abs(nx) * 10 + Math.abs(ny) * 10;
btn.style.boxShadow = '0 ' + (10 * lift).toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(8,6,12,.34)';
// 바닥 그림자 — 기운 반대쪽으로 떨어져 나가며 면적을 만든다
floor.style.transform = 'translate(' + (nx * 38).toFixed(1) + 'px, ' + (ny * 16).toFixed(1) + 'px) scale(' + (1 + mag * 0.32).toFixed(2) + ')';
floor.style.opacity = (0.16 + mag * 0.55).toFixed(2);
// 하이라이트 — 기운 반대쪽(들린 쪽)에서 밝아져 그림자와 짝으로 움직인다
var gx = (50 - nx * 32).toFixed(0);
var gy = (50 - ny * 32).toFixed(0);
glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.16 + mag * 0.42).toFixed(2) + '), rgba(255,255,255,0) 62%)';
}
바닥 그림자와 하이라이트가 nx·ny 부호를 서로 반대로 받는 점이 이 항목의 한 겹으로, 버튼이 기운 쪽은 그림자가 짙어지고 반대로 들린 쪽은 하이라이트가 밝아지는 짝을 이룹니다.
이 아홉 버튼, 실무 화면 어디에 붙일까?
카드형 CTA 버튼에는 01·02 처럼 좌표 값을 바로 각도로 쓰는 쪽이 잘 맞습니다. 계산이 단순해 목록 안에 여러 개를 나란히 둬도 값 하나가 서로 간섭하지 않고, css 버튼 클릭 효과까지 이어지는 click 핸들러 하나만 얹으면 마무리됩니다.
결제·폼 제출처럼 사용자가 버튼을 눌렀다는 사실 자체가 중요한 자리에는 06 의 스프링 감쇠가 맞습니다. 짧게 누르면 가볍게, 오래 누르면 무겁게 되돌아오는 차이가 실수로 스친 클릭과 의도한 누름을 시각적으로 갈라 줍니다.
다크 톤 랜딩의 히어로 버튼처럼 정지 화면에서도 눈길을 끌어야 하는 자리에는 09 가 맞습니다. 방문자가 아직 커서를 움직이지 않은 순간에도 사인 곡선 값이 스스로 들림·그림자·하이라이트를 흔들어 버튼이 죽어 있지 않다는 신호를 보냅니다.
세 가지로 바뀐 값 — 기울기·감쇠·번짐 폭
같은 계산에 값 하나만 바꿔도 체감은 크게 달라집니다. 01 은 MAX_DEG 를 14 에서 20 으로 올려 회전 폭 자체를 키운 쪽이고, 06 은 DAMP_BASE 를 0.60 에서 0.40 으로 낮춰 감쇠를 줄인 쪽이고, 08 은 BLUR_RANGE 를 34 에서 50 으로 넓혀 광 번짐의 최대치를 늘린 쪽입니다.
| 항목 | 값 | 방향 |
|---|---|---|
| 01 커서 좌표 값이 기울기 각도로 바뀌는 버튼 | MAX_DEG 14 → 20 |
회전 폭이 커져 입체감이 더 또렷해집니다 |
| 06 눌림 깊이 값이 스프링 감쇠로 튕겨 돌아오는 버튼 | DAMP_BASE 0.60 → 0.40 |
감쇠가 줄어 되돌아올 때 더 크게 튕깁니다 |
| 08 기울기 크기 값이 테두리 광의 번짐 반경을 정하는 버튼 | BLUR_RANGE 34 → 50 |
기울일 때 광이 훨씬 넓게 번집니다 |
아홉 개 전체 측정값
아홉 개를 다 재도 누적면적은 2~14% 대에 머무릅니다. 버튼 편이라 화면에서 실제로 바뀌는 부분이 버튼 자체와 그림자·하이라이트뿐이라, 배경 전체가 움직이는 장면형 데모보다 면적이 작게 나오는 게 정상입니다 — 컴포넌트 편에서 보는 건 면적이 아니라 값 하나가 얼마나 여러 겹을 겸하는지입니다.
| 항목 | 누적면적 | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 커서 회전 | 3.1% | 34.0 | 23/23 |
| 02 하이라이트 스윕 | 12.4% | 22.3 | 23/23 |
| 03 그림자 층 | 6.1% | 30.2 | 22/23 |
| 04 아이콘 시차 | 2.4% | 63.2 | 23/23 |
| 05 테두리 베벨 | 13.9% | 20.7 | 23/23 |
| 06 스프링 감쇠 | 3.7% | 39.1 | 23/23 |
| 07 격자 시차 | 4.1% | 22.4 | 23/23 |
| 08 림 글로우 | 10.3% | 25.8 | 23/23 |
| 09 아이들 드리프트 | 6.9% | 21.7 | 23/23 |
클릭 직후 260밀리초 — 기울기가 깨지는 함정
01 의 click 핸들러를 그대로 옮겨 붙이면 실무에서 자주 놓치는 지점이 하나 생깁니다. 클릭 순간 hasPointer 를 켜고 목표 좌표를 고정값으로 바꾼 뒤, clearTimeout(clickTimer) 로 이전 타이머를 지우고 260ms 뒤 다시 hasPointer 를 끄도록 setTimeout 을 걸어 둡니다. 이 리셋은 커서가 버튼 위에 그대로 머물러 있어도 예외 없이 실행됩니다. 그래서 클릭한 자리에 커서를 두고 가만히 있으면 260ms 뒤 버튼이 순간적으로 평평해졌다가, 다음 pointermove 이벤트가 들어와야 다시 기울기가 붙습니다.
프레임 값 자체는 03 처럼 22/23 으로 한 번 멈추는 경우도 있는데, 이건 버그가 아니라 정규화 좌표(curNX·curNY)가 목표값에 LERP 비율만큼 충분히 가까워지면 소수점 둘째 자리 반올림값이 같아져 픽셀 상으로 변화가 없는 프레임이 한 번 섞이기 때문입니다. 값을 더 세밀하게 반올림하지 않는 한 자연스러운 결과입니다.
qvyanve8 을 풀어 실제 파일을 프로젝트에 옮겨 붙일 때는 scene·btn·sheen·floor·glow 같은 자리표시자 클래스명이 기존 마크업과 겹치지 않는지부터 확인해야 합니다.
접근성 — 동작 줄이기와 키보드로 값 대신 각도를 옮기는 법
prefers-reduced-motion 가 켜진 환경에서는 아홉 개 전부 requestAnimationFrame 루프 자체를 시작하지 않고, render() 를 값 한 번만 고정으로 호출해 살짝 기운 정지 자세만 보여줍니다. 자동 시연과 포인터 추적은 꺼지지만, 클릭·화살표 키 입력은 관성 보간 없이 그 자리에서 render() 를 한 번 더 호출해 즉시 화면에 반영됩니다.
키보드로는 버튼에 포커스를 두고 방향키를 누르면 step 값(0.24)만큼 정규화 좌표가 움직여 마우스 없이도 기울기를 체감할 수 있습니다. e.preventDefault() 로 스크롤과 겹치지 않게 막아 두었고, Enter·Space 는 click 이벤트로 이어져 마우스 클릭과 같은 반응을 냅니다. getBoundingClientRect() 로 좌표를 정규화하는 부분은 포인터 입력에만 걸려 있어 키보드 흐름에는 관여하지 않습니다.
버튼 입체감을 적용하기 전에 자주 받는 질문
css 버튼 입체감을 CSS 값만으로 못 만드나요?
:hover 의 transform: rotateX() 로 고정된 기울기는 낼 수 있지만, 커서 위치에 따라 각도가 실시간으로 바뀌는 css 버튼 마우스 오버 효과는 좌표를 읽어야 해서 JavaScript 계산이 필요합니다. perspective 와 transform-style: preserve-3d 는 CSS 쪽 값이고, 그 값을 매 프레임 무엇으로 채울지는 JS 몫입니다.
모바일 터치에서도 커서 추적이 되나요?
pointermove 는 터치에서도 발생하지만 손가락이 눌린 순간에만 좌표가 들어옵니다. 손을 떼면 pointerleave 로 목표 좌표가 0 으로 돌아가므로, 터치 환경에서는 누른 채 미세하게 움직여야 기울기 차이가 드러납니다.
왜 06 만 css 버튼 눌림 효과에 별도 이벤트가 붙었나요?
나머지 여덟 개는 기울기만 다루지만 06 은 z축 깊이까지 계산해야 해서 실제로 눌렸다 떼어지는 순간(pointerdown·pointerup)과 키보드로 누른 순간(keyup)을 따로 잡습니다. 이 값이 있어야 heldMs 로 누른 시간을 재고, 그 시간만큼 감쇠를 다르게 줄 수 있습니다.
이 계산 방식은 css 3d 큐브 캐러셀 틸트 9종 — 한 줄 복붙으로 편의 좌표-회전 매핑과 뿌리가 같고, 카드 단위로 적용하고 싶다면 CSS 카드 애니메이션 호버 효과 9종 — 한 줄 복붙으로 편을, 커서 자체를 꾸미는 쪽은 마우스 이펙트 9가지 — 커서부터 드래그까지 편을 함께 보면 좋습니다.









