09二つのセクションが波の境目で受け渡される区切り線の場面
上のセクションの色が波の境目の下へ広がり、スクロール位置に応じて波の位相が動き、境目の線が描かれる。
使いどころcss 区切り線を一つの場面にまとめる
WAVE_PHASE_PER_PXBLEED_PCTDRAW_RANGE_PX

<main class="stage">
<div class="dv is-demo" id="dvRoot">
<div class="dv__scroller" id="dvScroller" tabindex="0" aria-label="スクロール領域">
<section class="dv__sec dv__sec--a">
<p class="dv__title">最初のセクション</p>
</section>
<svg class="dv__wave" id="dvWave" viewBox="0 0 320 72" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path class="dv__fill" id="dvFill" d="M0 0H320V36H0Z"></path>
<path class="dv__edge" id="dvEdge" d="M0 36H320" pathLength="1"></path>
</svg>
<section class="dv__sec dv__sec--b">
<div class="dv__bleed" id="dvBleed"></div>
<p class="dv__title">次のセクション</p>
</section>
</div>
<p class="dv__meta"><span class="dv__tag">PHASE</span> <b id="dvPhase">0</b><span>rad</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: scrollTop 하나가 파도 위상, 경계선이 그려진 길이, 위 섹션 색이 번진 높이를 한꺼번에 정한다.
$sec-a: $subject-pink;
$sec-b: $subject-sky;
@include stage($stage-ink);
.dv {
width: min(100%, 360px);
display: grid;
gap: $sp-2;
}
.dv__scroller {
box-sizing: border-box;
height: 144px;
overflow-y: auto;
scrollbar-width: none;
border-radius: $r-card;
background: $sec-b;
}
.dv__scroller::-webkit-scrollbar { display: none; }
.dv__scroller:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.dv__sec {
position: relative;
box-sizing: border-box;
display: grid;
align-items: center;
padding: 0 $sp-4;
}
.dv__sec--a { height: 64px; background: $sec-a; }
.dv__sec--b { height: 136px; background: $sec-b; overflow: hidden; }
.dv__title {
position: relative;
margin: 0;
color: $subject-ink;
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
.dv__wave {
display: block;
width: 100%;
height: 56px;
background: $sec-b;
}
.dv__fill { fill: $sec-a; }
.dv__edge {
fill: none;
stroke: $stage-paper;
stroke-width: 3;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.dv__bleed {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
background: linear-gradient(180deg, $sec-a, rgba($sec-a, 0));
}
.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 { min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.dv__bleed { 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__scroller {
box-sizing: border-box;
height: 144px;
overflow-y: auto;
scrollbar-width: none;
border-radius: 16px;
background: #6ec8ff;
}
.dv__scroller::-webkit-scrollbar {
display: none;
}
.dv__scroller:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.dv__sec {
position: relative;
box-sizing: border-box;
display: grid;
align-items: center;
padding: 0 16px;
}
.dv__sec--a {
height: 64px;
background: #ff9ebb;
}
.dv__sec--b {
height: 136px;
background: #6ec8ff;
overflow: hidden;
}
.dv__title {
position: relative;
margin: 0;
color: #17141a;
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
.dv__wave {
display: block;
width: 100%;
height: 56px;
background: #6ec8ff;
}
.dv__fill {
fill: #ff9ebb;
}
.dv__edge {
fill: none;
stroke: #ffffff;
stroke-width: 3;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.dv__bleed {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
background: linear-gradient(180deg, #ff9ebb, rgba(255, 158, 187, 0));
}
.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 {
min-width: 3ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.dv__bleed {
transition: none;
}
}(function () {
// 기법: 스크롤 위치 scrollTop 이 파도 위상(scrollTop * WAVE_PHASE_PER_PX)을 밀고, 같은 값의 비율이 경계선을 그려 나간다(stroke-dashoffset).
// 위 섹션 색은 아래 섹션 높이의 BLEED_PCT% 까지 투명해지며 번지고, 번지는 정도도 같은 진행도(0~1)에서 나온다.
var WAVE_PHASE_PER_PX = 0.07;
var BLEED_PCT = 60;
var SCROLL_SHARE = 0.62;
var DRAW_RANGE_PX = 90;
var WAVE_AMP = [11, 6];
var WAVE_PERIOD = [150, 61];
var WAVE_SPEED = [1, 1.6];
var BASE_Y = 34;
var VIEW_W = 320;
var STEP_PX = 4;
var CYCLE_MS = 2000;
var root = document.getElementById('dvRoot');
var scroller = document.getElementById('dvScroller');
var wave = document.getElementById('dvWave');
var fill = document.getElementById('dvFill');
var edge = document.getElementById('dvEdge');
var bleed = document.getElementById('dvBleed');
var phaseEl = document.getElementById('dvPhase');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function edgeY(x, phase) {
var y = BASE_Y;
for (var i = 0; i < WAVE_AMP.length; i += 1) {
y += WAVE_AMP[i] * Math.sin(2 * Math.PI * x / WAVE_PERIOD[i] + phase * WAVE_SPEED[i]);
}
return y;
}
function pathOf(phase) {
var pts = [];
for (var x = 0; x <= VIEW_W; x += STEP_PX) pts.push(x + ' ' + edgeY(x, phase).toFixed(1));
return pts;
}
function update() {
var top = scroller.scrollTop;
var phase = top * WAVE_PHASE_PER_PX;
var pts = pathOf(phase);
fill.setAttribute('d', 'M0 0H' + VIEW_W + 'V' + pts[pts.length - 1].split(' ')[1] + 'L' + pts.slice().reverse().join('L') + 'Z');
var line = 'M' + pts.join('L');
edge.setAttribute('d', line);
var drawn = Math.min(1, Math.max(0, top / DRAW_RANGE_PX));
edge.style.strokeDasharray = '1';
edge.style.strokeDashoffset = String(1 - drawn);
bleed.style.height = (BLEED_PCT * drawn).toFixed(1) + '%';
phaseEl.textContent = phase.toFixed(1);
}
function stopDemo() { root.classList.remove('is-demo'); }
scroller.addEventListener('scroll', update);
scroller.addEventListener('wheel', stopDemo, { passive: true });
scroller.addEventListener('pointerdown', stopDemo);
scroller.addEventListener('keydown', stopDemo);
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
if (root.classList.contains('is-demo')) {
var max = scroller.scrollHeight - scroller.clientHeight;
var phase = ((ts - t0) % CYCLE_MS) / CYCLE_MS;
var tri = phase < 0.5 ? phase * 2 : 2 - phase * 2;
scroller.scrollTop = max * SCROLL_SHARE * tri;
}
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) * SCROLL_SHARE / 2;
update();
} else {
update();
requestAnimationFrame(tick);
}
})();// WaveHandoff — vanilla 판(09-divl-section-handoff-wave-scene) 의 React 포트.
// scrollTop 하나가 파도 위상, 경계선이 그려진 길이, 위 섹션 색이 번진 높이를 한꺼번에 정한다.
import { useEffect, useRef } from "react";
import styles from "./WaveHandoff.module.scss";
type Box<T> = { current: T };
const WAVE_PHASE_PER_PX = 0.07;
const BLEED_PCT = 60;
const SCROLL_SHARE = 0.62;
const DRAW_RANGE_PX = 90;
const WAVE_AMP: number[] = [11, 6];
const WAVE_PERIOD: number[] = [150, 61];
const WAVE_SPEED: number[] = [1, 1.6];
const BASE_Y = 34;
const VIEW_W = 320;
const STEP_PX = 4;
const CYCLE_MS = 2000;
function edgeY(x: number, phase: number): number {
let y = BASE_Y;
for (let i = 0; i < WAVE_AMP.length; i += 1) {
y += WAVE_AMP[i] * Math.sin((2 * Math.PI * x) / WAVE_PERIOD[i] + phase * WAVE_SPEED[i]);
}
return y;
}
export default function WaveHandoff() {
const scrollerRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const fillRef = useRef<SVGPathElement | null>(null) as Box<SVGPathElement | null>;
const edgeRef = useRef<SVGPathElement | null>(null) as Box<SVGPathElement | null>;
const bleedRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const phaseRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const scroller = scrollerRef.current;
const fill = fillRef.current;
const edge = edgeRef.current;
const bleed = bleedRef.current;
const phaseEl = phaseRef.current;
if (!scroller || !fill || !edge || !bleed || !phaseEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let t0: number | null = null;
let raf = 0;
const update = (): void => {
const top = scroller.scrollTop;
const phase = top * WAVE_PHASE_PER_PX;
const pts: string[] = [];
let lastY = BASE_Y;
for (let x = 0; x <= VIEW_W; x += STEP_PX) {
lastY = edgeY(x, phase);
pts.push(x + " " + lastY.toFixed(1));
}
fill.setAttribute("d", "M0 0H" + VIEW_W + "V" + lastY.toFixed(1) + "L" + pts.slice().reverse().join("L") + "Z");
edge.setAttribute("d", "M" + pts.join("L"));
const drawn = Math.min(1, Math.max(0, top / DRAW_RANGE_PX));
edge.style.strokeDasharray = "1";
edge.style.strokeDashoffset = String(1 - drawn);
bleed.style.height = (BLEED_PCT * drawn).toFixed(1) + "%";
phaseEl.textContent = phase.toFixed(1);
};
const stopDemo = (): void => { demo = false; };
scroller.addEventListener("scroll", update);
scroller.addEventListener("wheel", stopDemo, { passive: true });
scroller.addEventListener("pointerdown", stopDemo);
scroller.addEventListener("keydown", stopDemo);
const tick = (ts: number): void => {
if (t0 === null) t0 = ts;
if (demo) {
const max = scroller.scrollHeight - scroller.clientHeight;
const phase = ((ts - t0) % CYCLE_MS) / CYCLE_MS;
const tri = phase < 0.5 ? phase * 2 : 2 - phase * 2;
scroller.scrollTop = max * SCROLL_SHARE * tri;
}
raf = requestAnimationFrame(tick);
};
if (reduce) {
scroller.scrollTop = ((scroller.scrollHeight - scroller.clientHeight) * SCROLL_SHARE) / 2;
update();
} else {
update();
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
scroller.removeEventListener("scroll", update);
scroller.removeEventListener("wheel", stopDemo);
scroller.removeEventListener("pointerdown", stopDemo);
scroller.removeEventListener("keydown", stopDemo);
};
}, []);
return (
<div className={styles.dv}>
<div className={styles.scroller} ref={scrollerRef} tabIndex={0} aria-label="스크롤 영역">
<section className={styles.secA}>
<p className={styles.title}>첫 섹션</p>
</section>
<svg className={styles.wave} viewBox="0 0 320 72" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path className={styles.fill} ref={fillRef} d="M0 0H320V36H0Z" />
<path className={styles.edge} ref={edgeRef} d="M0 36H320" pathLength={1} />
</svg>
<section className={styles.secB}>
<div className={styles.bleed} ref={bleedRef} />
<p className={styles.title}>다음 섹션</p>
</section>
</div>
<p className={styles.meta}>
<span className={styles.tag}>PHASE</span> <b ref={phaseRef}>0</b>
<span>rad</span>
</p>
</div>
);
}09 二つのセクションが波の境目で受け渡される区切り線の場面 — 計測 累積面積 25.993% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面
このデモが属する記事
css 区切り線 9パターン — 両端が消えて文字の幅で線が分かれる区切りの場面css 区切り線は線一本で済みそうだが、両端がぷつりと切れ、中央の文字の幅に線の長さが合わず、セクションの境目が直線で単調になる — 01番・02番・04番が一つずつ直す。このページの九つは、線の形を値で計算して描きます。 この記事のデモをすべて見る


