08ウェーブ境界フッター
明るい本文と暗いフッターの境目が2枚の波として流れ、2枚が違う速さで通るので曲線が生きて動きます。
使いどころセクションの色が変わる境目の柔らかな仕上げ
translateXprefers-reduced-motion:focus-visible

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="wv">
<div class="wv__body">
<p class="wv__lead">本文はここで終わります</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="フッターのリンク">
<a class="wv__link" href="#">紹介</a>
<a class="wv__link" href="#">記事一覧</a>
<a class="wv__link" href="#">お問い合わせ</a>
</nav>
<!-- 움직임을 끄는 스위치. 라벨 두 개를 다 심어 두고 aria-pressed 로 갈라 보인다 -->
<button class="wv__toggle" type="button" aria-pressed="false">
<span class="wv__t wv__t--stop">波を止める</span>
<span class="wv__t wv__t--play">波を動かす</span>
</button>
</div>
<p class="wv__copy">© 2026 サンプルサイト</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 ウェーブ境界フッター — 計測 累積面積 6.332% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · フッター デザイン コピペで終わるパターン9選
このデモが属する記事
フッター デザイン コピペで終わるパターン9選フッター デザイン コピペで探しているのは、ページの一番下でサイト全体を締めくくる区画のひな形です。ここではリンクの一覧で終わらせない九つのフッターを、そのまま貼れるHTMLとSCSSで載せました。サイトマップから波の境目まで、すべて画面の この記事のデモをすべて見る


