02광원 각도 값이 훑고 지나가는 버튼 하이라이트
커서 위치로 광원 각도 값을 계산해 반사광처럼 보이는 하이라이트를 그 방향으로 옮기고, 각도가 바뀔 때마다 하이라이트 위치도 같이 이동한다.
쓰임프리미엄 결제 버튼·프로모션 배너 버튼
SWEEP_WIDTHLERPconic-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 포함 · css 버튼 입체감 9가지 — 클릭 효과까지 커스텀 코드
이 데모가 속한 글
css 버튼 입체감 9가지 — 클릭 효과까지 커스텀 코드css 버튼 입체감을 만드는 계산은 늘 같은 질문에서 출발합니다. 커서가 버튼 위 어디쯤 있는지를 각도 값 하나로 바꾸면 평면이 어떻게 입체로 보일까? 좌표 값이 rotateX·rotateY 를 정하는 버튼, 기울기 이 글의 데모 전부 보기


