09Showcase footer
이메일을 넣고 구독 버튼을 누르면 실제로 형식을 검사해 통과하면 체크가 그려지고, 틀리면 입력칸이 좌우로 짧게 흔들립니다.
Best for
submitstroke-dashoffsetclick

<main class="stage">
<footer class="pf2">
<div class="pf2__top">
<p class="pf2__heading">Get new project updates by email</p>
<form class="pf2__form" novalidate>
<input class="pf2__input" type="email" placeholder="[email protected]" aria-label="Email">
<button class="pf2__submit" type="submit">
<span class="pf2__submit-label">Subscribe</span>
<svg class="pf2__check" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</button>
</form>
<p class="pf2__status" role="status" aria-live="polite"></p>
</div>
<div class="pf2__cols">
<div class="pf2__col"><p class="pf2__col-title">Showcase</p><a href="#">Projects</a><a href="#">Resume</a></div>
<div class="pf2__col"><p class="pf2__col-title">Contact</p><a href="#">Email</a><a href="#">Contact form</a></div>
<div class="pf2__col"><p class="pf2__col-title">Follow</p><a href="#">GitHub</a><a href="#">LinkedIn</a></div>
</div>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 구독 버튼·체크 전환
$easing: $ease-pop;
$color: $subject-blue; // 구독 버튼 색
@include stage($stage-ink);
.pf2 { width: min(420px, 95vw); }
.pf2__heading { margin: 0 0 $sp-2; font-weight: 700; font-size: clamp(11px, 3.2vw, 12px); line-height: 1.4; font-family: inherit; color: $subject-cream; }
.pf2__form { display: flex; gap: $sp-1; }
.pf2__input {
flex: 1 1 auto;
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
border: 1px solid rgba(255, 247, 230, .28);
background: rgba(255, 247, 230, .06);
color: $subject-cream;
font-weight: 500; font-size: 11px; line-height: 1.6; font-family: inherit;
}
.pf2__input.is-shake { animation: pf2-shake $dur-quick $easing; }
@keyframes pf2-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.pf2__submit {
position: relative;
flex: 0 0 auto;
display: inline-flex; align-items: center; gap: 4px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-sm;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
animation: pf2-glow 2s $easing infinite;
}
@keyframes pf2-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .45); }
50% { box-shadow: 0 0 0 6px rgba(47, 109, 246, 0); }
}
.pf2__check {
width: 11px; height: 11px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset $duration $easing;
}
.pf2__form.is-done .pf2__check { stroke-dashoffset: 0; }
.pf2__status { min-height: 14px; margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba(255, 247, 230, .6); }
.pf2__cols { display: flex; gap: $sp-5; margin-top: $sp-4; flex-wrap: wrap; }
.pf2__col { display: flex; flex-direction: column; gap: 4px; }
.pf2__col-title { margin: 0 0 $sp-1; font-weight: 700; font-size: 9px; line-height: 1.3; font-family: inherit; letter-spacing: .08em; color: rgba($subject-cream, .72); } // 잉크 면 위 9.18:1
.pf2__col a { font-weight: 500; font-size: 10px; line-height: 1.6; font-family: inherit; color: rgba(255, 247, 230, .75); text-decoration: none; }
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.pf2__cols { display: none; }
.pf2__heading { margin-bottom: $sp-1; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pf2__input.is-shake { animation: none !important; }
.pf2__submit { animation: none !important; }
.pf2__check { 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);
}
.pf2 {
width: min(420px, 95vw);
}
.pf2__heading {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
}
.pf2__form {
display: flex;
gap: 4px;
}
.pf2__input {
flex: 1 1 auto;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-weight: 500;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
}
.pf2__input.is-shake {
animation: pf2-shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes pf2-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-6px);
}
75% {
transform: translateX(6px);
}
}
.pf2__submit {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
animation: pf2-glow 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pf2-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45);
}
50% {
box-shadow: 0 0 0 6px rgba(47, 109, 246, 0);
}
}
.pf2__check {
width: 11px;
height: 11px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pf2__form.is-done .pf2__check {
stroke-dashoffset: 0;
}
.pf2__status {
min-height: 14px;
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(255, 247, 230, 0.6);
}
.pf2__cols {
display: flex;
gap: 20px;
margin-top: 16px;
flex-wrap: wrap;
}
.pf2__col {
display: flex;
flex-direction: column;
gap: 4px;
}
.pf2__col-title {
margin: 0 0 4px;
font-weight: 700;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
letter-spacing: 0.08em;
color: rgba(255, 247, 230, 0.72);
}
.pf2__col a {
font-weight: 500;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
color: rgba(255, 247, 230, 0.75);
text-decoration: none;
}
@media (max-width: 340px), (max-height: 220px) {
.pf2__cols {
display: none;
}
.pf2__heading {
margin-bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pf2__input.is-shake {
animation: none !important;
}
.pf2__submit {
animation: none !important;
}
.pf2__check {
transition: none;
}
}// 실제 동작: 제출 시 실제로 Email 형식을 검사한다. 통과하면 체크가 그려지고, 틀리면 입력칸이 흔들린다.
var form = document.querySelector('.pf2__form');
var input = document.querySelector('.pf2__input');
var status = document.querySelector('.pf2__status');
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^\S+@\S+\.\S+$/.test(input.value);
if (ok) {
form.classList.add('is-done');
status.textContent = 'Subscribed — new projects will land in your inbox.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = 'Please enter a valid email address.';
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectFooter.module.scss";
export type ProjectFooterProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ProjectFooter({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#0a4fe8" }: ProjectFooterProps) {
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [shake, setShake] = useState(false);
const [status, setStatus] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 제출 시 실제로 이메일 형식을 검사한다. 통과하면 체크가 그려지고, 틀리면 입력칸이 흔들린다.
function onSubmit(e: any) {
e.preventDefault();
if (/^\S+@\S+\.\S+$/.test(email)) {
setDone(true);
setStatus("구독 완료 — 새 프로젝트를 메일로 보내드립니다.");
} else {
setShake(false);
requestAnimationFrame(() => setShake(true));
setStatus("올바른 이메일을 입력해 주세요.");
}
}
return (
<footer className={styles.pf} style={vars}>
<div className={styles.top}>
<p className={styles.heading}>새 프로젝트 소식을 메일로 받아보세요</p>
<form className={`${styles.form} ${done ? styles.done : ""}`} noValidate onSubmit={onSubmit}>
<input
className={`${styles.input} ${shake ? styles.shake : ""}`}
type="email"
placeholder="[email protected]"
aria-label="이메일"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
onAnimationEnd={() => setShake(false)}
/>
<button type="submit" className={styles.submit}>
<span>구독</span>
<svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5" /></svg>
</button>
</form>
<p className={styles.status} role="status" aria-live="polite">{status}</p>
</div>
<div className={styles.cols}>
<div className={styles.col}><p className={styles.colTitle}>쇼케이스</p><a href="#">프로젝트</a><a href="#">이력서</a></div>
<div className={styles.col}><p className={styles.colTitle}>연락</p><a href="#">이메일</a><a href="#">문의 폼</a></div>
<div className={styles.col}><p className={styles.colTitle}>팔로우</p><a href="#">깃허브</a><a href="#">링크드인</a></div>
</div>
</footer>
);
}09 Showcase footer — Measured area 0.583% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · 9 Project Showcase Section Templates — Copy-Paste
The post this demo lives in
9 Project Showcase Section Templates — Copy-PasteA project showcase section template bundles the blocks a portfolio page runs through — hero, grid, case study, contact f See all demos in this post


