ホログラム シール デザイン 9種 — 白フチから剥がれる角まで
このホログラム シール デザイン 9種のうち、1つは白フチと影だけでダイカットステッカーに見せ、1つはポインタの角度に沿って表面を虹色の光が走り抜け、1つはカーソルに合わせてわずかに傾いて本物のシールに触れる感触を出します。
01 sticker-white-border-drop-shadow
02 sticker-peel-corner
03 sticker-holographic
04 sticker-tape-strip
05 sticker-folder-tab
06 sticker-tilt-on-hover
07 sticker-random-rotate-scatter
08 sticker-drag-place
09 sticker-print-cut-line
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 白フチのダイカットステッカーカード
- 02 角からめくれるステッカー
- 03 角度に追従するホログラムステッカー
- 04 マスキングテープで留めたステッカー
- 05 フォルダタブ型ステッカー
- 06 カーソルに合わせて傾くステッカー
- 07 ランダムな角度で散らばったステッカーの山
- 08 ドラッグして貼るステッカー
- 09 切り取り線が見えるステッカーシート
以下の9種はどれもフレームワーク不要・ビルド不要の素のHTML/CSS/JSファイル1本で完結しており、フォルダを1つ取り出して index.html をブラウザで開くだけでそのまま動きます。ホログラム シール 作り方を調べている方が実際に手を動かして確かめられるよう、各パターンの計算層だけを次の章で切り出します。
デモ
上の各カードにマウスを乗せる・クリックする・ドラッグすると、それぞれ異なる剥がれ方・傾き方・スナップの仕方を確認できます。以下の説明はすべて記憶からの要約ではなく、各デモの実際のソースを読んで書いています。
9パターンの内訳 — 計算が1層だけ走る場所
下の表はどのパターンも同じ枚数だけキャプチャしたフレームの実測値です。累積面積は動いた部分が各フレームでどれだけの割合を占めたかを合計した値、平均強度は動いたフレームごとのピクセル変化の強さ、動いたフレーム数(23フレーム中)は23回のフレーム間比較のうち実際に差分があった回数を指します。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム(23フレーム中) |
|---|---|---|---|---|
| 01 | 白フチのダイカットステッカーカード | 7.18% | 100.8 | 15 |
| 02 | 角からめくれるステッカー | 0.11% | 71.9 | 22 |
| 03 | 角度に追従するホログラムステッカー | 14.42% | 24.9 | 23 |
| 04 | マスキングテープで留めたステッカー | 10.05% | 78.3 | 20 |
| 05 | フォルダタブ型ステッカー | 1.11% | 127.5 | 15 |
| 06 | カーソルに合わせて傾くステッカー | 5.16% | 57.7 | 23 |
| 07 | ランダムな角度で散らばったステッカーの山 | 18.39% | 101.0 | 17 |
| 08 | ドラッグして貼るステッカー | 21.61% | 128.2 | 16 |
| 09 | 切り取り線が見えるステッカーシート | 8.43% | 72.3 | 23 |
累積面積が最大なのは08で21.61%、格子1マス分をドラッグして動かす方が、02の角がほんの0.11%だけめくれるより遥かに多くのピクセルを動かすためです。平均強度も08が128.2で最も高く、05の127.5とはわずか0.7差にとどまります — チップがスナップする瞬間とcanvasで実測したタブの縁は、面積が違っても変化そのものは鋭いということです。03・06・09はいずれも23フレーム中23回動いており並んで最多ですが、03自身の平均強度は24.9で9種中最も低く、色と光がゆっくり流れる変化はスナップや点線の行進ほどピクセルを激しく変えないからです。
クラスターA — ポインタのベクトルが毎フレーム角度に変わる(02・03・06)
02・03・06はどれもポインタの現在座標を Math.atan2() か -1..1 の正規化にかけ、毎フレーム角度や回転量を作り出します。めくれのclip-path角度も、光沢の色相とスイープの方向も、カードのrotateX/rotateYも、あらかじめ決めた位置の集合ではなく今ポインタがある場所そのものから生まれます。
クラスターB — ひとつの数値が固定式を動かす(01・04・09)
01・04・09は描画のたびにポインタ座標を読む必要がありません。持ち上がり比率・角の番号・経過時間という1つの数値だけで、box-shadowの重なりもテープの傾きも点線の行進オフセットも1本の算術式で決まります。
クラスターC — 入力が決まった状態の1つに収束する(05・07・08)
05・07・08は入力を少数の決まった結果のどれかに落とし込みます。canvasで実測したラベル幅を最小値と最大値の間に収める、短い固定リストから引いたシード値のハッシュを使う、ポインタ位置を2×2の格子に丸める、というように連続値のまま扱うことはありません。
パターンごとのコード
以下の9本はそれぞれのソース全体から、実際に動きを決めている計算層だけを切り出した短い断片です。フレームワークは使っていません。
01白フチのダイカットステッカーカード
画像やアイコンの周りに太い白フチを回し、背後に柔らかい影を敷いて、実際に印刷して切り抜いたステッカーをその場に貼ったように見せます。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="badge" id="badge" type="button" aria-label="ダイカットステッカー、マウスを乗せるかフォーカスすると持ち上がる">
<span class="badgeMark" aria-hidden="true"></span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 shadowFor(t) 에 있다 — 들어올림 진행도(t, 0~1)에 비례해 그림자 레이어
// 5장의 오프셋·흐림·불투명도를 산술로 늘려 쌓아 하나의 box-shadow 로 합친다. 여기는 다이컷 모양의
// 흰 테두리와 배지 표면만 맡는다. transform·box-shadow 는 JS 가 매 프레임 직접 쓴다(rAF, paint 만).
@include stage($stage-ink);
.wrap {
display: grid;
place-items: center;
}
.badge {
position: relative;
width: 130px;
height: 130px;
border-radius: 50%;
border: 10px solid $subject-cream;
background: $subject-blue-deep;
padding: 0;
appearance: none;
cursor: pointer;
will-change: transform, box-shadow;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; }
}
.badgeMark {
position: absolute;
inset: 0;
margin: auto;
width: 46px;
height: 46px;
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%);
}
@media (max-width: 340px) {
.badge { width: 100px; height: 100px; border-width: 8px; }
.badgeMark { width: 36px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로(pointerenter/focus)는 그대로 두되, 자동 루프의 시각 전환만 즉시 스냅되도록
// index.html 의 reduce 분기가 lerp 를 건너뛴다 — 여긴 transition 이 없어 추가로 끌 것이 없다.
.badge { 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);
}
.wrap {
display: grid;
place-items: center;
}
.badge {
position: relative;
width: 130px;
height: 130px;
border-radius: 50%;
border: 10px solid #fff7e6;
background: #0a4fe8;
padding: 0;
appearance: none;
cursor: pointer;
will-change: transform, box-shadow;
}
.badge:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.badgeMark {
position: absolute;
inset: 0;
margin: auto;
width: 46px;
height: 46px;
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%);
}
@media (max-width: 340px) {
.badge {
width: 100px;
height: 100px;
border-width: 8px;
}
.badgeMark {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
transition: none;
}
}(function () {
var LIFT_LAYERS = 5; // 그림자를 쌓는 겹 수
var LAYER_OFFSET = 3.6; // 겹마다 늘어나는 세로 오프셋(px, 완전히 뜬 상태 기준)
var LAYER_BLUR = 6; // 겹마다 늘어나는 흐림(px)
var LAYER_ALPHA_BASE = .34; // 첫 겹 불투명도
var LAYER_ALPHA_STEP = .05; // 겹마다 옅어지는 정도
var LIFT_PX = 18; // 완전히 뜬 상태에서 위로 이동하는 거리(px)
var LIFT_SCALE = .08; // 완전히 뜬 상태에서 커지는 비율
var LERP_RATE = .16;
var STEP_MS = 900;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var badge = document.getElementById('badge');
var isDemo = true;
var target = 0; // 0 = 붙어 있음, 1 = 완전히 뜸
var shown = 0;
// 겹마다 오프셋·흐림·불투명도를 산술로 늘려 쌓는다 — 하나의 box-shadow 문자열로 합쳐 다이컷 스티커의
// 두꺼운 그림자 뒤판을 만든다. 이 계산이 뜬 정도(t)에 정비례해 매 프레임 다시 도는 것이 계산 겹이다.
function shadowFor(t) {
var parts = [];
for (var i = 1; i <= LIFT_LAYERS; i++) {
var off = (LAYER_OFFSET * i * t).toFixed(2);
var blur = (LAYER_BLUR * i * t + 2).toFixed(2);
var alpha = Math.max(0, LAYER_ALPHA_BASE - LAYER_ALPHA_STEP * i) * t;
parts.push('0 ' + off + 'px ' + blur + 'px rgba(10,8,14,' + alpha.toFixed(3) + ')');
}
return parts.join(', ');
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function lift() { human(); target = 1; }
function drop() { human(); target = 0; }
badge.addEventListener('pointerenter', lift);
badge.addEventListener('pointerleave', drop);
badge.addEventListener('focus', lift);
badge.addEventListener('blur', drop);
var dir = 1;
var last = 0;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
target = dir > 0 ? 1 : 0;
dir *= -1;
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
badge.style.transform = 'translateY(' + (-LIFT_PX * shown).toFixed(2) + 'px) scale(' + (1 + LIFT_SCALE * shown).toFixed(3) + ')';
badge.style.boxShadow = shadowFor(shown);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// WhiteBorderDropShadow — vanilla 판(run/378/demo/01-sticker-white-border-drop-shadow/index.html) 의 React 포트.
// 들어올림 진행도(t)에 비례해 그림자 레이어 5장의 오프셋·흐림·불투명도를 산술로 늘려 쌓아
// 하나의 box-shadow 로 합친다 — 계산 겹은 shadowFor(t) 에 있다.
import { useEffect, useRef } from "react";
import styles from "./WhiteBorderDropShadow.module.scss";
type Box<T> = { current: T };
const LIFT_LAYERS = 5;
const LAYER_OFFSET = 3.6;
const LAYER_BLUR = 6;
const LAYER_ALPHA_BASE = 0.34;
const LAYER_ALPHA_STEP = 0.05;
const LIFT_PX = 18;
const LIFT_SCALE = 0.08;
const LERP_RATE = 0.16;
const STEP_MS = 900;
export default function WhiteBorderDropShadow() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const badgeRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const badge = badgeRef.current;
if (!wrap || !badge) return;
let isDemo = true;
let target = 0;
let shown = 0;
function shadowFor(t: number) {
const parts: string[] = [];
for (let i = 1; i <= LIFT_LAYERS; i++) {
const off = (LAYER_OFFSET * i * t).toFixed(2);
const blur = (LAYER_BLUR * i * t + 2).toFixed(2);
const alpha = Math.max(0, LAYER_ALPHA_BASE - LAYER_ALPHA_STEP * i) * t;
parts.push("0 " + off + "px " + blur + "px rgba(10,8,14," + alpha.toFixed(3) + ")");
}
return parts.join(", ");
}
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function lift() { human(); target = 1; }
function drop() { human(); target = 0; }
badge.addEventListener("pointerenter", lift);
badge.addEventListener("pointerleave", drop);
badge.addEventListener("focus", lift);
badge.addEventListener("blur", drop);
let dir = 1;
let last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
target = dir > 0 ? 1 : 0;
dir *= -1;
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
badge!.style.transform = "translateY(" + (-LIFT_PX * shown).toFixed(2) + "px) scale(" + (1 + LIFT_SCALE * shown).toFixed(3) + ")";
badge!.style.boxShadow = shadowFor(shown);
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
badge!.removeEventListener("pointerenter", lift);
badge!.removeEventListener("pointerleave", drop);
badge!.removeEventListener("focus", lift);
badge!.removeEventListener("blur", drop);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button className={styles.badge} ref={badgeRef} type="button" aria-label="다이컷 스티커, 마우스를 올리거나 초점을 주면 들어올려진다">
<span className={styles.badgeMark} aria-hidden="true"></span>
</button>
</div>
</main>
);
}01 · 白フチのダイカットステッカーカード — 計測 累積面積 7.179% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function shadowFor(t) {
var parts = [];
for (var i = 1; i <= LIFT_LAYERS; i++) {
var off = (LAYER_OFFSET * i * t).toFixed(2);
var blur = (LAYER_BLUR * i * t + 2).toFixed(2);
var alpha = Math.max(0, LAYER_ALPHA_BASE - LAYER_ALPHA_STEP * i) * t;
parts.push('0 ' + off + 'px ' + blur + 'px rgba(10,8,14,' + alpha.toFixed(3) + ')');
}
return parts.join(', ');
}
02角からめくれるステッカー
カードにカーソルを乗せると片方の角が紙のように後ろへめくれ上がり、その下に隠れていた別の色が少し見えます — 133のホログラムバッジのめくれ+スイープの組み合わせとは異なる実演で、ここではめくれる角度自体がポインタの位置に追従します。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="cardZone" id="zone">
<div class="backing" aria-hidden="true"></div>
<button class="card" id="card" type="button" aria-label="ステッカーカード、カーソルを角の方へ動かすとめくれる">
<span class="cardMark" aria-hidden="true"></span>
<span class="peelShade" id="shade" aria-hidden="true"></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 applyFromPoint(px, py) 에 있다 — 카드 우상단 꼭짓점에서 포인터까지의
// 거리로 벗겨지는 크기(--peel-size)를, atan2 각도로 그라디언트 방향(--peel-deg)을 매 프레임 다시 잰다.
// 여기는 그 두 변수를 clip-path 코너 컷과 그라디언트에 그대로 꽂아 쓴다(레이아웃을 건드리지 않는 paint 계산).
@include stage($stage-yellow);
.wrap { display: grid; place-items: center; }
.cardZone {
position: relative;
width: 170px;
height: 170px;
}
.backing {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-cream;
}
.card {
position: absolute;
inset: 0;
border: 0;
padding: 0;
appearance: none;
cursor: pointer;
background: $subject-blue;
--peel-size: 0px;
clip-path: polygon(0 0, calc(100% - var(--peel-size)) 0, 100% var(--peel-size), 100% 100%, 0 100%);
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; }
}
.cardMark {
position: absolute;
inset: 0;
margin: auto;
width: 56px;
height: 56px;
border-radius: 50%;
border: 8px solid rgba($subject-cream, .8);
}
.peelShade {
position: absolute;
top: 0;
right: 0;
width: var(--peel-size, 0px);
height: var(--peel-size, 0px);
--peel-deg: 45deg;
clip-path: polygon(100% 0, 100% 100%, 0 0);
background: linear-gradient(var(--peel-deg), rgba(255, 247, 230, .95), rgba(255, 247, 230, 0) 72%);
pointer-events: none;
}
@media (max-width: 340px) {
.cardZone { width: 130px; height: 130px; }
.cardMark { width: 42px; height: 42px; 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 {
position: relative;
width: 170px;
height: 170px;
}
.backing {
position: absolute;
inset: 0;
border-radius: 16px;
background: #fff7e6;
}
.card {
position: absolute;
inset: 0;
border: 0;
padding: 0;
appearance: none;
cursor: pointer;
background: #2f6df6;
--peel-size: 0px;
clip-path: polygon(0 0, calc(100% - var(--peel-size)) 0, 100% var(--peel-size), 100% 100%, 0 100%);
}
.card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.cardMark {
position: absolute;
inset: 0;
margin: auto;
width: 56px;
height: 56px;
border-radius: 50%;
border: 8px solid rgba(255, 247, 230, 0.8);
}
.peelShade {
position: absolute;
top: 0;
right: 0;
width: var(--peel-size, 0px);
height: var(--peel-size, 0px);
--peel-deg: 45deg;
clip-path: polygon(100% 0, 100% 100%, 0 0);
background: linear-gradient(var(--peel-deg), rgba(255, 247, 230, 0.95), rgba(255, 247, 230, 0) 72%);
pointer-events: none;
}
@media (max-width: 340px) {
.cardZone {
width: 130px;
height: 130px;
}
.cardMark {
width: 42px;
height: 42px;
border-width: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var PEEL_CORNER_SIZE = 24; // 커서가 모서리에 딱 붙었을 때 벗겨지는 기본 크기(px) — 변형은 44px
var PEEL_REACH = 190; // 이 거리(px) 밖이면 벗겨짐이 0 이 된다
var LERP_RATE = .18;
var ORBIT_MS = 2600; // 자동 루프가 코너 주변을 도는 한 바퀴 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var zone = document.getElementById('zone');
var card = document.getElementById('card');
var shade = document.getElementById('shade');
var isDemo = true;
var targetSize = 0, targetDeg = 45;
var shownSize = 0, shownDeg = 45;
// 카드 우상단 꼭짓점(pivot)에서 포인터까지의 벡터로 벗겨짐 크기와 그라디언트 각도를 계산한다.
// 거리(distance)가 가까울수록 크게 벗겨지고, 각도(atan2)는 그라디언트가 흐르는 방향을 정한다 —
// 포인터 각도 → clip-path/그라디언트 계산.
function applyFromPoint(px, py) {
var r = card.getBoundingClientRect();
var pivotX = r.right, pivotY = r.top;
var dx = pivotX - px, dy = py - pivotY;
var distance = Math.hypot(dx, dy);
var amt = Math.max(0, Math.min(1, 1 - distance / PEEL_REACH));
targetSize = amt * PEEL_CORNER_SIZE;
targetDeg = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360;
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function onMove(e) { human(); applyFromPoint(e.clientX, e.clientY); }
function onLeave() { human(); targetSize = 0; }
zone.addEventListener('pointermove', onMove);
zone.addEventListener('pointerleave', onLeave);
card.addEventListener('focus', function () { human(); applyFromPoint(card.getBoundingClientRect().left, card.getBoundingClientRect().top); });
card.addEventListener('blur', function () { human(); targetSize = 0; });
var last = 0;
function autoAdvance(ts) {
if (!last) last = ts;
var r = card.getBoundingClientRect();
var t = ((ts - last) % ORBIT_MS) / ORBIT_MS;
var angle = t * Math.PI * 2;
var px = r.right - 26 + Math.cos(angle) * 30;
var py = r.top + 26 + Math.sin(angle) * 30;
applyFromPoint(px, py);
}
function loop(ts) {
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shownSize = targetSize; shownDeg = targetDeg; }
else {
shownSize += (targetSize - shownSize) * LERP_RATE;
shownDeg += (targetDeg - shownDeg) * LERP_RATE;
}
card.style.setProperty('--peel-size', shownSize.toFixed(2) + 'px');
shade.style.setProperty('--peel-deg', shownDeg.toFixed(1) + 'deg');
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// PeelCorner — vanilla 판(run/378/demo/02-sticker-peel-corner/index.html) 의 React 포트.
// 카드 우상단 꼭짓점에서 포인터까지의 벡터로 벗겨짐 크기와 그라디언트 각도(atan2)를 계산한다.
import { useEffect, useRef } from "react";
import styles from "./PeelCorner.module.scss";
type Box<T> = { current: T };
const PEEL_CORNER_SIZE = 24; // 변형: 44px
const PEEL_REACH = 190;
const LERP_RATE = 0.18;
const ORBIT_MS = 2600;
export default function PeelCorner() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const zoneRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const shadeRef = 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 shade = shadeRef.current;
if (!wrap || !zone || !card || !shade) return;
let isDemo = true;
let targetSize = 0, targetDeg = 45;
let shownSize = 0, shownDeg = 45;
function applyFromPoint(px: number, py: number) {
const r = card!.getBoundingClientRect();
const pivotX = r.right, pivotY = r.top;
const dx = pivotX - px, dy = py - pivotY;
const distance = Math.hypot(dx, dy);
const amt = Math.max(0, Math.min(1, 1 - distance / PEEL_REACH));
targetSize = amt * PEEL_CORNER_SIZE;
targetDeg = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360;
}
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function onMove(e: PointerEvent) { human(); applyFromPoint(e.clientX, e.clientY); }
function onLeave() { human(); targetSize = 0; }
function onFocus() { human(); const r = card!.getBoundingClientRect(); applyFromPoint(r.left, r.top); }
function onBlur() { human(); targetSize = 0; }
zone.addEventListener("pointermove", onMove);
zone.addEventListener("pointerleave", onLeave);
card.addEventListener("focus", onFocus);
card.addEventListener("blur", onBlur);
let last = 0;
function autoAdvance(ts: number) {
if (!last) last = ts;
const r = card!.getBoundingClientRect();
const t = ((ts - last) % ORBIT_MS) / ORBIT_MS;
const angle = t * Math.PI * 2;
const px = r.right - 26 + Math.cos(angle) * 30;
const py = r.top + 26 + Math.sin(angle) * 30;
applyFromPoint(px, py);
}
function loop(ts: number) {
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shownSize = targetSize; shownDeg = targetDeg; }
else {
shownSize += (targetSize - shownSize) * LERP_RATE;
shownDeg += (targetDeg - shownDeg) * LERP_RATE;
}
card!.style.setProperty("--peel-size", shownSize.toFixed(2) + "px");
shade!.style.setProperty("--peel-deg", shownDeg.toFixed(1) + "deg");
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
zone!.removeEventListener("pointermove", onMove);
zone!.removeEventListener("pointerleave", onLeave);
card!.removeEventListener("focus", onFocus);
card!.removeEventListener("blur", onBlur);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.cardZone} ref={zoneRef}>
<div className={styles.backing} aria-hidden="true"></div>
<button className={styles.card} ref={cardRef} type="button" aria-label="스티커 카드, 커서를 모서리 쪽으로 움직이면 벗겨진다">
<span className={styles.cardMark} aria-hidden="true"></span>
<span className={styles.peelShade} ref={shadeRef} aria-hidden="true"></span>
</button>
</div>
</div>
</main>
);
}02 · 角からめくれるステッカー — 計測 累積面積 0.108% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function applyFromPoint(px, py) {
var r = card.getBoundingClientRect();
var pivotX = r.right, pivotY = r.top;
var dx = pivotX - px, dy = py - pivotY;
var distance = Math.hypot(dx, dy);
var amt = Math.max(0, Math.min(1, 1 - distance / PEEL_REACH));
targetSize = amt * PEEL_CORNER_SIZE;
targetDeg = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360;
}
03角度に追従するホログラムステッカー
表面の上に虹色の薄い膜を重ね、カーソルが動く方向と速さに合わせてその光を表面を横切って走らせます — 133の静的なカード・バッジのホログラムセットとは異なる実演で、ここではポインタの移動量そのものが光の移動速度を直接決めます。外部画像を使わず、conic-gradientをmix-blend-modeで重ねるだけのホログラム シート シール 作り方の一例として見ることもできます。

<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 に同梱 · パスワードを見る
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;
}
04マスキングテープで留めたステッカー
カードの角二か所に半透明のテープ片を斜めに乗せて、実際に手でテープを貼って留めたスクラップブックのように見せます。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="cardZone">
<div class="card" id="card">
<span class="cardMark" aria-hidden="true"></span>
</div>
<span class="tape tape--a" id="tapeA" aria-hidden="true"></span>
<span class="tape tape--b" id="tapeB" aria-hidden="true"></span>
</div>
<button class="pinBtn" id="pinBtn" type="button" aria-pressed="false">テープを片方はがす</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 tapeAngle(i) 에 있다 — 두 테이프의 기울기를 base + i*step 산술식으로
// 얻고(하드코딩 개별값 아님), 카드는 그 자체가 아니라 떼어짐 진행도(shown)에 비례해 회전·이동한다.
// 여기는 카드·테이프의 모양과 위치만 담당한다.
@include stage($stage-paper);
.wrap {
display: grid;
place-items: center;
gap: $sp-4;
}
.cardZone {
position: relative;
width: 168px;
height: 168px;
}
.card {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-blue;
box-shadow: $shadow-raised;
transform-origin: 20% 80%; // 오른쪽 위 테이프에 붙들려 왼쪽 아래를 축으로 기운다
display: grid;
place-items: center;
}
.cardMark {
width: 56px;
height: 56px;
border-radius: 50%;
border: 8px solid rgba($subject-cream, .85);
}
.tape {
position: absolute;
width: 84px;
height: 30px;
background: rgba($subject-cream, .55);
border: 1px solid rgba($subject-ink, .08);
box-shadow: 0 2px 4px rgba($subject-ink, .12);
}
.tape--a { top: -10px; left: -18px; }
.tape--b { bottom: -12px; right: -16px; }
.pinBtn {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
padding: $sp-2 $sp-4;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: scale(1.03); }
&:active { transform: scale(.97); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
@media (max-width: 340px) {
.cardZone { width: 130px; height: 130px; }
.cardMark { width: 44px; height: 44px; border-width: 6px; }
.tape { width: 66px; height: 24px; }
.pinBtn { font-size: 11px; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.pinBtn { 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);
}
.wrap {
display: grid;
place-items: center;
gap: 16px;
}
.cardZone {
position: relative;
width: 168px;
height: 168px;
}
.card {
position: absolute;
inset: 0;
border-radius: 16px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: 20% 80%;
display: grid;
place-items: center;
}
.cardMark {
width: 56px;
height: 56px;
border-radius: 50%;
border: 8px solid rgba(255, 247, 230, 0.85);
}
.tape {
position: absolute;
width: 84px;
height: 30px;
background: rgba(255, 247, 230, 0.55);
border: 1px solid rgba(23, 20, 26, 0.08);
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.12);
}
.tape--a {
top: -10px;
left: -18px;
}
.tape--b {
bottom: -12px;
right: -16px;
}
.pinBtn {
appearance: none;
border: none;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
padding: 8px 16px;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: transform 140ms ease-out;
}
.pinBtn:hover {
transform: scale(1.03);
}
.pinBtn:active {
transform: scale(0.97);
}
.pinBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
@media (max-width: 340px) {
.cardZone {
width: 130px;
height: 130px;
}
.cardMark {
width: 44px;
height: 44px;
border-width: 6px;
}
.tape {
width: 66px;
height: 24px;
}
.pinBtn {
font-size: 11px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pinBtn {
transition: none;
}
}(function () {
var TAPE_BASE_ANGLE = 14; // 테이프 기울기의 시작값(deg)
var TAPE_ANGLE_STEP = 8; // 테이프마다 더해지는 기울기 증가폭(deg) — 각도는 하드코딩이 아니라 산술로 만든다
var LIFT_MAX_DEG = 10; // 완전히 떼어졌을 때 카드가 기우는 각도(deg)
var LIFT_MAX_PX = 8; // 완전히 떼어졌을 때 카드가 뜨는 높이(px)
var TAPE_STRETCH = 6; // 떼는 동안 tapeA 가 더 기울어지는 최대폭(deg)
var LERP_RATE = .17;
var STEP_MS = 550;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var tapeA = document.getElementById('tapeA');
var tapeB = document.getElementById('tapeB');
var pinBtn = document.getElementById('pinBtn');
var isDemo = true;
// 두 테이프의 기울기는 코너 순번(i)에 산술식(base + i*step)을 적용해 얻는다 — 부호는 좌우 대칭으로
// 번갈아 뒤집는다. 하드코딩된 개별 각도값이 아니라 하나의 공식에서 나온 값이라는 게 계산 겹이다.
function tapeAngle(i) {
var sign = i % 2 === 0 ? -1 : 1;
return sign * (TAPE_BASE_ANGLE + TAPE_ANGLE_STEP * i);
}
var baseAngleA = tapeAngle(1);
var baseAngleB = tapeAngle(2);
tapeB.style.transform = 'rotate(' + baseAngleB + 'deg)';
var target = 0; // 0 = 붙어 있음, 1 = 한쪽이 떼어짐
var shown = 0;
var pinned = true;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function toggle() {
human();
pinned = !pinned;
target = pinned ? 0 : 1;
pinBtn.setAttribute('aria-pressed', String(!pinned));
pinBtn.textContent = pinned ? 'テープを片方はがす' : 'もう一度貼る';
}
pinBtn.addEventListener('click', toggle);
var dir = 1, last = 0;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
target = dir > 0 ? 1 : 0;
dir *= -1;
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
card.style.transform = 'rotate(' + (LIFT_MAX_DEG * shown).toFixed(2) + 'deg) translate(' +
(LIFT_MAX_PX * shown * .6).toFixed(2) + 'px, ' + (-LIFT_MAX_PX * shown).toFixed(2) + 'px)';
tapeA.style.transform = 'rotate(' + (baseAngleA + TAPE_STRETCH * shown).toFixed(2) + 'deg) translateY(' +
(-LIFT_MAX_PX * .6 * shown).toFixed(2) + 'px)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// TapeStrip — vanilla 판(run/378/demo/04-sticker-tape-strip/index.html) 의 React 포트.
// 두 테이프의 기울기를 base + i*step 산술식(tapeAngle)으로 얻는다 — 하드코딩 개별값이 아니다.
import { useEffect, useRef } from "react";
import styles from "./TapeStrip.module.scss";
type Box<T> = { current: T };
const TAPE_BASE_ANGLE = 14;
const TAPE_ANGLE_STEP = 8;
const LIFT_MAX_DEG = 10;
const LIFT_MAX_PX = 8;
const TAPE_STRETCH = 6;
const LERP_RATE = 0.17;
const STEP_MS = 550;
function tapeAngle(i: number) {
const sign = i % 2 === 0 ? -1 : 1;
return sign * (TAPE_BASE_ANGLE + TAPE_ANGLE_STEP * i);
}
const baseAngleA = tapeAngle(1);
const baseAngleB = tapeAngle(2);
export default function TapeStrip() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tapeARef = useRef(null) as unknown as Box<HTMLSpanElement>;
const tapeBRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const pinBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
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 tapeA = tapeARef.current;
const tapeB = tapeBRef.current;
const pinBtn = pinBtnRef.current;
if (!wrap || !card || !tapeA || !tapeB || !pinBtn) return;
tapeB.style.transform = "rotate(" + baseAngleB + "deg)";
let isDemo = true;
let target = 0;
let shown = 0;
let pinned = true;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function toggle() {
human();
pinned = !pinned;
target = pinned ? 0 : 1;
pinBtn!.setAttribute("aria-pressed", String(!pinned));
pinBtn!.textContent = pinned ? "테이프 한쪽 떼기" : "다시 붙이기";
}
pinBtn.addEventListener("click", toggle);
let dir = 1, last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
target = dir > 0 ? 1 : 0;
dir *= -1;
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
card!.style.transform = "rotate(" + (LIFT_MAX_DEG * shown).toFixed(2) + "deg) translate(" +
(LIFT_MAX_PX * shown * 0.6).toFixed(2) + "px, " + (-LIFT_MAX_PX * shown).toFixed(2) + "px)";
tapeA!.style.transform = "rotate(" + (baseAngleA + TAPE_STRETCH * shown).toFixed(2) + "deg) translateY(" +
(-LIFT_MAX_PX * 0.6 * shown).toFixed(2) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
pinBtn!.removeEventListener("click", toggle);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.cardZone}>
<div className={styles.card} ref={cardRef}>
<span className={styles.cardMark} aria-hidden="true"></span>
</div>
<span className={styles.tape + " " + styles.tapeA} ref={tapeARef} aria-hidden="true"></span>
<span className={styles.tape + " " + styles.tapeB} ref={tapeBRef} aria-hidden="true"></span>
</div>
<button className={styles.pinBtn} ref={pinBtnRef} type="button" aria-pressed="false">테이프 한쪽 떼기</button>
</div>
</main>
);
}04 · マスキングテープで留めたステッカー — 計測 累積面積 10.047% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 2本のテープの傾きは、角の番号(i)を base + i*step という1本の式に通して求める —
// 符号だけ左右で交互に反転させ、固定の角度を2つ持たせず1つの式から両方を作る。
function tapeAngle(i) {
var sign = i % 2 === 0 ? -1 : 1;
return sign * (TAPE_BASE_ANGLE + TAPE_ANGLE_STEP * i);
}
var baseAngleA = tapeAngle(1);
var baseAngleB = tapeAngle(2);
05フォルダタブ型ステッカー
上の角が書類フォルダのタブのように飛び出た形に切り抜かれ、そのタブ部分に名前を書いてカテゴリを分けます。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="folder">
<div class="tab" id="tab">
<span class="tabLabel" id="tabLabel">書類</span>
</div>
<div class="body"><span class="cardMark" aria-hidden="true"></span></div>
</div>
<div class="switchRow">
<button type="button" id="btnPrev">前のラベル</button>
<button type="button" id="btnNext">次のラベル</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 measureWidth(text) 에 있다 — 숨은 canvas 로 라벨 글자 폭을 실측해 탭
// 폭(--tab-w)을 정한다. 탭 상자 실제 width 는 최대치(TAB_MAX)로 고정해 두고 clip-path 로만
// 드러내는 폭을 바꿔 레이아웃을 다시 계산시키지 않는다.
@include stage($stage-ink);
.wrap {
display: grid;
place-items: center;
gap: $sp-4;
}
.folder {
width: 220px;
}
.tab {
--tab-w: 88px;
position: relative;
height: 32px;
width: 156px;
clip-path: inset(0 calc(156px - var(--tab-w)) 0 0);
background: $subject-orange-deep;
border-radius: $r-sm $r-sm 0 0;
display: flex;
align-items: center;
padding: 0 $sp-3;
}
.tabLabel {
color: $subject-cream;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.body {
height: 128px;
border-radius: 0 $r-card $r-card $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
display: grid;
place-items: center;
}
.cardMark {
width: 52px;
height: 52px;
border-radius: 50%;
border: 8px solid rgba($subject-orange-deep, .55);
}
.switchRow {
display: flex;
gap: $sp-2;
}
.switchRow button {
appearance: none;
border: none;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
color: $subject-cream;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background $dur-instant $ease-out;
&:hover { background: rgba($subject-cream, .24); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.folder { width: 180px; }
.tab { width: 128px; clip-path: inset(0 calc(128px - var(--tab-w)) 0 0); height: 28px; }
.body { height: 100px; }
.cardMark { width: 40px; height: 40px; border-width: 6px; }
.switchRow button { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.switchRow button { 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);
}
.wrap {
display: grid;
place-items: center;
gap: 16px;
}
.folder {
width: 220px;
}
.tab {
--tab-w: 88px;
position: relative;
height: 32px;
width: 156px;
clip-path: inset(0 calc(156px - var(--tab-w)) 0 0);
background: #c93000;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
padding: 0 12px;
}
.tabLabel {
color: #fff7e6;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.body {
height: 128px;
border-radius: 0 16px 16px 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
}
.cardMark {
width: 52px;
height: 52px;
border-radius: 50%;
border: 8px solid rgba(201, 48, 0, 0.55);
}
.switchRow {
display: flex;
gap: 8px;
}
.switchRow button {
appearance: none;
border: none;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 140ms ease-out;
}
.switchRow button:hover {
background: rgba(255, 247, 230, 0.24);
}
.switchRow button:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.folder {
width: 180px;
}
.tab {
width: 128px;
clip-path: inset(0 calc(128px - var(--tab-w)) 0 0);
height: 28px;
}
.body {
height: 100px;
}
.cardMark {
width: 40px;
height: 40px;
border-width: 6px;
}
.switchRow button {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.switchRow button {
transition: none;
}
}(function () {
var LABELS = ['書類', '色', '資料保管庫']; // 순서대로만 순환 — 길이가 다 다르다
var TAB_PADDING = 28; // 실측한 글자 폭 양옆에 더하는 여백(px)
var TAB_MIN = 60;
var TAB_MAX = 156;
var LERP_RATE = .15;
var STEP_MS = 500;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var tab = document.getElementById('tab');
var tabLabel = document.getElementById('tabLabel');
var btnPrev = document.getElementById('btnPrev');
var btnNext = document.getElementById('btnNext');
var isDemo = true;
var measureCanvas = document.createElement('canvas');
var ctx = measureCanvas.getContext('2d');
// 라벨 글자 폭을 canvas 로 실측해 탭이 그 글자에 딱 맞는 폭을 갖게 한다 — 폭은 미리 정한 값이
// 아니라 매번 실제로 잰 값이다. 실제 상자 너비(width)는 최대치로 고정해 두고 clip-path 로만
// 드러내는 폭을 바꿔 레이아웃 재계산 없이(transform·opacity 규칙과 같은 취지) 늘고 준다.
function measureWidth(text) {
ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var w = ctx.measureText(text).width;
return Math.max(TAB_MIN, Math.min(TAB_MAX, w + TAB_PADDING));
}
var index = 0;
var target = measureWidth(LABELS[0]);
var shown = target;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function setLabel(i) {
index = (i + LABELS.length) % LABELS.length;
tabLabel.textContent = LABELS[index];
target = measureWidth(LABELS[index]);
}
btnPrev.addEventListener('click', function () { human(); setLabel(index - 1); });
btnNext.addEventListener('click', function () { human(); setLabel(index + 1); });
var last = 0;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
setLabel(index + 1);
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
tab.style.setProperty('--tab-w', shown.toFixed(2) + 'px');
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// FolderTab — vanilla 판(run/378/demo/05-sticker-folder-tab/index.html) 의 React 포트.
// 숨은 canvas 로 라벨 글자 폭을 실측해(measureWidth) 탭 폭(--tab-w)을 정한다 — 실제 상자 width 는
// 최대치로 고정하고 clip-path 로만 드러내는 폭을 바꿔 레이아웃 재계산을 피한다.
import { useEffect, useRef } from "react";
import styles from "./FolderTab.module.scss";
type Box<T> = { current: T };
const LABELS = ["문서", "색상", "자료 보관함"];
const TAB_PADDING = 28;
const TAB_MIN = 60;
const TAB_MAX = 156;
const LERP_RATE = 0.15;
const STEP_MS = 500;
export default function FolderTab() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRef = useRef(null) as unknown as Box<HTMLDivElement>;
const labelRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const prevRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const nextRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const tab = tabRef.current;
const tabLabel = labelRef.current;
const btnPrev = prevRef.current;
const btnNext = nextRef.current;
if (!wrap || !tab || !tabLabel || !btnPrev || !btnNext) return;
const measureCanvas = document.createElement("canvas");
const ctx = measureCanvas.getContext("2d");
function measureWidth(text: string) {
if (!ctx) return TAB_MIN;
ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const w = ctx.measureText(text).width;
return Math.max(TAB_MIN, Math.min(TAB_MAX, w + TAB_PADDING));
}
let isDemo = true;
let index = 0;
let target = measureWidth(LABELS[0]);
let shown = target;
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function setLabel(i: number) {
index = (i + LABELS.length) % LABELS.length;
tabLabel!.textContent = LABELS[index];
target = measureWidth(LABELS[index]);
}
function onPrev() { human(); setLabel(index - 1); }
function onNext() { human(); setLabel(index + 1); }
btnPrev.addEventListener("click", onPrev);
btnNext.addEventListener("click", onNext);
let last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
setLabel(index + 1);
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (reduce) { shown = target; } else { shown += (target - shown) * LERP_RATE; }
tab!.style.setProperty("--tab-w", shown.toFixed(2) + "px");
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
btnPrev!.removeEventListener("click", onPrev);
btnNext!.removeEventListener("click", onNext);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.folder}>
<div className={styles.tab} ref={tabRef}>
<span className={styles.tabLabel} ref={labelRef}>문서</span>
</div>
<div className={styles.body}><span className={styles.cardMark} aria-hidden="true"></span></div>
</div>
<div className={styles.switchRow}>
<button type="button" ref={prevRef}>이전 이름표</button>
<button type="button" ref={nextRef}>다음 이름표</button>
</div>
</div>
</main>
);
}05 · フォルダタブ型ステッカー — 計測 累積面積 1.111% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// ラベルの実際の文字幅をcanvasで測り、タブが常にその文字にぴったり合う幅を持つようにする —
// 幅はあらかじめ決めた値ではなく、毎回実測した値になる。箱そのものは最大幅で固定しておき、
// clip-pathで見せる幅だけを変えるので(transform・opacityだけを動かす原則と同じ狙いで)、レイアウトの再計算が起きない。
function measureWidth(text) {
ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var w = ctx.measureText(text).width;
return Math.max(TAB_MIN, Math.min(TAB_MAX, w + TAB_PADDING));
}
06カーソルに合わせて傾くステッカー
カーソルがカード上のどこにあるかに応じてカードがその反対方向へわずかに立体的に傾き、表面のハイライトも一緒に動いて厚みがあるように見せます。

<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 に同梱 · パスワードを見る
// ポインタ座標をカード中心基準で-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;
}
07ランダムな角度で散らばったステッカーの山
複数のステッカーをそれぞれ違う角度で少し重ねて散らし置きますが、角度はあらかじめ決めた順番だけから選ぶので、何度読み込んでも同じ配置が再現されます。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="tray" id="tray">
<span class="chip chip--a" id="chipA" aria-hidden="true"></span>
<span class="chip chip--b" id="chipB" aria-hidden="true"></span>
<span class="chip chip--c" id="chipC" aria-hidden="true"></span>
<span class="chip chip--d" id="chipD" aria-hidden="true"></span>
<span class="chip chip--e" id="chipE" aria-hidden="true"></span>
</div>
<button class="shuffleBtn" id="shuffleBtn" type="button">もう一度散らす</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 hash(seed) 에 있다 — Math.random 없이 사인 해시로 결정적 소수부를
// 뽑아 회전·이동값을 만든다(deterministic scatter, seeded). 고정된 SEEDS 배열을 순환하므로
// 매 라운드 배치가 달라 보이지만 항상 재현 가능하다.
@include stage($stage-ink);
.wrap {
display: grid;
place-items: center;
gap: $sp-5;
}
.tray {
position: relative;
width: 220px;
height: 160px;
}
.chip {
position: absolute;
width: 56px;
height: 56px;
border-radius: $r-control;
box-shadow: $shadow-raised;
}
.chip--a { left: 10px; top: 10px; background: $subject-blue-deep; }
.chip--b { left: 80px; top: 4px; background: $subject-orange-deep; }
.chip--c { left: 150px; top: 20px; background: $subject-cream; }
.chip--d { left: 40px; top: 80px; background: $subject-mint; }
.chip--e { left: 120px; top: 84px; background: $subject-blue; }
.shuffleBtn {
appearance: none;
border: none;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
padding: $sp-2 $sp-5;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: scale(1.04); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
@media (max-width: 340px) {
.tray { width: 176px; height: 130px; transform: scale(.86); transform-origin: top center; }
.shuffleBtn { font-size: 11px; padding: $sp-1 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.shuffleBtn { 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);
}
.wrap {
display: grid;
place-items: center;
gap: 20px;
}
.tray {
position: relative;
width: 220px;
height: 160px;
}
.chip {
position: absolute;
width: 56px;
height: 56px;
border-radius: 12px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.chip--a {
left: 10px;
top: 10px;
background: #0a4fe8;
}
.chip--b {
left: 80px;
top: 4px;
background: #c93000;
}
.chip--c {
left: 150px;
top: 20px;
background: #fff7e6;
}
.chip--d {
left: 40px;
top: 80px;
background: #4cd4a6;
}
.chip--e {
left: 120px;
top: 84px;
background: #2f6df6;
}
.shuffleBtn {
appearance: none;
border: none;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
padding: 8px 20px;
font-weight: 600;
font-size: 12px;
cursor: pointer;
transition: transform 140ms ease-out;
}
.shuffleBtn:hover {
transform: scale(1.04);
}
.shuffleBtn:active {
transform: scale(0.96);
}
.shuffleBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
@media (max-width: 340px) {
.tray {
width: 176px;
height: 130px;
transform: scale(0.86);
transform-origin: top center;
}
.shuffleBtn {
font-size: 11px;
padding: 4px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.shuffleBtn {
transition: none;
}
}(function () {
var SCATTER_ROTATE_RANGE = 30; // 칩 하나가 회전할 수 있는 최대폭(deg) — 변형에서만 바뀐다
var SCATTER_POS_RANGE = 22; // 칩 중심에서 벗어나는 최대 거리(px)
var LERP_RATE = .22;
var SEEDS = [3, 17, 41, 8, 26]; // 고정된 씨앗값 목록 — 순환만 하고 매번 값 자체를 새로 뽑지 않는다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var shuffleBtn = document.getElementById('shuffleBtn');
var chips = [
document.getElementById('chipA'), document.getElementById('chipB'),
document.getElementById('chipC'), document.getElementById('chipD'),
document.getElementById('chipE')
];
var isDemo = true;
// deterministic scatter (seeded, no Math.random) — 사인 해시로 씨앗값 하나에서 항상 같은
// 소수부를 뽑는다. seed 가 같으면 언제 호출해도 같은 결과가 나오는 결정적 함수다.
function hash(seed) {
var v = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return v - Math.floor(v);
}
function layoutFor(seed) {
var h1 = hash(seed);
var h2 = hash(seed + 100);
var h3 = hash(seed + 200);
return {
rot: (h1 * 2 - 1) * SCATTER_ROTATE_RANGE,
dx: (h2 * 2 - 1) * SCATTER_POS_RANGE,
dy: (h3 * 2 - 1) * SCATTER_POS_RANGE
};
}
var round = 0;
var targets = chips.map(function (_, i) { return layoutFor(SEEDS[i] + round * 1000); });
var shown = targets.map(function (t) { return { rot: 0, dx: 0, dy: 0 }; });
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function reshuffle() {
round += 1;
targets = chips.map(function (_, i) { return layoutFor(SEEDS[i] + round * 1000); });
}
shuffleBtn.addEventListener('click', function () { human(); reshuffle(); });
var last = 0, STEP_MS = 700;
function autoAdvance(ts) {
if (ts - last < STEP_MS) return;
last = ts;
reshuffle();
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
chips.forEach(function (chip, i) {
var t = targets[i], s = shown[i];
if (reduce) { s.rot = t.rot; s.dx = t.dx; s.dy = t.dy; }
else {
s.rot += (t.rot - s.rot) * LERP_RATE;
s.dx += (t.dx - s.dx) * LERP_RATE;
s.dy += (t.dy - s.dy) * LERP_RATE;
}
chip.style.transform = 'translate(' + s.dx.toFixed(2) + 'px, ' + s.dy.toFixed(2) + 'px) rotate(' + s.rot.toFixed(2) + 'deg)';
});
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RandomRotateScatter — vanilla 판(run/378/demo/07-sticker-random-rotate-scatter/index.html) 의
// React 포트. deterministic scatter (seeded, no Math.random) — 사인 해시(hash)로 씨앗값 하나에서
// 항상 같은 소수부를 뽑아 회전·이동값을 만든다. 고정된 SEEDS 배열만 순환한다.
import { useEffect, useRef } from "react";
import styles from "./RandomRotateScatter.module.scss";
type Box<T> = { current: T };
const SCATTER_ROTATE_RANGE = 30; // 변형: 10deg
const SCATTER_POS_RANGE = 22;
const LERP_RATE = 0.22;
const SEEDS = [3, 17, 41, 8, 26];
const STEP_MS = 700;
function hash(seed: number) {
const v = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return v - Math.floor(v);
}
function layoutFor(seed: number) {
const h1 = hash(seed);
const h2 = hash(seed + 100);
const h3 = hash(seed + 200);
return {
rot: (h1 * 2 - 1) * SCATTER_ROTATE_RANGE,
dx: (h2 * 2 - 1) * SCATTER_POS_RANGE,
dy: (h3 * 2 - 1) * SCATTER_POS_RANGE,
};
}
export default function RandomRotateScatter() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const shuffleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const chipARef = useRef(null) as unknown as Box<HTMLSpanElement>;
const chipBRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const chipCRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const chipDRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const chipERef = 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 shuffleBtn = shuffleRef.current;
const chips = [chipARef.current, chipBRef.current, chipCRef.current, chipDRef.current, chipERef.current];
if (!wrap || !shuffleBtn || chips.some((c) => !c)) return;
let isDemo = true;
let round = 0;
let targets = chips.map((_, i) => layoutFor(SEEDS[i] + round * 1000));
const shown = chips.map(() => ({ rot: 0, dx: 0, dy: 0 }));
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function reshuffle() {
round += 1;
targets = chips.map((_, i) => layoutFor(SEEDS[i] + round * 1000));
}
function onShuffle() { human(); reshuffle(); }
shuffleBtn.addEventListener("click", onShuffle);
let last = 0;
function autoAdvance(ts: number) {
if (ts - last < STEP_MS) return;
last = ts;
reshuffle();
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) autoAdvance(ts);
chips.forEach((chip, i) => {
const t = targets[i], s = shown[i];
if (reduce) { s.rot = t.rot; s.dx = t.dx; s.dy = t.dy; }
else {
s.rot += (t.rot - s.rot) * LERP_RATE;
s.dx += (t.dx - s.dx) * LERP_RATE;
s.dy += (t.dy - s.dy) * LERP_RATE;
}
chip!.style.transform = "translate(" + s.dx.toFixed(2) + "px, " + s.dy.toFixed(2) + "px) rotate(" + s.rot.toFixed(2) + "deg)";
});
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
shuffleBtn!.removeEventListener("click", onShuffle);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.tray}>
<span className={styles.chip + " " + styles.chipA} ref={chipARef} aria-hidden="true"></span>
<span className={styles.chip + " " + styles.chipB} ref={chipBRef} aria-hidden="true"></span>
<span className={styles.chip + " " + styles.chipC} ref={chipCRef} aria-hidden="true"></span>
<span className={styles.chip + " " + styles.chipD} ref={chipDRef} aria-hidden="true"></span>
<span className={styles.chip + " " + styles.chipE} ref={chipERef} aria-hidden="true"></span>
</div>
<button className={styles.shuffleBtn} ref={shuffleRef} type="button">다시 흩뿌리기</button>
</div>
</main>
);
}07 · ランダムな角度で散らばったステッカーの山 — 計測 累積面積 18.395% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 決定的な散らし(シード固定、Math.random不使用) — サイン波のハッシュで、1つのシード値から
// 常に同じ小数部を取り出す。シードが同じなら何度呼んでも同じ結果になる関数。
function hash(seed) {
var v = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return v - Math.floor(v);
}
function layoutFor(seed) {
var h1 = hash(seed);
var h2 = hash(seed + 100);
var h3 = hash(seed + 200);
return {
rot: (h1 * 2 - 1) * SCATTER_ROTATE_RANGE,
dx: (h2 * 2 - 1) * SCATTER_POS_RANGE,
dy: (h3 * 2 - 1) * SCATTER_POS_RANGE
};
}
08ドラッグして貼るステッカー
ステッカーを指やマウスで押さえてボード上の好きな場所までドラッグすると、離した瞬間その場に少し回転した状態で貼り付き、他のステッカーの上に重なります。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="board" id="board">
<span class="cell" aria-hidden="true"></span>
<span class="cell" aria-hidden="true"></span>
<span class="cell" aria-hidden="true"></span>
<span class="cell" aria-hidden="true"></span>
<button class="chip" id="chip" type="button" aria-label="ステッカー、ドラッグして格子のマスに置く">
<span class="chipMark" aria-hidden="true"></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 snap(px, py) 에 있다 — 포인터 좌표를 GRID 칸 크기로 나눈 몫을
// 반올림해 항상 격자 위 한 점으로 되돌린다(자유 좌표 아님). 자동 루프도 같은 snap 대상
// 좌표(slots)만 순회해 실제 조작과 같은 경로를 탄다.
@include stage($stage-yellow);
.wrap { display: grid; place-items: center; }
.board {
position: relative;
width: 190px;
height: 190px;
border-radius: $r-card;
background: rgba($subject-cream, .12);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.cell {
border: 1px dashed rgba($subject-cream, .3);
}
.chip {
position: absolute;
left: 0;
top: 0;
width: 78px;
height: 78px;
appearance: none;
border: 0;
padding: 0;
cursor: grab;
border-radius: $r-control;
background: $subject-blue-deep;
box-shadow: $shadow-raised;
display: grid;
place-items: center;
touch-action: none;
&:active { cursor: grabbing; }
&:focus-visible { outline: 2px solid $subject-cream; outline-offset: 3px; }
}
.chipMark {
width: 34px;
height: 34px;
border-radius: 50%;
border: 6px solid rgba($subject-cream, .9);
}
@media (max-width: 340px) {
.board { width: 150px; height: 150px; }
.chip { width: 62px; height: 62px; }
.chipMark { width: 26px; height: 26px; border-width: 5px; }
}
@media (prefers-reduced-motion: reduce) {
.chip { 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;
}
.board {
position: relative;
width: 190px;
height: 190px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.12);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.cell {
border: 1px dashed rgba(255, 247, 230, 0.3);
}
.chip {
position: absolute;
left: 0;
top: 0;
width: 78px;
height: 78px;
appearance: none;
border: 0;
padding: 0;
cursor: grab;
border-radius: 12px;
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
touch-action: none;
}
.chip:active {
cursor: grabbing;
}
.chip:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.chipMark {
width: 34px;
height: 34px;
border-radius: 50%;
border: 6px solid rgba(255, 247, 230, 0.9);
}
@media (max-width: 340px) {
.board {
width: 150px;
height: 150px;
}
.chip {
width: 62px;
height: 62px;
}
.chipMark {
width: 26px;
height: 26px;
border-width: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}(function () {
var GRID = 78; // 격자 한 칸 크기(px) — 스냅 계산의 기준
var BOARD_W = 190, BOARD_H = 190;
var LERP_RATE = .22;
var ORBIT_MS = 650;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var board = document.getElementById('board');
var chip = document.getElementById('chip');
var isDemo = true;
var dragging = false;
var target = { x: GRID / 2, y: GRID / 2 };
var shown = { x: target.x, y: target.y };
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 포인터가 놓인 좌표를 GRID 칸 크기로 나눈 몫을 반올림해 칸 중심 좌표로 되돌린다 —
// 자유 좌표가 아니라 항상 격자 위 한 점으로 스냅되는 계산이다.
function snap(px, py) {
var col = Math.max(0, Math.min(1, Math.round((px - GRID / 2) / GRID)));
var row = Math.max(0, Math.min(1, Math.round((py - GRID / 2) / GRID)));
return { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
chip.addEventListener('pointerdown', function (e) {
human();
dragging = true;
chip.setPointerCapture(e.pointerId);
});
chip.addEventListener('pointermove', function (e) {
if (!dragging) return;
var rect = board.getBoundingClientRect();
var px = Math.max(0, Math.min(BOARD_W, e.clientX - rect.left));
var py = Math.max(0, Math.min(BOARD_H, e.clientY - rect.top));
target = snap(px, py);
});
function endDrag() { dragging = false; }
chip.addEventListener('pointerup', endDrag);
chip.addEventListener('pointercancel', endDrag);
chip.addEventListener('keydown', function (e) {
human();
var col = target.x > GRID ? 1 : 0, row = target.y > GRID ? 1 : 0;
if (e.key === 'ArrowRight') col = 1;
else if (e.key === 'ArrowLeft') col = 0;
else if (e.key === 'ArrowDown') row = 1;
else if (e.key === 'ArrowUp') row = 0;
else return;
e.preventDefault();
target = { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
});
var slots = [[0, 0], [1, 0], [0, 1], [1, 1]];
var slotIdx = 0, last = 0;
function autoAdvance(ts) {
if (ts - last < ORBIT_MS) return;
last = ts;
slotIdx = (slotIdx + 1) % slots.length;
target = { x: slots[slotIdx][0] * GRID + GRID / 2, y: slots[slotIdx][1] * GRID + GRID / 2 };
}
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && !dragging) autoAdvance(ts);
if (reduce) { shown.x = target.x; shown.y = target.y; }
else {
shown.x += (target.x - shown.x) * LERP_RATE;
shown.y += (target.y - shown.y) * LERP_RATE;
}
chip.style.transform = 'translate(' + (shown.x - GRID / 2).toFixed(2) + 'px, ' + (shown.y - GRID / 2).toFixed(2) + 'px)';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// DragPlace — vanilla 판(run/378/demo/08-sticker-drag-place/index.html) 의 React 포트.
// 포인터 좌표를 GRID 칸 크기로 나눈 몫을 반올림해 항상 격자 위 한 점으로 되돌린다(snap) —
// 자유 좌표가 아니라 항상 격자 스냅 계산이다. 자동 루프도 같은 slots 좌표만 순회한다.
import { useEffect, useRef } from "react";
import styles from "./DragPlace.module.scss";
type Box<T> = { current: T };
const GRID = 78;
const BOARD_W = 190, BOARD_H = 190;
const LERP_RATE = 0.22;
const ORBIT_MS = 650;
export default function DragPlace() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const boardRef = useRef(null) as unknown as Box<HTMLDivElement>;
const chipRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const board = boardRef.current;
const chip = chipRef.current;
if (!wrap || !board || !chip) return;
let isDemo = true;
let dragging = false;
let target = { x: GRID / 2, y: GRID / 2 };
const shown = { x: target.x, y: target.y };
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function snap(px: number, py: number) {
const col = Math.max(0, Math.min(1, Math.round((px - GRID / 2) / GRID)));
const row = Math.max(0, Math.min(1, Math.round((py - GRID / 2) / GRID)));
return { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
function onDown(e: PointerEvent) {
human();
dragging = true;
chip!.setPointerCapture(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging) return;
const rect = board!.getBoundingClientRect();
const px = Math.max(0, Math.min(BOARD_W, e.clientX - rect.left));
const py = Math.max(0, Math.min(BOARD_H, e.clientY - rect.top));
target = snap(px, py);
}
function endDrag() { dragging = false; }
function onKeyDown(e: KeyboardEvent) {
human();
let col = target.x > GRID ? 1 : 0, row = target.y > GRID ? 1 : 0;
if (e.key === "ArrowRight") col = 1;
else if (e.key === "ArrowLeft") col = 0;
else if (e.key === "ArrowDown") row = 1;
else if (e.key === "ArrowUp") row = 0;
else return;
e.preventDefault();
target = { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
chip.addEventListener("pointerdown", onDown);
chip.addEventListener("pointermove", onMove);
chip.addEventListener("pointerup", endDrag);
chip.addEventListener("pointercancel", endDrag);
chip.addEventListener("keydown", onKeyDown);
const slots = [[0, 0], [1, 0], [0, 1], [1, 1]];
let slotIdx = 0, last = 0;
function autoAdvance(ts: number) {
if (ts - last < ORBIT_MS) return;
last = ts;
slotIdx = (slotIdx + 1) % slots.length;
target = { x: slots[slotIdx][0] * GRID + GRID / 2, y: slots[slotIdx][1] * GRID + GRID / 2 };
}
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && !dragging) autoAdvance(ts);
if (reduce) { shown.x = target.x; shown.y = target.y; }
else {
shown.x += (target.x - shown.x) * LERP_RATE;
shown.y += (target.y - shown.y) * LERP_RATE;
}
chip!.style.transform = "translate(" + (shown.x - GRID / 2).toFixed(2) + "px, " + (shown.y - GRID / 2).toFixed(2) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
chip!.removeEventListener("pointerdown", onDown);
chip!.removeEventListener("pointermove", onMove);
chip!.removeEventListener("pointerup", endDrag);
chip!.removeEventListener("pointercancel", endDrag);
chip!.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.board} ref={boardRef}>
<span className={styles.cell} aria-hidden="true"></span>
<span className={styles.cell} aria-hidden="true"></span>
<span className={styles.cell} aria-hidden="true"></span>
<span className={styles.cell} aria-hidden="true"></span>
<button className={styles.chip} ref={chipRef} type="button" aria-label="스티커, 끌어서 격자 칸에 놓는다">
<span className={styles.chipMark} aria-hidden="true"></span>
</button>
</div>
</div>
</main>
);
}08 · ドラッグして貼るステッカー — 計測 累積面積 21.61% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// ポインタが置かれた座標をGRIDのマスサイズで割った商を四捨五入し、マス中心の座標に戻す —
// 自由な座標ではなく、常に格子上のどれか1点にスナップする計算になっている。
function snap(px, py) {
var col = Math.max(0, Math.min(1, Math.round((px - GRID / 2) / GRID)));
var row = Math.max(0, Math.min(1, Math.round((py - GRID / 2) / GRID)));
return { x: col * GRID + GRID / 2, y: row * GRID + GRID / 2 };
}
09切り取り線が見えるステッカーシート
複数のステッカーを格子状に並べたシートの上に点線の切り取り線を描き、1枚を押すとその点線に沿って型抜きされたように少し手前へ飛び出します。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="sheet">
<button class="die" id="dieA" type="button" aria-pressed="false" aria-label="1枚目のステッカー、押して切り抜く">
<span class="dieMark" aria-hidden="true"></span>
<svg class="cutLine" viewBox="0 0 100 100" aria-hidden="true">
<rect class="cutLineRect" x="3" y="3" width="94" height="94" rx="14" />
</svg>
</button>
<button class="die" id="dieB" type="button" aria-pressed="false" aria-label="2枚目のステッカー、押して切り抜く">
<span class="dieMark" aria-hidden="true"></span>
<svg class="cutLine" viewBox="0 0 100 100" aria-hidden="true">
<rect class="cutLineRect" x="3" y="3" width="94" height="94" rx="14" />
</svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 loop(ts) 안 offset 산술에 있다 — 경과 시간에 속도를 곱한 값을
// 점선 마디 길이로 모듈로 연산해 끊김 없이 도는 재단선(background-position)을 만든다.
// reduce 는 이 행진만 멈추고, 클릭으로 오려내는 실사용 경로는 그대로 둔다.
@include stage($stage-paper);
.wrap { display: grid; place-items: center; }
.sheet {
display: flex;
gap: $sp-6;
}
.die {
position: relative;
appearance: none;
border: 0;
padding: 0;
cursor: pointer;
width: 96px;
height: 96px;
border-radius: $r-control;
background: $subject-blue;
display: grid;
place-items: center;
transition: transform $dur-base $ease-out, opacity $dur-base $ease-out;
&:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 3px; }
&.is-punched {
transform: scale(.9) rotate(-4deg);
opacity: .55;
}
}
.dieMark {
width: 36px;
height: 36px;
border-radius: 50%;
border: 6px solid rgba($subject-cream, .9);
}
.cutLine {
position: absolute;
inset: -8px;
width: calc(100% + 16px);
height: calc(100% + 16px);
pointer-events: none;
opacity: .6;
}
.cutLineRect {
fill: none;
stroke: $subject-ink;
stroke-width: 2.4;
}
@media (max-width: 340px) {
.sheet { gap: $sp-4; }
.die { width: 74px; height: 74px; }
.dieMark { width: 28px; height: 28px; border-width: 5px; }
}
@media (prefers-reduced-motion: reduce) {
.die { 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);
}
.wrap {
display: grid;
place-items: center;
}
.sheet {
display: flex;
gap: 24px;
}
.die {
position: relative;
appearance: none;
border: 0;
padding: 0;
cursor: pointer;
width: 96px;
height: 96px;
border-radius: 12px;
background: #2f6df6;
display: grid;
place-items: center;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.die:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 3px;
}
.die.is-punched {
transform: scale(0.9) rotate(-4deg);
opacity: 0.55;
}
.dieMark {
width: 36px;
height: 36px;
border-radius: 50%;
border: 6px solid rgba(255, 247, 230, 0.9);
}
.cutLine {
position: absolute;
inset: -8px;
width: calc(100% + 16px);
height: calc(100% + 16px);
pointer-events: none;
opacity: 0.6;
}
.cutLineRect {
fill: none;
stroke: #17141a;
stroke-width: 2.4;
}
@media (max-width: 340px) {
.sheet {
gap: 16px;
}
.die {
width: 74px;
height: 74px;
}
.dieMark {
width: 28px;
height: 28px;
border-width: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.die {
transition: none;
}
}(function () {
var DASH = 6, GAP = 4; // 재단선 점선 한 마디 길이(svg 단위, viewBox 100 기준)
var MARCH_SPEED = 14; // 점선이 도는 속도(단위/s)
var PATTERN = DASH + GAP;
var STEP_MS = 1700;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var dies = [document.getElementById('dieA'), document.getElementById('dieB')];
var rects = Array.prototype.map.call(document.querySelectorAll('.cutLineRect'), function (r) { return r; });
rects.forEach(function (r) { r.style.strokeDasharray = DASH + ' ' + GAP; });
var isDemo = true;
var punched = [false, false];
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function togglePunch(i) {
punched[i] = !punched[i];
dies[i].setAttribute('aria-pressed', String(punched[i]));
dies[i].classList.toggle('is-punched', punched[i]);
}
dies.forEach(function (die, i) {
die.addEventListener('click', function () { human(); togglePunch(i); });
});
var last = 0, stepLast = 0, autoIdx = 0;
function autoAdvance(ts) {
if (ts - stepLast < STEP_MS) return;
stepLast = ts;
togglePunch(autoIdx);
autoIdx = (autoIdx + 1) % dies.length;
}
// 점선 마디 길이(PATTERN)에 대해 경과 시간을 곱한 오프셋을 모듈로 연산으로 되감아, 끊김 없이
// 도는 재단선(stroke-dashoffset)을 만든다 — 실제 인쇄 재단선의 "개미 행진" 표시와 같은 계산.
function loop(ts) {
if (!last) last = ts;
last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (!reduce) {
var offset = (ts * MARCH_SPEED / 1000) % PATTERN;
rects.forEach(function (r) { r.style.strokeDashoffset = (-offset).toFixed(2); });
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// PrintCutLine — vanilla 판(run/378/demo/09-sticker-print-cut-line/index.html) 의 React 포트.
// 경과 시간에 속도를 곱한 값을 점선 마디 길이(PATTERN)로 모듈로 연산해 끊김 없이 도는
// 재단선(stroke-dashoffset)을 만든다 — SVG rect 의 stroke-dasharray 기반 마칭 앤츠.
import { useEffect, useRef } from "react";
import styles from "./PrintCutLine.module.scss";
type Box<T> = { current: T };
const DASH = 6, GAP = 4;
const MARCH_SPEED = 14;
const PATTERN = DASH + GAP;
const STEP_MS = 1700;
export default function PrintCutLine() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const dieARef = useRef(null) as unknown as Box<HTMLButtonElement>;
const dieBRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rectARef = useRef(null) as unknown as Box<SVGRectElement>;
const rectBRef = useRef(null) as unknown as Box<SVGRectElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = rootRef.current;
const dies = [dieARef.current, dieBRef.current];
const rects = [rectARef.current, rectBRef.current];
if (!wrap || dies.some((d) => !d) || rects.some((r) => !r)) return;
rects.forEach((r) => { r!.style.strokeDasharray = DASH + " " + GAP; });
let isDemo = true;
const punched = [false, false];
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function togglePunch(i: number) {
punched[i] = !punched[i];
dies[i]!.setAttribute("aria-pressed", String(punched[i]));
dies[i]!.classList.toggle(styles.isPunched, punched[i]);
}
const clickHandlers = dies.map((die, i) => {
const fn = () => { human(); togglePunch(i); };
die!.addEventListener("click", fn);
return fn;
});
let last = 0, stepLast = 0, autoIdx = 0;
function autoAdvance(ts: number) {
if (ts - stepLast < STEP_MS) return;
stepLast = ts;
togglePunch(autoIdx);
autoIdx = (autoIdx + 1) % dies.length;
}
function loop(ts: number) {
if (!last) last = ts;
last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (!reduce) {
const offset = (ts * MARCH_SPEED / 1000) % PATTERN;
rects.forEach((r) => { r!.style.strokeDashoffset = (-offset).toFixed(2); });
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
dies.forEach((die, i) => die!.removeEventListener("click", clickHandlers[i]));
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.sheet}>
<button className={styles.die} ref={dieARef} type="button" aria-pressed="false" aria-label="첫 번째 스티커, 눌러서 오려내기">
<span className={styles.dieMark} aria-hidden="true"></span>
<svg className={styles.cutLine} viewBox="0 0 100 100" aria-hidden="true">
<rect ref={rectARef} className={styles.cutLineRect} x={3} y={3} width={94} height={94} rx={14} />
</svg>
</button>
<button className={styles.die} ref={dieBRef} type="button" aria-pressed="false" aria-label="두 번째 스티커, 눌러서 오려내기">
<span className={styles.dieMark} aria-hidden="true"></span>
<svg className={styles.cutLine} viewBox="0 0 100 100" aria-hidden="true">
<rect ref={rectBRef} className={styles.cutLineRect} x={3} y={3} width={94} height={94} rx={14} />
</svg>
</button>
</div>
</div>
</main>
);
}09 · 切り取り線が見えるステッカーシート — 計測 累積面積 8.425% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 点線の1区切り分(PATTERN)に対して経過時間を掛けたオフセットをモジュロ演算で巻き戻し、
// 途切れず回り続ける裁断線(stroke-dashoffset)を作る — 実際の印刷裁断線の行進表示と同じ計算。
function loop(ts) {
if (!last) last = ts;
last = ts;
if (isDemo && !reduce) autoAdvance(ts);
if (!reduce) {
var offset = (ts * MARCH_SPEED / 1000) % PATTERN;
rects.forEach(function (r) { r.style.strokeDashoffset = (-offset).toFixed(2); });
}
requestAnimationFrame(loop);
}
値が変わるとUIがどう変わるか — 角度・サイズ・タイミング・順番
どのパターンも、実際に働いている変数はたった1つです。その変数を変えるだけでパターン全体の印象が変わります。
01 — 持ち上がり比率
バッジのbox-shadowの重なりは shadowFor(t) から来ており、t は0(貼り付いた状態)から1(完全に持ち上がった状態)まで動くので、5層すべてのオフセット・ぼかし・不透明度がこの1つの比率と連動して増減します。
02 — 角へのベクトル
applyFromPoint() はポインタとカード右上角との距離と Math.atan2() の角度を、めくれのサイズとグラデーション角度に変換するので、ポインタが実際にその角へ近づいたときだけめくれます。
03 — ポインタの速度
applyMove() はポインタのフレーム間移動量を経過時間で割ってミリ秒あたりのピクセル速度を求め、その1つの速度が色相の回転とスイープ位置の両方を同時に動かします。
04 — 番号の算術
tapeAngle(i) は sign * (TAPE_BASE_ANGLE + TAPE_ANGLE_STEP * i) を返すので、2本のテープの角度は別々に選んだ2つの数値ではなく、角の番号を渡した1本の式から生まれます。
05 — 実測した幅
measureWidth() は隠しcanvasから実際のピクセル幅を読み取り、TAB_MIN と TAB_MAX の間に収めるので、フォルダタブの幅は常に実際の文字に合い、当て推量の値になりません。
06 — 正規化した位置
applyFromPoint() はカード内のポインタ位置を各軸で-1..1に正規化し MAX_TILT を掛けるので、カードの回転もハイライトの位置も、今ポインタがどこにあるかをそのまま読み取った値です。
07 — シード付きハッシュ
hash(seed) は固定のシードを Math.sin() に通して小数部だけを残すので、layoutFor() は同じシードに対して常に同じ回転量とずれを返します — その場で新しく作り出しているわけではありません。
08 — 格子へのスナップ
snap() はポインタの生座標を GRID で割って四捨五入し、また掛け戻すことで、自由な位置を最も近い格子マスの中心に変えます。
09 — 経過時間のモジュロ
裁断線のオフセットは (ts * MARCH_SPEED / 1000) % PATTERN から来ており、行進の速さはコマ送りのキーフレームではなく経過時間の直接の関数になっています。
どこで壊れるか — アクセシビリティ・reduced-motion・キーボード
03のカードはネイティブな <button> なので、キーボードユーザーもTabで到達できます。しかし色相の回転と光のスイープが反応するのは pointermove だけで、focus/blur も監視している01・02・06とは異なります。ホログラムのカードにTabで移動してEnterを押しても、光沢には何も起きません — applyMove() は座標の実際の差分がないと速度を計算できないので、キーボードだけの訪問者はこのパターンが見せたい効果を一度も見ないまま終わります。これは描画のクセではなく実在するギャップで、実際の製品に載せる前に focus ハンドラで塞ぐ価値があります。同じ「見た目は同じボタン」でも、01・02・06のように focus/blur まで面倒を見ているか、03のように pointermove 一本で済ませているかで、キーボード利用者に届く体験はまったく別物になるという点は、9種を並べて実測してみないと気づきにくい差です。
reduced-motionで消えるもの
- 01 ダイカットの持ち上がり — 消えるのはイージングと自動デモループだけで、box-shadowの重なり自体は
pointerenter/focusで持ち上がった状態・戻った状態へ即座に切り替わります。 - 02 めくれる角 — サイズと角度が滑らかに動く代わりに目標値へ即座に切り替わり、デモ自身の自動周回ループも止まります。
- 03 ホログラムの光沢 — 色相とスイープ位置がそのまま0に固定され、虹色の色変化も光の帯もまったく再生されず、静止した斜めの光沢だけが残ります。
- 04 テープ — 傾きと持ち上がりが留めた状態・剥がれた状態へ即座に切り替わり、自動デモの交互めくりループも止まります。
- 05 フォルダタブ — タブの幅が新しいラベルの幅へ滑らかさなしに即座に切り替わり、3つのラベルを自動で巡回する動きも止まります。
- 06 カーソル傾き — rotateX/rotateYとハイライト位置が目標値へ即座に切り替わり、自動で周回するデモループも止まります。
- 07 散らばりの山 — 各チップの回転とずれが新しいハッシュ配置へ即座に切り替わり、時間差での自動再シャッフルも止まります。
- 08 ドラッグして配置 — 離した瞬間や矢印キーで格子マスへスナップする動作自体は残りますが途中の滑らかさが消え、自動で巡回するデモループも完全に止まります。
- 09 切り取り線 — 行進するオフセットは完全に静止したまま動かなくなりますが、ステッカーをクリックすると即座に前へ飛び出す動作はそのまま残ります。
上の9本のソースはすべて共通の1つのパスワードdkbn5f83の内側にあり、アーカイブを一度解凍するだけでこの記事のすべてのパターンに対応するHTML・CSS・JSファイルが手元に揃い、そのままブラウザで開けます。
3つのバリエーションとアクセシビリティ
| バリエーション | 値 | 感触 |
|---|---|---|
| ホログラムスイープの角度範囲を広げる | 03 HOLO_SWEEP_ANGLE_RANGE 45deg → 90deg | 光の帯が流れる基準角が広がり、ポインタを少し動かすだけでスイープが対角線を大きく横切って方向が急に曲がる — 45degに比べて反応が誇張されて見える。 |
| めくれる角のサイズを大きくする | 02 PEEL_CORNER_SIZE 24px → 44px | 最大にめくれた時に現れる三角形がほぼ倍のサイズになり、隠れていた裏面の色が角の飾りではなくカード内容の一部のように目立つ — さりげないヒントが明白な露出に変わる。 |
| ランダムスキャッターの角度幅を狭める | 07 SCATTER_ROTATE_RANGE 30deg → 10deg | 同じハッシュ値でも回転幅が狭まり、ステッカーがほぼ整列して見える — 無造作に積まれた山から、わずかにずれただけの陳列棚のような印象に変わる。 |
01・02・03・06・08は説明的な aria-label を持つネイティブな <button type="button"> なので、EnterとSpaceは追加のkeydownコードなしですでにトリガーやフォーカスとして働きます。04のピン留めトグルと09の2つのサイコロボタンはどちらも aria-pressed で状態を伝え、08はさらに矢印キーの keydown も監視しているので、ポインタなしでもチップを格子マスの間で移動できます。
FAQ
この9種のホログラム シート シール 作り方はフレームワークやビルド手順が必要ですか?
いいえ。どのパターンもビルド手順もフレームワークも使わない、単独で完結したHTML/CSS/JSファイル1本です。index.htmlをそのまま開くか、マークアップをどのページに置いても同じように動きます。
prefers-reduced-motionが本当に効いているかはどう確かめられますか?
OSのアクセシビリティ設定で「視差効果を減らす」を有効にしてから、上の9種のデモをどれか読み込み直してください。それぞれが起動時に window.matchMedia('(prefers-reduced-motion: reduce)') を読み取り、自分のアニメーションを取り除くか止めるので、そのパターンのCSSやJSと突き合わせて直接確認できます。
3d ホログラム シール 作り方として、クリックやドラッグなしでも反応するのはどれですか?
01・02・03・06はポインタが乗った瞬間や動いた瞬間にすぐ反応するので、追加の操作なしで反応します。04・05・07・08・09はクリック・ドラッグ・キー操作という1回限りの動作を待ちます — テープ、フォルダタブ、散らばりの山、ドラッグ先、切り取り線はいずれも継続的なホバー状態ではなく一度きりの操作だからです。
出典: MDN — clip-path、MDN — mix-blend-mode、MDN — Pointer events、MDN — conic-gradient()、MDN — prefers-reduced-motion。
関連記事: CSS ホログラムカード効果9選 — ステッカー・バッジ・チケット · css キャラクター アニメーション 9選 — かわいいキャラを動かすCSS · ペーパーカット デザインのLPパーツ9選、コピペOK









