03角度に追従するホログラムステッカー
表面の上に虹色の薄い膜を重ね、カーソルが動く方向と速さに合わせてその光を表面を横切って走らせる(133の静的なカード・バッジのホログラムセットとは異なる実演 — ここではポインタの移動量が光の移動速度を直接決める)。
使いどころ限定版カード、グッズモックアッププレビュー
conic-gradientmix-blend-modeatan2

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-label="ホログラムステッカー、カーソルを動かすと表面の虹色の光が方向と速さに追従する">
<span class="cardMark" aria-hidden="true"></span>
<span class="sheen" id="sheen" aria-hidden="true"></span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 applyMove(x, y, now) 에 있다 — 포인터의 이동 속도(px/ms)가 색상 회전
// 속도(--hue-deg)와 빛 이동 위치(--sweep-pos)를 직접 정하고, 이동 방향(atan2)이 스윕 각도(--sweep-angle)를
// 정한다. 133 의 정적 카드·배지 홀로그램과 다른 계산 축 — 여기는 포인터 이동량이 입력이다.
@include stage($stage-orange);
.wrap { display: grid; place-items: center; }
.card {
position: relative;
width: 176px;
height: 176px;
border: 0;
padding: 0;
appearance: none;
cursor: pointer;
border-radius: $r-card;
background: $subject-ink;
overflow: hidden;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; }
}
.cardMark {
position: absolute;
inset: 0;
margin: auto;
width: 64px;
height: 64px;
background: $subject-cream;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: .18;
}
.sheen {
position: absolute;
inset: 0;
--hue-deg: 0deg;
--sweep-pos: 0%;
--sweep-angle: 115deg;
background:
linear-gradient(var(--sweep-angle),
transparent 0%, rgba(255, 255, 255, .5) 18%, transparent 36%),
conic-gradient(from var(--hue-deg),
#ddb9bc, #e3d6c5, #e1e0c1, #c1e1d2, #c1d8e1, #cdc1e1, #ddb9bc);
background-size: 220% 220%, 160% 160%;
background-position: var(--sweep-pos) 50%, center;
mix-blend-mode: color-dodge;
opacity: .8;
pointer-events: none;
}
@media (max-width: 340px) {
.card { width: 138px; height: 138px; }
.cardMark { width: 50px; height: 50px; }
}
@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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: grid;
place-items: center;
}
.card {
position: relative;
width: 176px;
height: 176px;
border: 0;
padding: 0;
appearance: none;
cursor: pointer;
border-radius: 16px;
background: #17141a;
overflow: hidden;
}
.card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.cardMark {
position: absolute;
inset: 0;
margin: auto;
width: 64px;
height: 64px;
background: #fff7e6;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
opacity: 0.18;
}
.sheen {
position: absolute;
inset: 0;
--hue-deg: 0deg;
--sweep-pos: 0%;
--sweep-angle: 115deg;
background: linear-gradient(var(--sweep-angle), transparent 0%, rgba(255, 255, 255, 0.5) 18%, transparent 36%), conic-gradient(from var(--hue-deg), #ddb9bc, #e3d6c5, #e1e0c1, #c1e1d2, #c1d8e1, #cdc1e1, #ddb9bc);
background-size: 220% 220%, 160% 160%;
background-position: var(--sweep-pos) 50%, center;
mix-blend-mode: color-dodge;
opacity: 0.8;
pointer-events: none;
}
@media (max-width: 340px) {
.card {
width: 138px;
height: 138px;
}
.cardMark {
width: 50px;
height: 50px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var HOLO_SWEEP_ANGLE_RANGE = 45; // 스윕 그라디언트가 회전하는 각도 폭(deg) — 변형은 90deg
var HUE_SPEED = 220; // 포인터 속도(px/ms) 가 色 회전 속도(deg/s)에 곱해지는 계수
var SWEEP_SPEED = 340; // 포인터 속도가 빛 이동 위치(%/s)에 곱해지는 계수
var LERP_RATE = .2;
var ORBIT_MS = 1900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var sheen = document.getElementById('sheen');
var isDemo = true;
var hue = 0, sweepPos = 0, sweepAngleTarget = 115;
var shownAngle = 115;
var lastX = null, lastY = null, lastTime = 0;
// 포인터의 이동량(dx, dy)과 걸린 시간(dt)으로 속도를 구해 色 회전 속도와 빛의 이동 속도를
// 직접 정한다 — 133 의 정적 홀로그램과 달리 여기선 포인터 이동량 자체가 계산 입력이다.
function applyMove(x, y, now) {
if (lastX !== null) {
var dt = Math.max(now - lastTime, 1);
var dx = x - lastX, dy = y - lastY;
var speed = Math.hypot(dx, dy) / dt; // px/ms
hue = (hue + speed * HUE_SPEED * (dt / 1000)) % 360;
sweepPos = (sweepPos + speed * SWEEP_SPEED * (dt / 1000)) % 200;
sweepAngleTarget = 90 + (Math.atan2(dy, dx) * 180 / Math.PI) % HOLO_SWEEP_ANGLE_RANGE;
}
lastX = x; lastY = y; lastTime = now;
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function onMove(e) { human(); applyMove(e.clientX, e.clientY, performance.now()); }
card.addEventListener('pointermove', onMove);
card.addEventListener('pointerleave', function () { human(); lastX = null; });
var orbitLast = 0;
function autoAdvance(ts) {
var t = (ts % ORBIT_MS) / ORBIT_MS;
var angle = t * Math.PI * 2;
var r = card.getBoundingClientRect();
var px = r.left + r.width / 2 + Math.cos(angle) * (r.width * .38);
var py = r.top + r.height / 2 + Math.sin(angle) * (r.height * .38);
applyMove(px, py, ts);
}
function loop(ts) {
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shownAngle = sweepAngleTarget; }
else { shownAngle += (sweepAngleTarget - shownAngle) * LERP_RATE; }
card.style.setProperty('--hue-deg', (reduce ? 0 : hue).toFixed(1) + 'deg');
card.style.setProperty('--sweep-pos', (reduce ? 0 : sweepPos).toFixed(1) + '%');
sheen.style.setProperty('--sweep-angle', shownAngle.toFixed(1) + 'deg');
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// Holographic — vanilla 판(run/378/demo/03-sticker-holographic/index.html) 의 React 포트.
// 포인터 이동 속도(px/ms)가 색상 회전 속도(--hue-deg)와 빛 이동 속도(--sweep-pos)를 직접 정하고,
// 이동 방향(atan2)이 스윕 각도(--sweep-angle)를 정한다 — 계산 겹은 applyMove(x, y, now) 에 있다.
import { useEffect, useRef } from "react";
import styles from "./Holographic.module.scss";
type Box<T> = { current: T };
const HOLO_SWEEP_ANGLE_RANGE = 45; // 변형: 90deg
const HUE_SPEED = 220;
const SWEEP_SPEED = 340;
const LERP_RATE = 0.2;
const ORBIT_MS = 1900;
export default function Holographic() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const sheenRef = 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 card = cardRef.current;
const sheen = sheenRef.current;
if (!wrap || !card || !sheen) return;
let isDemo = true;
let hue = 0, sweepPos = 0, sweepAngleTarget = 115;
let shownAngle = 115;
let lastX: number | null = null, lastY = 0, lastTime = 0;
function applyMove(x: number, y: number, now: number) {
if (lastX !== null) {
const dt = Math.max(now - lastTime, 1);
const dx = x - lastX, dy = y - lastY;
const speed = Math.hypot(dx, dy) / dt;
hue = (hue + speed * HUE_SPEED * (dt / 1000)) % 360;
sweepPos = (sweepPos + speed * SWEEP_SPEED * (dt / 1000)) % 200;
sweepAngleTarget = 90 + (Math.atan2(dy, dx) * 180 / Math.PI) % HOLO_SWEEP_ANGLE_RANGE;
}
lastX = x; lastY = y; lastTime = now;
}
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function onMove(e: PointerEvent) { human(); applyMove(e.clientX, e.clientY, performance.now()); }
function onLeave() { human(); lastX = null; }
card.addEventListener("pointermove", onMove);
card.addEventListener("pointerleave", onLeave);
function autoAdvance(ts: number) {
const t = (ts % ORBIT_MS) / ORBIT_MS;
const angle = t * Math.PI * 2;
const r = card!.getBoundingClientRect();
const px = r.left + r.width / 2 + Math.cos(angle) * (r.width * 0.38);
const py = r.top + r.height / 2 + Math.sin(angle) * (r.height * 0.38);
applyMove(px, py, ts);
}
function loop(ts: number) {
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shownAngle = sweepAngleTarget; }
else { shownAngle += (sweepAngleTarget - shownAngle) * LERP_RATE; }
card!.style.setProperty("--hue-deg", (reduce ? 0 : hue).toFixed(1) + "deg");
card!.style.setProperty("--sweep-pos", (reduce ? 0 : sweepPos).toFixed(1) + "%");
sheen!.style.setProperty("--sweep-angle", shownAngle.toFixed(1) + "deg");
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
card!.removeEventListener("pointermove", onMove);
card!.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button className={styles.card} ref={cardRef} type="button" aria-label="홀로그램 스티커, 커서를 움직이면 표면 위 무지개빛이 방향과 속도를 따라간다">
<span className={styles.cardMark} aria-hidden="true"></span>
<span className={styles.sheen} ref={sheenRef} aria-hidden="true"></span>
</button>
</div>
</main>
);
}03 角度に追従するホログラムステッカー — 計測 累積面積 14.422% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ホログラム シール デザイン 9種 — 白フチから剥がれる角まで
このデモが属する記事
ホログラム シール デザイン 9種 — 白フチから剥がれる角までこのホログラム シール デザイン 9種のうち、1つは白フチと影だけでダイカットステッカーに見せ、1つはポインタの角度に沿って表面を虹色の光が走り抜け、1つはカーソルに合わせてわずかに傾いて本物のシールに触れる感触を出します。 この記事のデモをすべて見る


