05고정 시드 노이즈로 들쭉날쭉하게 자른 찢긴 종이 경계
고정 시드로 만든 노이즈 높낮이를 꼭짓점마다 얹어 clip-path 로 자르고, 새로고침해도 같은 모양이 나온다.
쓰임손으로 찢은 느낌의 경계
TORN_SEEDTORN_STEPTORN_AMP

<main class="stage">
<div class="dv is-demo" id="dvRoot">
<div class="dv__paper" role="separator">
<div class="dv__sheet" id="dvSheet"></div>
</div>
<div class="dv__bar">
<button class="dv__btn" id="dvBtn" type="button">다시 찢기</button>
<p class="dv__meta"><span class="dv__tag">TORN_SEED</span> <b id="dvSeed">0</b></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 시드 고정 노이즈로 높이를 정한 꼭짓점을 clip-path polygon 으로 이어 종이 아래 경계를 찢는다.
$sheet-color: $subject-pink;
$base-color: $subject-mint;
@include stage($stage-ink);
.dv {
width: min(100%, 360px);
display: grid;
gap: $sp-2;
}
.dv__paper {
box-sizing: border-box;
height: 100px;
border-radius: $r-card;
background: $base-color;
overflow: hidden;
}
.dv__sheet {
height: 100px;
background: $sheet-color;
clip-path: polygon(0 0, 100% 0, 100% 58px, 0 58px);
}
.dv__bar {
box-sizing: border-box;
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $sp-2 $sp-3;
}
.dv__btn {
box-sizing: border-box;
height: 40px;
padding: 0 $sp-5;
border: 0;
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.dv__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.dv__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.dv__tag { font-weight: 700; letter-spacing: .08em; }
.dv__meta b { font-variant-numeric: tabular-nums; color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.dv__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dv {
width: min(100%, 360px);
display: grid;
gap: 8px;
}
.dv__paper {
box-sizing: border-box;
height: 100px;
border-radius: 16px;
background: #4cd4a6;
overflow: hidden;
}
.dv__sheet {
height: 100px;
background: #ff9ebb;
clip-path: polygon(0 0, 100% 0, 100% 58px, 0 58px);
}
.dv__bar {
box-sizing: border-box;
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
}
.dv__btn {
box-sizing: border-box;
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.dv__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.dv__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.dv__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.dv__meta b {
font-variant-numeric: tabular-nums;
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.dv__btn {
transition: none;
}
}(function () {
// 기법: 직접 구현한 시드 난수(mulberry32)로 값 표를 한 번 만들고, 큰 굴곡(4칸마다 보간)과 칸별 잔떨림을 섞어 꼭짓점 높이를 정한다.
// 같은 TORN_SEED 이면 새로고침해도 같은 모양이다. 찢김은 왼쪽에서 오른쪽으로 progress 만큼 진행한다.
var TORN_SEED = 20261008;
var TORN_STEP = 9;
var TORN_AMP = 20;
var BASE_Y = 48;
var START_MS = 200;
var TEAR_MS = 1400;
var CYCLE_MS = 2000;
var root = document.getElementById('dvRoot');
var sheet = document.getElementById('dvSheet');
var btn = document.getElementById('dvBtn');
var seedEl = document.getElementById('dvSeed');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function mulberry32(seed) {
var a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) >>> 0;
var t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = mulberry32(TORN_SEED);
var table = [];
var i;
for (i = 0; i < 256; i += 1) table.push(rand());
function smooth(t) { return t * t * (3 - 2 * t); }
function noiseAt(k) {
var big = k / 4;
var i0 = Math.floor(big);
var f = smooth(big - i0);
var coarse = table[i0 % 256] * (1 - f) + table[(i0 + 1) % 256] * f;
var fine = table[(k * 7 + 3) % 256];
return 0.65 * coarse + 0.35 * fine;
}
var progress = 0;
function paint() {
var count = Math.max(8, Math.ceil(sheet.clientWidth / TORN_STEP));
var pts = ['0 0', '100% 0'];
var edge = [];
for (var k = 0; k <= count; k += 1) {
var xr = k / count;
var y = xr <= progress ? BASE_Y + TORN_AMP * noiseAt(k) : BASE_Y + TORN_AMP * 0.5;
edge.push((xr * 100).toFixed(2) + '% ' + y.toFixed(1) + 'px');
}
edge.reverse();
sheet.style.clipPath = 'polygon(' + pts.concat(edge).join(',') + ')';
}
function stopDemo() { root.classList.remove('is-demo'); }
var restartAt = null;
btn.addEventListener('click', function () {
stopDemo();
if (reduce) { progress = 1; paint(); return; }
restartAt = -1;
});
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var local = null;
if (restartAt === -1) restartAt = ts;
if (root.classList.contains('is-demo')) local = (ts - t0) % CYCLE_MS;
else if (restartAt !== null) local = ts - restartAt;
if (local !== null) {
progress = Math.min(1, Math.max(0, (local - START_MS) / TEAR_MS));
paint();
}
requestAnimationFrame(tick);
}
seedEl.textContent = String(TORN_SEED);
if (reduce) {
stopDemo();
progress = 1;
paint();
} else {
paint();
requestAnimationFrame(tick);
}
})();// TornEdge — vanilla 판(05-divl-torn-edge-seeded-noise) 의 React 포트.
// 직접 구현한 시드 난수로 꼭짓점 높이를 정해 clip-path polygon 으로 종이 아래 경계를 찢는다. 같은 시드는 같은 모양이다.
import { useEffect, useRef } from "react";
import styles from "./TornEdge.module.scss";
type Box<T> = { current: T };
const TORN_SEED = 20261008;
const TORN_STEP = 9;
const TORN_AMP = 20;
const BASE_Y = 48;
const START_MS = 200;
const TEAR_MS = 1400;
const CYCLE_MS = 2000;
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const smooth = (t: number): number => t * t * (3 - 2 * t);
export default function TornEdge() {
const sheetRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
useEffect(() => {
const sheet = sheetRef.current;
const btn = btnRef.current;
if (!sheet || !btn) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const rand = mulberry32(TORN_SEED);
const table: number[] = [];
for (let i = 0; i < 256; i += 1) table.push(rand());
const noiseAt = (k: number): number => {
const big = k / 4;
const i0 = Math.floor(big);
const f = smooth(big - i0);
const coarse = table[i0 % 256] * (1 - f) + table[(i0 + 1) % 256] * f;
const fine = table[(k * 7 + 3) % 256];
return 0.65 * coarse + 0.35 * fine;
};
let demo = !reduce;
let progress = reduce ? 1 : 0;
let restartAt: number | null = null;
let t0: number | null = null;
let raf = 0;
const paint = (): void => {
const count = Math.max(8, Math.ceil(sheet.clientWidth / TORN_STEP));
const edge: string[] = [];
for (let k = 0; k <= count; k += 1) {
const xr = k / count;
const y = xr <= progress ? BASE_Y + TORN_AMP * noiseAt(k) : BASE_Y + TORN_AMP * 0.5;
edge.push((xr * 100).toFixed(2) + "% " + y.toFixed(1) + "px");
}
edge.reverse();
sheet.style.clipPath = "polygon(" + ["0 0", "100% 0"].concat(edge).join(",") + ")";
};
const onClick = (): void => {
demo = false;
if (reduce) {
progress = 1;
paint();
return;
}
restartAt = -1;
};
btn.addEventListener("click", onClick);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
let local: number | null = null;
if (restartAt === -1) restartAt = ts;
if (demo) local = (ts - t0) % CYCLE_MS;
else if (restartAt !== null) local = ts - restartAt;
if (local !== null) {
progress = Math.min(1, Math.max(0, (local - START_MS) / TEAR_MS));
paint();
}
raf = requestAnimationFrame(tick);
}
paint();
if (!reduce) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div className={styles.dv}>
<div className={styles.paper} role="separator">
<div className={styles.sheet} ref={sheetRef} />
</div>
<div className={styles.bar}>
<button className={styles.btn} ref={btnRef} type="button">다시 찢기</button>
<p className={styles.meta}>
<span className={styles.tag}>TORN_SEED</span> <b>{TORN_SEED}</b>
</p>
</div>
</div>
);
}05 고정 시드 노이즈로 들쭉날쭉하게 자른 찢긴 종이 경계 — 측정 누적면적 0.949% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 구분선 9가지 — 양 끝이 사라지고 글자 폭에 맞춰 선이 나뉘는 구분선 장면
이 데모가 속한 글
css 구분선 9가지 — 양 끝이 사라지고 글자 폭에 맞춰 선이 나뉘는 구분선 장면css 구분선은 선 한 줄이면 될 것 같지만, 양 끝이 뚝 잘려 어색하고 가운데 글자 폭에 선 길이가 안 맞고 섹션 경계가 직선이라 밋밋하다 — 01번·02번·04번이 하나씩 푼다. 이 편의 아홉 구분선은 선의 모양 이 글의 데모 전부 보기


