08Wavy-edge footer
The boundary between the light body and the dark footer flows as two waves drifting at different speeds, so the curve stays alive.
Best forSoft closures where the section color changes
translateXprefers-reduced-motion:focus-visible

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="wv">
<div class="wv__body">
<p class="wv__lead">The main text ends here</p>
</div>
<!-- 경계 — 물결 두 겹. 모양은 SVG path 를 data URI 로 깐 배경이고, 타일 폭은 120px 이다 -->
<div class="wv__edge" aria-hidden="true">
<span class="wv__wave wv__wave--back"></span>
<span class="wv__wave wv__wave--front"></span>
</div>
<div class="wv__foot">
<div class="wv__row">
<nav class="wv__nav" aria-label="Footer links">
<a class="wv__link" href="#">About</a>
<a class="wv__link" href="#">Posts</a>
<a class="wv__link" href="#">Contact</a>
</nav>
<!-- 움직임을 끄는 스위치. 라벨 두 개를 다 심어 두고 aria-pressed 로 갈라 보인다 -->
<button class="wv__toggle" type="button" aria-pressed="false">
<span class="wv__t wv__t--stop">Pause waves</span>
<span class="wv__t wv__t--play">Play waves</span>
</button>
</div>
<p class="wv__copy">© 2026 Example Site</p>
</div>
</footer>
</div>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실제 대비비(WCAG 2.1 1.4.3)
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33;
$ft-paper: $stage-paper; // 잉크 위 17.28:1
$ft-cream: #fbf6ee; // 밝은 본문
$ft-stone: #575162; // 크림 위 7.08:1
$ft-mist: #b9b3c4; // 잉크 위 8.48:1 — 뒷물결의 옅은 색이기도 하다
$ft-amber: $stage-yellow;
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5; // 잉크 위 11.58:1
$duration: $dur-loop; // 앞물결이 한 벌(480px = 타일 4장)을 지나는 시간
$easing: linear; // 흐르는 물결은 등속이어야 이음매가 안 보인다
$color: $ft-ink; // 앞물결 = 푸터 바닥과 같은 색이라 경계가 이어진다
// 물결 모양은 SVG path 를 data URI 로 깐 배경이다 (외부 요청 0).
// 타일은 120×28, 양 끝의 접선 기울기가 같아 옆으로 붙여도 이음매가 없다.
// 색은 URL 안에 들어가므로 # 을 %23 으로 인코딩한 문자열로 둔다
$w-front: "%231b1a20"; // = $ft-ink
$w-back: "%23b9b3c4"; // = $ft-mist
@include stage($ft-ink);
.wv {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
// 위 — 밝은 본문
.wv__body {
padding: $sp-4;
background: $ft-cream;
text-align: center;
}
.wv__lead {
margin: 0;
color: $ft-stone;
font-weight: 600; font-size: clamp(11px, 2.6vw, 13px); line-height: 1.3;
font-family: inherit;
}
// 경계 — 두 겹이 서로 다른 속도로 흐른다. 상자는 크림이고 물결이 잉크를 끌어올린다
.wv__edge {
position: relative;
height: 28px;
overflow: hidden;
background: $ft-cream;
}
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px; // 한 벌 480px(타일 4장) × 2 — 정확히 두 벌이라 반 바퀴가 한 벌이다
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px; // 타일을 먼저 못 박는다
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-duration: $duration;
}
// 뒷겹 — 옅고 느리다. 한 주기에 타일 2장(240px)만 간다
.wv__wave--back {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 12C20 2 40 2 60 12S100 22 120 12V28H0Z' fill='#{$w-back}'/%3E%3C/svg%3E");
animation-name: waveBack;
}
// 앞겹 — 진하고 빠르다. 한 주기에 타일 4장(480px). 둘 다 타일 폭의 정수배라 텔레포트가 없다
.wv__wave--front {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 14C15 0 45 0 60 14S105 28 120 14V28H0Z' fill='#{$w-front}'/%3E%3C/svg%3E");
animation-name: waveFront;
}
// 아래 — 어두운 푸터
.wv__foot {
padding: $sp-3 $sp-4 $sp-4;
background: $color;
}
.wv__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
flex-wrap: wrap;
}
.wv__nav { display: flex; gap: $sp-3; flex-wrap: wrap; }
.wv__link {
color: $ft-paper; // 잉크 위 17.28:1
text-decoration: none;
font-weight: 600; font-size: clamp(11px, 2.6vw, 12px); line-height: 1.4;
}
.wv__link:hover { color: $ft-aqua; } // 잉크 위 11.58:1
.wv__link:focus-visible { outline: 2px solid $ft-aqua; outline-offset: 3px; border-radius: $r-xs; }
.wv__toggle {
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border: 1px solid $ft-mist;
border-radius: $r-pill;
background: $ft-ink2;
color: $ft-mist; // 잉크2 위 8.1:1
cursor: pointer;
font-weight: 600; font-size: 11px; line-height: 1.3;
font-family: inherit;
}
.wv__toggle:focus-visible { outline: 2px solid $ft-aqua; outline-offset: 2px; }
.wv__toggle[aria-pressed="false"] .wv__t--play { display: none; }
.wv__toggle[aria-pressed="true"] .wv__t--stop { display: none; }
.wv__copy {
margin: $sp-2 0 0;
color: $ft-mist; // 잉크 위 8.48:1
font-weight: 500; font-size: 10px; line-height: 1.4;
font-family: inherit;
}
// 이동량은 타일 폭(120px)의 정수배 — 한 바퀴 뒤 무늬가 픽셀까지 같아 이음매가 없다
@keyframes waveBack { from { transform: translateX(0); } to { transform: translateX(-240px); } }
@keyframes waveFront { from { transform: translateX(0); } to { transform: translateX(-480px); } }
// 스위치로 멈춘 상태 — 움직임만 멈추고 모양은 그대로다
.wv.is-paused .wv__wave { animation-play-state: paused; }
@media (max-width: 340px), (max-height: 220px) {
.wv { width: 276px; }
.wv__body { padding: $sp-2; }
.wv__edge { height: 20px; }
.wv__wave { height: 20px; background-size: 120px 20px; }
.wv__foot { padding: $sp-2; }
.wv__nav { gap: $sp-2; }
.wv__link { font-size: 11px; }
.wv__toggle { font-size: 10px; }
.wv__copy { margin: $sp-1 0 0; font-size: 9px; }
}
// .wv.is-paused .wv__wave 처럼 특이도가 올라간 규칙이 있으므로 !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.wv__wave { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1b1a20;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wv {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.wv__body {
padding: 16px;
background: #fbf6ee;
text-align: center;
}
.wv__lead {
margin: 0;
color: #575162;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1.3;
font-family: inherit;
}
.wv__edge {
position: relative;
height: 28px;
overflow: hidden;
background: #fbf6ee;
}
.wv__wave {
position: absolute;
top: 0;
left: 0;
display: block;
width: 960px;
height: 28px;
background-repeat: repeat-x;
background-size: 120px 28px;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: 2s;
}
.wv__wave--back {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 12C20 2 40 2 60 12S100 22 120 12V28H0Z' fill='%23b9b3c4'/%3E%3C/svg%3E");
animation-name: waveBack;
}
.wv__wave--front {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' preserveAspectRatio='none'%3E%3Cpath d='M0 14C15 0 45 0 60 14S105 28 120 14V28H0Z' fill='%231b1a20'/%3E%3C/svg%3E");
animation-name: waveFront;
}
.wv__foot {
padding: 12px 16px 16px;
background: #1b1a20;
}
.wv__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.wv__nav {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.wv__link {
color: #ffffff;
text-decoration: none;
font-weight: 600;
font-size: clamp(11px, 2.6vw, 12px);
line-height: 1.4;
}
.wv__link:hover {
color: #b3dad5;
}
.wv__link:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 3px;
border-radius: 4px;
}
.wv__toggle {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #b9b3c4;
border-radius: 999px;
background: #2c2a33;
color: #b9b3c4;
cursor: pointer;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
}
.wv__toggle:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.wv__toggle[aria-pressed=false] .wv__t--play {
display: none;
}
.wv__toggle[aria-pressed=true] .wv__t--stop {
display: none;
}
.wv__copy {
margin: 8px 0 0;
color: #b9b3c4;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
}
@keyframes waveBack {
from {
transform: translateX(0);
}
to {
transform: translateX(-240px);
}
}
@keyframes waveFront {
from {
transform: translateX(0);
}
to {
transform: translateX(-480px);
}
}
.wv.is-paused .wv__wave {
animation-play-state: paused;
}
@media (max-width: 340px), (max-height: 220px) {
.wv {
width: 276px;
}
.wv__body {
padding: 8px;
}
.wv__edge {
height: 20px;
}
.wv__wave {
height: 20px;
background-size: 120px 20px;
}
.wv__foot {
padding: 8px;
}
.wv__nav {
gap: 8px;
}
.wv__link {
font-size: 11px;
}
.wv__toggle {
font-size: 10px;
}
.wv__copy {
margin: 4px 0 0;
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.wv__wave {
animation: none !important;
}
}(function () {
var wv = document.querySelector('.wv');
var btn = document.querySelector('.wv__toggle');
// button 이라 클릭·터치·Enter·Space 가 모두 click 으로 온다
btn.addEventListener('click', function () {
var isPaused = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', isPaused ? 'false' : 'true');
wv.classList.toggle('is-paused', !isPaused);
});
})();import { useCallback, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./WaveTopFooter.module.scss";
export type WaveTopFooterProps = {
/** 앞물결이 한 벌(480px = 타일 4장)을 지나는 시간(ms) */
duration?: number;
/** 흐르는 물결은 등속이어야 이음매가 안 보인다 */
easing?: string;
/** 푸터 바닥 색 — 앞물결과 같은 색이라 경계가 이어진다 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WaveTopFooter({
duration = 2000,
easing = "linear",
color = "#1b1a20",
}: WaveTopFooterProps): ReactNode {
const [paused, setPaused] = useState(false);
// button 이라 클릭·터치·Enter·Space 가 모두 onClick 으로 온다
const toggleWave = useCallback((): void => setPaused((p) => !p), []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={`${styles.wv} ${paused ? styles.isPaused : ""}`} style={vars}>
<div className={styles.body}>
<p className={styles.lead}>본문은 여기서 끝납니다</p>
</div>
{/* 경계 — 물결 두 겹. 모양은 SVG path 를 data URI 로 깐 배경이고, 타일 폭은 120px 이다 */}
<div className={styles.edge} aria-hidden="true">
<span className={`${styles.wave} ${styles.waveBack}`} />
<span className={`${styles.wave} ${styles.waveFront}`} />
</div>
<div className={styles.foot}>
<div className={styles.row}>
<nav className={styles.nav} aria-label="푸터 링크">
<a className={styles.link} href="#">소개</a>
<a className={styles.link} href="#">글 목록</a>
<a className={styles.link} href="#">문의</a>
</nav>
<button className={styles.toggle} type="button" aria-pressed={paused} onClick={toggleWave}>
{paused ? "물결 재생" : "물결 멈춤"}
</button>
</div>
<p className={styles.copy}>© 2026 예시 사이트</p>
</div>
</footer>
);
}08 Wavy-edge footer — Measured area 6.332% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Website Footer Design: 9 Copy-Paste Patterns
The post this demo lives in
Website Footer Design: 9 Copy-Paste PatternsWebsite footer design is how the last band of a page is laid out — sitemap links, a signup box, contact details, a copyr See all demos in this post


