07ランダムな角度で散らばったステッカーの山
複数のステッカーをそれぞれ違う角度で少し重ねて散らし置くが、角度はあらかじめ決めた順番だけから選ぶので、何度読み込んでも同じ配置が再現される。
使いどころノートPCステッカー風ムードボード、ランディングヒーロー装飾
Math.sinbox-shadowrequestAnimationFrame

<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 に同梱 · ホログラム シール デザイン 9種 — 白フチから剥がれる角まで
このデモが属する記事
ホログラム シール デザイン 9種 — 白フチから剥がれる角までこのホログラム シール デザイン 9種のうち、1つは白フチと影だけでダイカットステッカーに見せ、1つはポインタの角度に沿って表面を虹色の光が走り抜け、1つはカーソルに合わせてわずかに傾いて本物のシールに触れる感触を出します。 この記事のデモをすべて見る


