09Dark-ink contrast footer
The light body settles onto a dark-ink floor to close the page, and accent-colored underlines scan across the links on the ink in turn.
Best forGrounded closing footers for light-toned sites
scaleXtranslateY:focus-visible

<div class="stage">
<!-- footer 가 body 계통에 있으므로 암묵적 role 은 contentinfo 다 -->
<footer class="ic">
<!-- 밝은 본문 카드가 잉크 바닥 위로 내려앉으며 페이지가 닫힌다 -->
<div class="ic__sheet">
<p class="ic__eyebrow">End of page</p>
<h2 class="ic__head" tabindex="-1">Thank you for reading</h2>
</div>
<div class="ic__floor">
<nav class="ic__nav" aria-label="Footer links">
<a class="ic__link" href="#"><span class="ic__lt">About</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">Posts</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">Contact</span><span class="ic__rule" aria-hidden="true"></span></a>
<a class="ic__link" href="#"><span class="ic__lt">Privacy</span><span class="ic__rule" aria-hidden="true"></span></a>
</nav>
<div class="ic__bottom">
<p class="ic__copy">© 2026 Example Site</p>
<button class="ic__top" type="button">
<span class="ic__ic" aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M200 112H56l72-72Z" opacity=".2"/><path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z"/></g></svg>
</span>
Back to top
</button>
</div>
</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; // 잉크 위 11.97:1
$ft-flame: $stage-orange;
$ft-teal: #479089;
$ft-aqua: #b3dad5; // 잉크 위 11.58:1 — 밑줄 색
$duration: $dur-loop; // 카드가 내려앉고 밑줄 넷이 차례로 스캔되는 한 주기
$easing: $ease-spring;
$color: $ft-aqua; // 밑줄·초점 테두리 색
@include stage($ft-ink);
.ic {
--lift: -20px; // 본문 카드가 내려앉기 시작하는 높이
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
// 밝은 본문 — 잉크 위에서 유일하게 밝은 면이라 대비가 세다 (크림 위 잉크 글자 16.06:1)
.ic__sheet {
padding: $sp-4;
border-radius: $r-card;
background: $ft-cream;
text-align: center;
box-shadow: $shadow-raised;
}
.ic__eyebrow {
margin: 0 0 $sp-1;
color: $ft-stone; // 크림 위 7.08:1
font-weight: 700; font-size: 10px; line-height: 1.2; letter-spacing: .08em;
font-family: inherit;
}
.ic__head {
margin: 0;
color: $ft-ink; // 크림 위 16.06:1
font-weight: 800; font-size: clamp(14px, 3.2vw, 17px); line-height: 1.25;
font-family: inherit;
}
.ic__head:focus-visible { outline: 3px solid $ft-teal; outline-offset: 4px; }
// 잉크 바닥 — 무대와 같은 색이라 카드가 그대로 바닥에 내려앉아 보인다
.ic__floor {
padding: $sp-4 $sp-2 0;
}
.ic__nav {
display: flex;
flex-wrap: wrap;
gap: $sp-2 $sp-5;
}
.ic__link {
position: relative;
display: inline-block;
padding-bottom: $sp-1;
color: $ft-paper; // 잉크 위 17.28:1
text-decoration: none;
font-weight: 700; font-size: clamp(12px, 2.8vw, 13px); line-height: 1.3;
}
.ic__link:focus-visible { outline: 2px solid $color; outline-offset: 3px; border-radius: $r-xs; }
// 밑줄 — 기본은 접혀 있고 손을 얹거나 초점이 오면 왼쪽에서 자란다.
// .is-demo 는 이 규칙 위에 스캔 연출을 덧씌울 뿐이다
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 밑줄 왼쪽 채움
transition: transform $dur-quick $easing;
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule { transform: scaleX(1); }
.ic__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
flex-wrap: wrap;
margin: $sp-3 0 0;
padding: $sp-2 0 0;
border-top: 1px solid $ft-ink2;
}
.ic__copy {
margin: 0;
color: $ft-mist; // 잉크 위 8.48:1
font-weight: 500; font-size: 10px; line-height: 1.4;
font-family: inherit;
}
.ic__top {
display: inline-flex;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border: 1px solid $ft-ink2;
border-radius: $r-pill;
background: $ft-ink2;
color: $ft-mist;
cursor: pointer;
font-weight: 600; font-size: 10px; line-height: 1.3;
font-family: inherit;
}
.ic__top:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ic__ic { display: flex; }
// 자동 재생 — 카드가 내려앉고, 밑줄 넷이 시차로 스캔된다.
// animation: 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓰고 fill-mode: backwards 를 함께 준다
.ic.is-demo .ic__sheet {
animation-name: sheetDrop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__rule {
animation-name: ruleScan;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule { animation-delay: 180ms; }
.ic.is-demo .ic__link:nth-child(3) .ic__rule { animation-delay: 360ms; }
.ic.is-demo .ic__link:nth-child(4) .ic__rule { animation-delay: 540ms; }
@keyframes sheetDrop {
0% { transform: translateY(var(--lift)); }
22% { transform: translateY(0); }
30% { transform: translateY(-3px); }
38%, 100% { transform: translateY(0); }
}
// 복귀는 90% 안에 끝낸다 — 24프레임의 마지막 장은 95.8% 지점이라 그 뒤는 샘플이 없다
@keyframes ruleScan {
0% { transform: scaleX(0); }
24%, 60% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.ic { --lift: -14px; width: 276px; }
.ic__sheet { padding: $sp-2; }
.ic__head { font-size: 13px; }
.ic__floor { padding: $sp-2 0 0; }
.ic__nav { gap: $sp-1 $sp-3; }
.ic__link { font-size: 12px; }
.ic__bottom { margin: $sp-2 0 0; padding: $sp-1 0 0; }
.ic__copy { font-size: 9px; }
.ic__top { font-size: 9px; }
}
// .ic.is-demo .ic__rule 은 (0,3,0) 이라 @media 안의 낮은 특이도로는 못 이긴다 — !important 로 끈다
@media (prefers-reduced-motion: reduce) {
.ic__sheet { animation: none !important; transform: none !important; }
.ic__rule { animation: none !important; transition: 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);
}
.ic {
--lift: -20px;
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
.ic__sheet {
padding: 16px;
border-radius: 16px;
background: #fbf6ee;
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ic__eyebrow {
margin: 0 0 4px;
color: #575162;
font-weight: 700;
font-size: 10px;
line-height: 1.2;
letter-spacing: 0.08em;
font-family: inherit;
}
.ic__head {
margin: 0;
color: #1b1a20;
font-weight: 800;
font-size: clamp(14px, 3.2vw, 17px);
line-height: 1.25;
font-family: inherit;
}
.ic__head:focus-visible {
outline: 3px solid #479089;
outline-offset: 4px;
}
.ic__floor {
padding: 16px 8px 0;
}
.ic__nav {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
}
.ic__link {
position: relative;
display: inline-block;
padding-bottom: 4px;
color: #ffffff;
text-decoration: none;
font-weight: 700;
font-size: clamp(12px, 2.8vw, 13px);
line-height: 1.3;
}
.ic__link:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 3px;
border-radius: 4px;
}
.ic__rule {
position: absolute;
left: 0;
bottom: 0;
display: block;
width: 100%;
height: 2px;
background: #b3dad5;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ic__link:hover .ic__rule,
.ic__link:focus-visible .ic__rule {
transform: scaleX(1);
}
.ic__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin: 12px 0 0;
padding: 8px 0 0;
border-top: 1px solid #2c2a33;
}
.ic__copy {
margin: 0;
color: #b9b3c4;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
}
.ic__top {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid #2c2a33;
border-radius: 999px;
background: #2c2a33;
color: #b9b3c4;
cursor: pointer;
font-weight: 600;
font-size: 10px;
line-height: 1.3;
font-family: inherit;
}
.ic__top:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.ic__ic {
display: flex;
}
.ic.is-demo .ic__sheet {
animation-name: sheetDrop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__rule {
animation-name: ruleScan;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__link:nth-child(2) .ic__rule {
animation-delay: 180ms;
}
.ic.is-demo .ic__link:nth-child(3) .ic__rule {
animation-delay: 360ms;
}
.ic.is-demo .ic__link:nth-child(4) .ic__rule {
animation-delay: 540ms;
}
@keyframes sheetDrop {
0% {
transform: translateY(var(--lift));
}
22% {
transform: translateY(0);
}
30% {
transform: translateY(-3px);
}
38%, 100% {
transform: translateY(0);
}
}
@keyframes ruleScan {
0% {
transform: scaleX(0);
}
24%, 60% {
transform: scaleX(1);
}
84%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ic {
--lift: -14px;
width: 276px;
}
.ic__sheet {
padding: 8px;
}
.ic__head {
font-size: 13px;
}
.ic__floor {
padding: 8px 0 0;
}
.ic__nav {
gap: 4px 12px;
}
.ic__link {
font-size: 12px;
}
.ic__bottom {
margin: 8px 0 0;
padding: 4px 0 0;
}
.ic__copy {
font-size: 9px;
}
.ic__top {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.ic__sheet {
animation: none !important;
transform: none !important;
}
.ic__rule {
animation: none !important;
transition: none !important;
}
}(function () {
var ic = document.querySelector('.ic');
var headEl = document.querySelector('.ic__head');
var topBtn = document.querySelector('.ic__top');
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
ic.classList.add('is-demo');
function stopDemo() { ic.classList.remove('is-demo'); }
['pointerdown', 'keydown'].forEach(function (t) {
document.addEventListener(t, stopDemo, { once: true });
});
// 버튼을 누르면 스크롤을 되돌리고 초점도 문서 처음으로 옮긴다(키보드 사용자는 초점이 따라가야 한다).
// focus() 에는 반드시 preventScroll: 이 데모는 목록에 iframe 으로 박혀 있어 부모 페이지가 끌려간다
topBtn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
headEl.focus({ preventScroll: true });
});
})();import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./InkContrastFooter.module.scss";
export type InkContrastFooterProps = {
/** 한 주기(ms) — 카드가 내려앉고 밑줄 넷이 차례로 스캔되는 시간 */
duration?: number;
easing?: string;
/** 밑줄·초점 테두리 색 — 잉크 위 11.58:1 인 아쿠아가 기본 */
color?: string;
/** 푸터 링크 이름 4개 */
links?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** ambient 에 HTMLHeadingElement 초점 호출용 타입만 로컬로 둔다 */
type FocusTarget = { focus(o: { preventScroll: boolean }): void };
export default function InkContrastFooter({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#6ee7d7",
links = ["소개", "글 목록", "문의", "개인정보"],
}: InkContrastFooterProps): ReactNode {
const [demo, setDemo] = useState(true);
const headRef = useRef<FocusTarget>(null);
// 격자 미리보기용 자동 재생. 사람이 손을 대면 떼고 진짜 조작으로 넘긴다
useEffect(() => {
const off = (): void => setDemo(false);
document.addEventListener("pointerdown", off, { once: true });
document.addEventListener("keydown", off, { once: true });
return () => {
document.removeEventListener("pointerdown", off);
document.removeEventListener("keydown", off);
};
}, []);
// 맨 위로 — 스크롤을 되돌리고 초점도 문서 맨 위로 옮긴다.
// focus() 에는 반드시 preventScroll: 데모가 목록에 iframe 으로 박히면 부모 페이지가 끌려간다
const goTop = useCallback((): void => {
window.scrollTo({ top: 0, behavior: "smooth" });
if (headRef.current) headRef.current.focus({ preventScroll: true });
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<footer className={`${styles.ic} ${demo ? styles.isDemo : ""}`} style={vars}>
<div className={styles.sheet}>
<p className={styles.eyebrow}>본문 끝</p>
<h2 className={styles.head} tabIndex={-1} ref={headRef}>읽어 주셔서 고맙습니다</h2>
</div>
<div className={styles.floor}>
<nav className={styles.nav} aria-label="푸터 링크">
{links.map((t) => (
<a key={t} className={styles.link} href="#">
<span className={styles.lt}>{t}</span>
<span className={styles.rule} aria-hidden="true" />
</a>
))}
</nav>
<div className={styles.bottom}>
<p className={styles.copy}>© 2026 예시 사이트</p>
<button className={styles.top} type="button" onClick={goTop}>
<span className={styles.ic2} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M200 112H56l72-72Z" opacity=".2" />
<path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z" />
</g>
</svg>
</span>
맨 위로
</button>
</div>
</div>
</footer>
);
}09 Dark-ink contrast footer — Measured area 14.908% · moving frames 22/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


