02Newsletter-integrated footer
Fill the email and send: press, spinning loader, and a stamped confirmation follow in one line, and the result is announced to people who cannot see the screen.
Best forSubscription footers and weekly newsletter signups
aria-livesteps(1, end)translateY

<main class="stage">
<footer class="fn is-demo is-idle" role="contentinfo">
<p class="fn__title">Get the weekly digest</p>
<p class="fn__desc">New posts and code snippets, once a week. Nothing else.</p>
<form class="fn__form" novalidate>
<label class="fn__label" for="fn-mail">Email address</label>
<div class="fn__row">
<input class="fn__input" id="fn-mail" type="email" name="email" autocomplete="email" placeholder="[email protected]">
<button class="fn__send" type="submit">
<span class="fn__send-t">Subscribe</span>
<span class="fn__icons" aria-hidden="true">
<span class="fn__ic fn__ic--plane"><svg viewBox="0 0 256 256" width="16" height="16" focusable="false"><g fill="currentColor"><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="fn__ic fn__ic--load"><span class="fn__spin"></span></span>
<span class="fn__ic fn__ic--done"><svg viewBox="0 0 256 256" width="18" height="18" focusable="false"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</span>
</button>
</div>
<span class="fn__bar" aria-hidden="true"></span>
</form>
<p class="fn__msg" role="status" aria-live="polite">
<span class="fn__m fn__m--idle">You can unsubscribe in one click, anytime.</span>
<span class="fn__m fn__m--send">Sending…</span>
<span class="fn__m fn__m--done">Confirmation email sent.</span>
<span class="fn__m fn__m--err"></span>
</p>
<p class="fn__legal">© 2026 Studio</p>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// 272 푸터 팔레트 — 세 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$ft-ink: #1b1a20; // 어두운 푸터 바닥
$ft-ink2: #2c2a33; // 바닥 위 입력칸·구분선
$ft-paper: $stage-paper; // 흰 글자 on ink 17.28:1 · 흰 글자 on teal 5.79:1
$ft-mist: #b9b3c4; // 보조 글자 on ink 8.48:1
$ft-amber: $stage-yellow; // 무대
$ft-flame: $stage-orange; // 오류 글자 on ink 5.21:1
$ft-teal: #479089; // 보내기 버튼 면
$ft-aqua: #b3dad5; // 완료 버튼 면 (ink 글자 11.58:1) · 진행 막대
$duration: $dur-loop; // 입력 대기 → 보냄 → 완료 → 다시 대기 한 바퀴
$easing: $ease-pop; // 완료 도장이 튀어 오를 때
$color: $ft-amber; // 무대(앰버)
@include stage($ft-amber);
.fn {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: $sp-4;
border-radius: $r-card;
background: $ft-ink;
color: $ft-paper;
}
.fn__title {
margin: 0;
font-weight: 800; font-size: 16px; line-height: 1.2;
}
.fn__desc {
margin: $sp-1 0 $sp-3;
color: $ft-mist;
font-weight: 500; font-size: 12px; line-height: 1.4;
}
.fn__label {
display: block;
margin-bottom: $sp-1;
color: $ft-mist;
font-weight: 600; font-size: 11px; line-height: 1.2;
}
.fn__row {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.fn__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border: 1px solid $ft-mist;
border-radius: $r-control;
background: $ft-ink2;
color: $ft-paper;
font-family: inherit;
font-weight: 500; font-size: 13px; line-height: 1.4;
}
.fn__input::placeholder { color: $ft-mist; }
.fn__input:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
}
// 기본 면은 teal — 흰 글자로 5.79:1. 완료 상태에서만 aqua 로 바뀌고 글자가 ink 로 뒤집힌다
.fn__send {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 2px solid $ft-aqua;
border-radius: $r-control;
background: $ft-teal;
color: $ft-paper;
font-family: inherit;
font-weight: 700; font-size: 13px; line-height: 1.4;
cursor: pointer;
}
.fn__send:focus-visible {
outline: 2px solid $ft-aqua;
outline-offset: 2px;
}
.fn.is-done .fn__send { background: $ft-aqua; color: $ft-ink; }
.fn.is-sending .fn__send { transform: translateY(3px); }
// 아이콘 세 장을 같은 칸에 포갠다 — 상태가 바뀌어도 버튼 폭이 흔들리지 않는다
.fn__icons {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.fn__ic {
grid-area: 1 / 1;
display: grid;
place-items: center;
opacity: 0;
}
.fn.is-idle .fn__ic--plane,
.fn.is-error .fn__ic--plane { opacity: 1; }
.fn.is-sending .fn__ic--load { opacity: 1; }
.fn.is-done .fn__ic--done { opacity: 1; }
.fn__spin {
display: block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, .35);
border-top-color: currentColor;
border-radius: 50%;
animation-name: fnSpin;
animation-duration: 500ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 보내는 동안 차오르는 막대. 탭 인디케이터가 아니라 진행 게이지다
.fn__bar {
display: block;
height: 2px;
margin-top: $sp-2;
border-radius: $r-pill;
background: $ft-aqua;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 진행 게이지 왼쪽 채움
}
.fn.is-sending .fn__bar { transform: scaleX(.6); }
.fn.is-done .fn__bar { transform: scaleX(1); }
// 결과 문장 넷을 같은 칸에 포갠다 — 글자가 바뀌어도 줄이 흔들리지 않는다
.fn__msg {
display: grid;
margin: $sp-2 0 0;
min-height: 17px;
}
.fn__m {
grid-area: 1 / 1;
opacity: 0;
font-weight: 500; font-size: 12px; line-height: 1.4;
}
.fn__m--idle, .fn__m--send { color: $ft-mist; }
.fn__m--done { color: $ft-aqua; }
.fn__m--err { color: $ft-flame; }
.fn.is-idle .fn__m--idle,
.fn.is-sending .fn__m--send,
.fn.is-done .fn__m--done,
.fn.is-error .fn__m--err { opacity: 1; }
.fn__legal {
margin: $sp-3 0 0;
padding-top: $sp-3;
border-top: 1px solid $ft-ink2;
color: $ft-mist;
font-weight: 500; font-size: 11px; line-height: 1.4;
}
// ── 자동 재생 ─────────────────────────────────────────────
// 상태 교체는 steps(1, end) 컷 — 반투명 유령 프레임이 생기지 않는다.
// 컷만 있으면 움직인 프레임이 두세 장으로 떨어지므로 옆에 transform 움직임
// (도장 translateY 팝 · 버튼 누름 · 막대 scaleX)을 같이 둔다.
// 퇴장은 재현하지 않는다 — 완료 표시를 애니메이션으로 빼지 않고 컷으로 조용히 되돌린다.
.fn.is-demo .fn__ic, .fn.is-demo .fn__m {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fn.is-demo .fn__ic--plane { animation-name: fnPlane; }
.fn.is-demo .fn__ic--load { animation-name: fnLoad; }
.fn.is-demo .fn__ic--done { animation-name: fnDone; }
.fn.is-demo .fn__m--idle { animation-name: fnMsgIdle; }
.fn.is-demo .fn__m--send { animation-name: fnMsgSend; }
.fn.is-demo .fn__m--done { animation-name: fnMsgDone; }
.fn.is-demo .fn__ic--done svg {
animation-name: fnPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fn.is-demo .fn__send {
animation-name: fnFace, fnPress;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-spring;
animation-iteration-count: infinite, infinite;
}
.fn.is-demo .fn__bar {
animation-name: fnBar;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes fnSpin { to { transform: rotate(360deg); } }
@keyframes fnPlane { 0% { opacity: 1; } 40% { opacity: 0; } 82% { opacity: 1; } }
@keyframes fnLoad { 0% { opacity: 0; } 40% { opacity: 1; } 58% { opacity: 0; } }
@keyframes fnDone { 0% { opacity: 0; } 58% { opacity: 1; } 82% { opacity: 0; } }
@keyframes fnMsgIdle { 0% { opacity: 1; } 40% { opacity: 0; } 89% { opacity: 1; } }
@keyframes fnMsgSend { 0% { opacity: 0; } 40% { opacity: 1; } 58% { opacity: 0; } }
@keyframes fnMsgDone { 0% { opacity: 0; } 58% { opacity: 1; } 89% { opacity: 0; } }
@keyframes fnFace {
0% { background: #{$ft-teal}; color: #{$ft-paper}; }
58% { background: #{$ft-aqua}; color: #{$ft-ink}; }
86% { background: #{$ft-teal}; color: #{$ft-paper}; }
}
@keyframes fnPress {
0%, 38% { transform: translateY(0); }
41% { transform: translateY(3px); }
46%, 100% { transform: translateY(0); }
}
@keyframes fnPop {
0%, 58% { transform: translateY(6px) scale(.7); }
66%, 82% { transform: translateY(0) scale(1); }
83%, 100% { transform: translateY(6px) scale(.7); }
}
@keyframes fnBar {
0%, 40% { transform: scaleX(0); }
58%, 88.9% { transform: scaleX(1); }
89%, 100% { transform: scaleX(0); }
}
// 좁은 화면 — 세로 여유가 174px 뿐이라 값을 올리지 않고 내린다
@media (max-width: 340px), (max-height: 220px) {
.fn { width: 100%; padding: $sp-2; }
.fn__title { font-size: 12px; }
.fn__desc { margin: $sp-1 0 $sp-2; font-size: 10px; line-height: 1.3; }
.fn__label { font-size: 10px; }
.fn__row { gap: $sp-1; }
.fn__input { padding: $sp-1 $sp-2; font-size: 11px; }
.fn__send { padding: $sp-1 $sp-2; font-size: 11px; }
.fn__bar { margin-top: $sp-1; }
.fn__msg { margin-top: $sp-1; min-height: 14px; }
.fn__m { font-size: 10px; line-height: 1.3; }
.fn__legal { margin-top: $sp-1; padding-top: $sp-1; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .fn.is-demo .fn__ic (0,3,0) 를 이기려면 !important 가 필요하다
.fn__ic, .fn__m, .fn__send, .fn__bar, .fn__spin, .fn__ic--done svg { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fn {
box-sizing: border-box;
width: clamp(320px, 92vw, 432px);
padding: 16px;
border-radius: 16px;
background: #1b1a20;
color: #ffffff;
}
.fn__title {
margin: 0;
font-weight: 800;
font-size: 16px;
line-height: 1.2;
}
.fn__desc {
margin: 4px 0 12px;
color: #b9b3c4;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.fn__label {
display: block;
margin-bottom: 4px;
color: #b9b3c4;
font-weight: 600;
font-size: 11px;
line-height: 1.2;
}
.fn__row {
display: flex;
align-items: stretch;
gap: 8px;
}
.fn__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid #b9b3c4;
border-radius: 12px;
background: #2c2a33;
color: #ffffff;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.fn__input::placeholder {
color: #b9b3c4;
}
.fn__input:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.fn__send {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 2px solid #b3dad5;
border-radius: 12px;
background: #479089;
color: #ffffff;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
}
.fn__send:focus-visible {
outline: 2px solid #b3dad5;
outline-offset: 2px;
}
.fn.is-done .fn__send {
background: #b3dad5;
color: #1b1a20;
}
.fn.is-sending .fn__send {
transform: translateY(3px);
}
.fn__icons {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.fn__ic {
grid-area: 1/1;
display: grid;
place-items: center;
opacity: 0;
}
.fn.is-idle .fn__ic--plane,
.fn.is-error .fn__ic--plane {
opacity: 1;
}
.fn.is-sending .fn__ic--load {
opacity: 1;
}
.fn.is-done .fn__ic--done {
opacity: 1;
}
.fn__spin {
display: block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: currentColor;
border-radius: 50%;
animation-name: fnSpin;
animation-duration: 500ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.fn__bar {
display: block;
height: 2px;
margin-top: 8px;
border-radius: 999px;
background: #b3dad5;
transform: scaleX(0);
transform-origin: left center;
}
.fn.is-sending .fn__bar {
transform: scaleX(0.6);
}
.fn.is-done .fn__bar {
transform: scaleX(1);
}
.fn__msg {
display: grid;
margin: 8px 0 0;
min-height: 17px;
}
.fn__m {
grid-area: 1/1;
opacity: 0;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
}
.fn__m--idle, .fn__m--send {
color: #b9b3c4;
}
.fn__m--done {
color: #b3dad5;
}
.fn__m--err {
color: #ff4d1f;
}
.fn.is-idle .fn__m--idle,
.fn.is-sending .fn__m--send,
.fn.is-done .fn__m--done,
.fn.is-error .fn__m--err {
opacity: 1;
}
.fn__legal {
margin: 12px 0 0;
padding-top: 12px;
border-top: 1px solid #2c2a33;
color: #b9b3c4;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.fn.is-demo .fn__ic, .fn.is-demo .fn__m {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.fn.is-demo .fn__ic--plane {
animation-name: fnPlane;
}
.fn.is-demo .fn__ic--load {
animation-name: fnLoad;
}
.fn.is-demo .fn__ic--done {
animation-name: fnDone;
}
.fn.is-demo .fn__m--idle {
animation-name: fnMsgIdle;
}
.fn.is-demo .fn__m--send {
animation-name: fnMsgSend;
}
.fn.is-demo .fn__m--done {
animation-name: fnMsgDone;
}
.fn.is-demo .fn__ic--done svg {
animation-name: fnPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.fn.is-demo .fn__send {
animation-name: fnFace, fnPress;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.fn.is-demo .fn__bar {
animation-name: fnBar;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fnSpin {
to {
transform: rotate(360deg);
}
}
@keyframes fnPlane {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
82% {
opacity: 1;
}
}
@keyframes fnLoad {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
58% {
opacity: 0;
}
}
@keyframes fnDone {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
82% {
opacity: 0;
}
}
@keyframes fnMsgIdle {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
89% {
opacity: 1;
}
}
@keyframes fnMsgSend {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
58% {
opacity: 0;
}
}
@keyframes fnMsgDone {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
89% {
opacity: 0;
}
}
@keyframes fnFace {
0% {
background: #479089;
color: #ffffff;
}
58% {
background: #b3dad5;
color: #1b1a20;
}
86% {
background: #479089;
color: #ffffff;
}
}
@keyframes fnPress {
0%, 38% {
transform: translateY(0);
}
41% {
transform: translateY(3px);
}
46%, 100% {
transform: translateY(0);
}
}
@keyframes fnPop {
0%, 58% {
transform: translateY(6px) scale(0.7);
}
66%, 82% {
transform: translateY(0) scale(1);
}
83%, 100% {
transform: translateY(6px) scale(0.7);
}
}
@keyframes fnBar {
0%, 40% {
transform: scaleX(0);
}
58%, 88.9% {
transform: scaleX(1);
}
89%, 100% {
transform: scaleX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fn {
width: 100%;
padding: 8px;
}
.fn__title {
font-size: 12px;
}
.fn__desc {
margin: 4px 0 8px;
font-size: 10px;
line-height: 1.3;
}
.fn__label {
font-size: 10px;
}
.fn__row {
gap: 4px;
}
.fn__input {
padding: 4px 8px;
font-size: 11px;
}
.fn__send {
padding: 4px 8px;
font-size: 11px;
}
.fn__bar {
margin-top: 4px;
}
.fn__msg {
margin-top: 4px;
min-height: 14px;
}
.fn__m {
font-size: 10px;
line-height: 1.3;
}
.fn__legal {
margin-top: 4px;
padding-top: 4px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fn__ic, .fn__m, .fn__send, .fn__bar, .fn__spin, .fn__ic--done svg {
animation: none !important;
}
}(function () {
var root = document.querySelector('.fn');
var form = root.querySelector('.fn__form');
var input = root.querySelector('.fn__input');
var err = root.querySelector('.fn__m--err');
var timer = 0;
// 상태는 뿌리의 클래스 하나로만 관리한다 — 정적 규칙이 먼저, 자동 루프는 그 위에 덧씌운다
function setState(s) {
root.classList.remove('is-idle', 'is-sending', 'is-done', 'is-error');
root.classList.add(s);
}
function stopLoop() { root.classList.remove('is-demo'); }
form.addEventListener('submit', function (e) {
e.preventDefault();
stopLoop();
clearTimeout(timer);
var v = input.value.trim();
// 오류도 같은 aria-live 영역으로 알린다 — 화면을 못 보는 사람에게 결과가 한 곳에서만 들린다
if (!v) { err.textContent = 'Please enter an email address.'; setState('is-error'); return; }
if (v.indexOf('@') < 0) { err.textContent = 'The address is missing an @.'; setState('is-error'); return; }
setState('is-sending');
timer = setTimeout(function () { setState('is-done'); }, 900);
});
input.addEventListener('input', function () {
stopLoop();
if (root.classList.contains('is-error')) { setState('is-idle'); }
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent, ReactNode } from "react";
import styles from "./FooterNewsletter.module.scss";
/* ambient 에 FormEvent 가 없어 파일 안에 필요한 만큼만 선언한다 */
type SubmitEvt = { preventDefault(): void };
export type NewsletterState = "idle" | "sending" | "done" | "error";
export type FooterNewsletterProps = {
title?: string;
desc?: string;
label?: string;
placeholder?: string;
sendText?: string;
idleText?: string;
sendingText?: string;
doneText?: string;
emptyError?: string;
atError?: string;
legal?: string;
/** 입력 대기 → 보냄 → 완료 → 다시 대기 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 무대 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Plane = (): ReactNode => (
<svg viewBox="0 0 256 256" width="16" height="16" focusable="false">
<g fill="currentColor">
<path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" />
</g>
</svg>
);
const CheckCircle = (): ReactNode => (
<svg viewBox="0 0 256 256" width="18" height="18" focusable="false">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
export default function FooterNewsletter({
title = "주간 소식 받기",
desc = "새 글과 코드 조각을 한 주에 한 번만 보내 드립니다.",
label = "이메일 주소",
placeholder = "[email protected]",
sendText = "구독",
idleText = "언제든 한 번에 구독을 끊을 수 있습니다.",
sendingText = "보내는 중입니다.",
doneText = "확인 메일을 보냈습니다.",
emptyError = "이메일 주소를 입력해 주세요.",
atError = "주소에 @ 가 빠졌습니다.",
legal = "© 2026 스튜디오",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: FooterNewsletterProps): ReactNode {
const [state, setState] = useState<NewsletterState>("idle");
const [value, setValue] = useState<string>("");
const [error, setError] = useState<string>("");
const [loop, setLoop] = useState<boolean>(true);
const timer = useRef<number | null>(null);
useEffect(() => () => {
if (timer.current !== null) { window.clearTimeout(timer.current); }
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const submit = (e: SubmitEvt): void => {
e.preventDefault();
setLoop(false);
if (timer.current !== null) { window.clearTimeout(timer.current); }
const v = value.trim();
/* 오류도 같은 aria-live 영역으로 알린다 — 결과가 한 곳에서만 들린다 */
if (v === "") { setError(emptyError); setState("error"); return; }
if (v.indexOf("@") < 0) { setError(atError); setState("error"); return; }
setState("sending");
timer.current = window.setTimeout(() => setState("done"), 900);
};
const onInput = (e: ChangeEvent<HTMLInputElement>): void => {
setLoop(false);
setValue(e.target.value);
if (state === "error") { setState("idle"); }
};
const stateClass =
state === "sending" ? styles.isSending : state === "done" ? styles.isDone : state === "error" ? styles.isError : styles.isIdle;
return (
<footer
className={`${styles.fn} ${stateClass}${loop ? ` ${styles.isDemo}` : ""}`}
style={vars}
role="contentinfo"
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.title}>{title}</p>
<p className={styles.desc}>{desc}</p>
<form className={styles.form} noValidate onSubmit={submit}>
<label className={styles.label} htmlFor="fn-mail">{label}</label>
<div className={styles.row}>
<input
className={styles.input}
id="fn-mail"
type="email"
name="email"
autoComplete="email"
placeholder={placeholder}
value={value}
onChange={onInput}
/>
<button className={styles.send} type="submit">
<span className={styles.sendT}>{sendText}</span>
<span className={styles.icons} aria-hidden="true">
<span className={`${styles.ic} ${styles.icPlane}`}><Plane /></span>
<span className={`${styles.ic} ${styles.icLoad}`}><span className={styles.spin} /></span>
<span className={`${styles.ic} ${styles.icDone}`}><CheckCircle /></span>
</span>
</button>
</div>
<span className={styles.bar} aria-hidden="true" />
</form>
<p className={styles.msg} role="status" aria-live="polite">
<span className={`${styles.m} ${styles.mIdle}`}>{idleText}</span>
<span className={`${styles.m} ${styles.mSend}`}>{sendingText}</span>
<span className={`${styles.m} ${styles.mDone}`}>{doneText}</span>
<span className={`${styles.m} ${styles.mErr}`}>{error}</span>
</p>
<p className={styles.legal}>{legal}</p>
</footer>
);
}02 Newsletter-integrated footer — Measured area 3.778% · moving frames 10/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


