06カーソルに合わせて傾くステッカー
カーソルがカード上のどこにあるかに応じてカードがその反対方向へわずかに立体的に傾き、表面のハイライトも一緒に動いて厚みがあるように見える。
使いどころ商品カード、グッズプレビュー
rotateXrotateYperspective

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="cardZone" id="cardZone" type="button" aria-label="ステッカー、マウスを乗せるとポインタの方向へ傾く">
<span class="card" id="card">
<span class="glare" id="glare" aria-hidden="true"></span>
<span class="cardMark" aria-hidden="true"></span>
</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 applyFromPoint(px, py) 에 있다 — 포인터 좌표를 카드 중심 기준
// -1..1 로 정규화한 값에 MAX_TILT 를 곱해 rotateX/rotateY 를 얻고, 같은 정규화 값으로
// 광택(glare) 위치도 함께 옮긴다. perspective 는 부모(.cardZone)에 둔다.
@include stage($stage-yellow);
.wrap { display: grid; place-items: center; }
.cardZone {
appearance: none;
border: 0;
padding: 0;
background: none;
cursor: pointer;
perspective: 700px;
&:focus-visible .card { outline: 2px solid $subject-blue-deep; outline-offset: 4px; }
}
.card {
position: relative;
display: grid;
place-items: center;
width: 172px;
height: 172px;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
transform-style: preserve-3d;
overflow: hidden;
}
.cardMark {
width: 60px;
height: 60px;
border-radius: 50%;
border: 8px solid $subject-blue-deep;
}
.glare {
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: overlay;
}
@media (max-width: 340px) {
.card { width: 132px; height: 132px; }
.cardMark { width: 46px; height: 46px; border-width: 6px; }
}
@media (prefers-reduced-motion: reduce) {
.card { 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);
}
.wrap {
display: grid;
place-items: center;
}
.cardZone {
appearance: none;
border: 0;
padding: 0;
background: none;
cursor: pointer;
perspective: 700px;
}
.cardZone:focus-visible .card {
outline: 2px solid #0a4fe8;
outline-offset: 4px;
}
.card {
position: relative;
display: grid;
place-items: center;
width: 172px;
height: 172px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-style: preserve-3d;
overflow: hidden;
}
.cardMark {
width: 60px;
height: 60px;
border-radius: 50%;
border: 8px solid #0a4fe8;
}
.glare {
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: overlay;
}
@media (max-width: 340px) {
.card {
width: 132px;
height: 132px;
}
.cardMark {
width: 46px;
height: 46px;
border-width: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var MAX_TILT = 14; // 최대 회전각(deg)
var GLARE_RANGE = 60; // 광택 이동 범위(%)
var LERP_RATE = .18;
var ORBIT_MS = 2200;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var zone = document.getElementById('cardZone');
var card = document.getElementById('card');
var glare = document.getElementById('glare');
var isDemo = true;
var targetRX = 0, targetRY = 0, targetGX = 50, targetGY = 50;
var shownRX = 0, shownRY = 0, shownGX = 50, shownGY = 50;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 포인터 좌표를 카드 중심 기준 -1..1 로 정규화한 뒤 그 값에 MAX_TILT 를 곱해 회전각을 얻는다.
// 세로 이동은 rotateX 를, 가로 이동은 rotateY 를 반대 부호로 결정한다(위로 밀면 뒤로 젖혀짐).
function applyFromPoint(px, py) {
var rect = zone.getBoundingClientRect();
var nx = ((px - rect.left) / rect.width) * 2 - 1;
var ny = ((py - rect.top) / rect.height) * 2 - 1;
nx = Math.max(-1, Math.min(1, nx));
ny = Math.max(-1, Math.min(1, ny));
targetRY = nx * MAX_TILT;
targetRX = -ny * MAX_TILT;
targetGX = 50 + nx * GLARE_RANGE;
targetGY = 50 + ny * GLARE_RANGE;
}
function reset() { targetRX = 0; targetRY = 0; targetGX = 50; targetGY = 50; }
zone.addEventListener('pointermove', function (e) { human(); applyFromPoint(e.clientX, e.clientY); });
zone.addEventListener('pointerleave', function () { human(); reset(); });
zone.addEventListener('focus', function () { human(); targetRY = MAX_TILT * .5; targetRX = -MAX_TILT * .3; });
zone.addEventListener('blur', function () { reset(); });
var start = 0;
function autoAdvance(ts) {
if (!start) start = ts;
var t = ((ts - start) % ORBIT_MS) / ORBIT_MS * Math.PI * 2;
applyFromPoint(
zone.getBoundingClientRect().left + zone.getBoundingClientRect().width * (.5 + .4 * Math.cos(t)),
zone.getBoundingClientRect().top + zone.getBoundingClientRect().height * (.5 + .4 * Math.sin(t))
);
}
function loop(ts) {
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) {
shownRX = targetRX; shownRY = targetRY; shownGX = targetGX; shownGY = targetGY;
} else {
shownRX += (targetRX - shownRX) * LERP_RATE;
shownRY += (targetRY - shownRY) * LERP_RATE;
shownGX += (targetGX - shownGX) * LERP_RATE;
shownGY += (targetGY - shownGY) * LERP_RATE;
}
card.style.transform = 'rotateX(' + shownRX.toFixed(2) + 'deg) rotateY(' + shownRY.toFixed(2) + 'deg)';
glare.style.background = 'radial-gradient(circle at ' + shownGX.toFixed(1) + '% ' + shownGY.toFixed(1) +
'%, rgba(255,255,255,.5), transparent 55%)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// TiltOnHover — vanilla 판(run/378/demo/06-sticker-tilt-on-hover/index.html) 의 React 포트.
// 포인터 좌표를 카드 중심 기준 -1..1 로 정규화한 값에 MAX_TILT 를 곱해 rotateX/rotateY 를 얻는다
// (applyFromPoint) — 같은 정규화 값으로 광택 위치도 함께 옮긴다.
import { useEffect, useRef } from "react";
import styles from "./TiltOnHover.module.scss";
type Box<T> = { current: T };
const MAX_TILT = 14;
const GLARE_RANGE = 60;
const LERP_RATE = 0.18;
const ORBIT_MS = 2200;
export default function TiltOnHover() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const zoneRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const cardRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const glareRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const zone = zoneRef.current;
const card = cardRef.current;
const glare = glareRef.current;
if (!wrap || !zone || !card || !glare) return;
let isDemo = true;
let targetRX = 0, targetRY = 0, targetGX = 50, targetGY = 50;
let shownRX = 0, shownRY = 0, shownGX = 50, shownGY = 50;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function applyFromPoint(px: number, py: number) {
const rect = zone!.getBoundingClientRect();
let nx = ((px - rect.left) / rect.width) * 2 - 1;
let ny = ((py - rect.top) / rect.height) * 2 - 1;
nx = Math.max(-1, Math.min(1, nx));
ny = Math.max(-1, Math.min(1, ny));
targetRY = nx * MAX_TILT;
targetRX = -ny * MAX_TILT;
targetGX = 50 + nx * GLARE_RANGE;
targetGY = 50 + ny * GLARE_RANGE;
}
function reset() { targetRX = 0; targetRY = 0; targetGX = 50; targetGY = 50; }
function onMove(e: PointerEvent) { human(); applyFromPoint(e.clientX, e.clientY); }
function onLeave() { human(); reset(); }
function onFocus() { human(); targetRY = MAX_TILT * 0.5; targetRX = -MAX_TILT * 0.3; }
function onBlur() { reset(); }
zone.addEventListener("pointermove", onMove);
zone.addEventListener("pointerleave", onLeave);
zone.addEventListener("focus", onFocus);
zone.addEventListener("blur", onBlur);
let start = 0;
function autoAdvance(ts: number) {
if (!start) start = ts;
const t = ((ts - start) % ORBIT_MS) / ORBIT_MS * Math.PI * 2;
const rect = zone!.getBoundingClientRect();
applyFromPoint(
rect.left + rect.width * (0.5 + 0.4 * Math.cos(t)),
rect.top + rect.height * (0.5 + 0.4 * Math.sin(t))
);
}
function loop(ts: number) {
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) {
shownRX = targetRX; shownRY = targetRY; shownGX = targetGX; shownGY = targetGY;
} else {
shownRX += (targetRX - shownRX) * LERP_RATE;
shownRY += (targetRY - shownRY) * LERP_RATE;
shownGX += (targetGX - shownGX) * LERP_RATE;
shownGY += (targetGY - shownGY) * LERP_RATE;
}
card!.style.transform = "rotateX(" + shownRX.toFixed(2) + "deg) rotateY(" + shownRY.toFixed(2) + "deg)";
glare!.style.background = "radial-gradient(circle at " + shownGX.toFixed(1) + "% " + shownGY.toFixed(1) +
"%, rgba(255,255,255,.5), transparent 55%)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
zone!.removeEventListener("pointermove", onMove);
zone!.removeEventListener("pointerleave", onLeave);
zone!.removeEventListener("focus", onFocus);
zone!.removeEventListener("blur", onBlur);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button className={styles.cardZone} ref={zoneRef} type="button" aria-label="스티커, 마우스를 올리면 포인터 방향으로 기운다">
<span className={styles.card} ref={cardRef}>
<span className={styles.glare} ref={glareRef} aria-hidden="true"></span>
<span className={styles.cardMark} aria-hidden="true"></span>
</span>
</button>
</div>
</main>
);
}06 カーソルに合わせて傾くステッカー — 計測 累積面積 5.158% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ホログラム シール デザイン 9種 — 白フチから剥がれる角まで
このデモが属する記事
ホログラム シール デザイン 9種 — 白フチから剥がれる角までこのホログラム シール デザイン 9種のうち、1つは白フチと影だけでダイカットステッカーに見せ、1つはポインタの角度に沿って表面を虹色の光が走り抜け、1つはカーソルに合わせてわずかに傾いて本物のシールに触れる感触を出します。 この記事のデモをすべて見る


