05Newsletter signup band
이메일을 넣고 구독하면 정규식으로 실제 형식을 검사해 통과하면 체크가 stroke-dashoffset 으로 그려지고, 틀리면 입력칸이 흔들립니다.
Best for
정규식stroke-dashoffsetsubmit

<main class="stage">
<form class="nl" novalidate>
<span class="nl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="m224 56l-96 88l-96-88Z" opacity=".2"/><path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z"/></g></svg></span>
<p class="nl__title">Get new component updates</p>
<div class="nl__row">
<label for="nl-email" class="sr-only">Email address</label>
<input id="nl-email" class="nl__input" type="email" placeholder="[email protected]" autocomplete="email">
<button type="submit" class="nl__submit">
<span class="nl__submit-text">Subscribe</span>
<svg class="nl__check" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><polyline points="4 12 9 17 20 6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<p class="nl__msg" role="status"></p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-orange);
.nl { width: min(320px, 94vw); text-align: center; background: $stage-paper; border-radius: $r-card; padding: $sp-5 $sp-4; box-shadow: $shadow-raised; }
.nl__ic { display: grid; place-items: center; margin: 0 auto $sp-2; color: $color; animation: nl-bob $duration $easing infinite; }
@keyframes nl-bob {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-4px) rotate(-4deg); }
}
.nl__title { margin: 0 0 $sp-3; font-weight: 800; font-size: 13px; line-height: 1.3; font-family: inherit; color: $subject-ink; }
.nl__row { display: flex; gap: $sp-2; }
.nl__input {
flex: 1 1 auto; min-width: 0;
padding: $sp-2 $sp-3;
border: 1.5px solid rgba(23, 20, 26, .16);
border-radius: $r-control;
font-weight: 500; font-size: 12px; line-height: 1.2;
color: $subject-ink;
}
.nl__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
.nl__input.is-shake { animation: nl-shake .32s $ease-out; }
@keyframes nl-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.nl__submit {
flex: 0 0 auto; display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-3;
border: 0; border-radius: $r-control;
background: $color; color: $subject-ink;
font-weight: 800; font-size: 12px; line-height: 1;
cursor: pointer;
}
.nl__check {
color: $subject-ink;
stroke-dasharray: 28; stroke-dashoffset: 28;
transition: stroke-dashoffset $duration $easing;
}
.nl.is-done .nl__check { stroke-dashoffset: 0; }
.nl.is-done .nl__submit { background: #50a26e; color: $stage-paper; }
.nl__msg { margin: $sp-2 0 0; min-height: 14px; font-size: 11px; line-height: 1.3; color: rgba(23, 20, 26, .6); font-family: inherit; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.nl__ic, .nl__input.is-shake, .nl__check { animation: none !important; transition: none !important; }
}
// ── 반응형(320×200) ──────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.nl { padding: $sp-2 $sp-2; width: min(280px, 92vw); }
.nl__ic { margin: 0 auto $sp-1; }
.nl__ic svg { width: 16px; height: 16px; }
.nl__title { margin: 0 0 $sp-1; font-size: 11px; }
.nl__row { gap: 4px; }
.nl__input { padding: 4px $sp-2; font-size: 10px; }
.nl__submit { padding: 4px $sp-2; font-size: 10px; }
.nl__msg { margin-top: $sp-1; min-height: 0; font-size: 9px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.nl {
width: min(320px, 94vw);
text-align: center;
background: #ffffff;
border-radius: 16px;
padding: 20px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.nl__ic {
display: grid;
place-items: center;
margin: 0 auto 8px;
color: #4cd4a6;
animation: nl-bob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes nl-bob {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-4px) rotate(-4deg);
}
}
.nl__title {
margin: 0 0 12px;
font-weight: 800;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.nl__row {
display: flex;
gap: 8px;
}
.nl__input {
flex: 1 1 auto;
min-width: 0;
padding: 8px 12px;
border: 1.5px solid rgba(23, 20, 26, 0.16);
border-radius: 12px;
font-weight: 500;
font-size: 12px;
line-height: 1.2;
color: #17141a;
}
.nl__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.nl__input.is-shake {
animation: nl-shake 0.32s ease-out;
}
@keyframes nl-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
.nl__submit {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 0;
border-radius: 12px;
background: #4cd4a6;
color: #17141a;
font-weight: 800;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.nl__check {
color: #17141a;
stroke-dasharray: 28;
stroke-dashoffset: 28;
transition: stroke-dashoffset 2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nl.is-done .nl__check {
stroke-dashoffset: 0;
}
.nl.is-done .nl__submit {
background: #50a26e;
color: #ffffff;
}
.nl__msg {
margin: 8px 0 0;
min-height: 14px;
font-size: 11px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.6);
font-family: inherit;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.nl__ic, .nl__input.is-shake, .nl__check {
animation: none !important;
transition: none !important;
}
}
@media (max-width: 340px), (max-height: 220px) {
.nl {
padding: 8px 8px;
width: min(280px, 92vw);
}
.nl__ic {
margin: 0 auto 4px;
}
.nl__ic svg {
width: 16px;
height: 16px;
}
.nl__title {
margin: 0 0 4px;
font-size: 11px;
}
.nl__row {
gap: 4px;
}
.nl__input {
padding: 4px 8px;
font-size: 10px;
}
.nl__submit {
padding: 4px 8px;
font-size: 10px;
}
.nl__msg {
margin-top: 4px;
min-height: 0;
font-size: 9px;
}
}// 실제 동작: Email 형식을 정규식으로 검사해 통과하면 버튼 글자가 "Subscribed"로 바뀌며 체크가 stroke-dashoffset 으로 실제로 그려지고, 틀리면 입력칸이 좌우로 짧게 흔들린다.
var form = document.querySelector('.nl');
var input = document.getElementById('nl-email');
var btn = document.querySelector('.nl__submit');
var msg = document.querySelector('.nl__msg');
var RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
form.addEventListener('submit', function (e) {
e.preventDefault();
if (RE.test(input.value)) {
form.classList.add('is-done');
btn.querySelector('.nl__submit-text').textContent = 'Subscribed';
msg.textContent = input.value + ' — we sent a confirmation email.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
msg.textContent = 'Please check the email format again.';
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NewsletterBand.module.scss";
export type NewsletterBandProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function NewsletterBand({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#4cd4a6" }: NewsletterBandProps) {
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [shake, setShake] = useState(false);
const [msg, setMsg] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onSubmit(e: any) {
e.preventDefault();
if (RE.test(email)) {
setDone(true);
setMsg(`${email} 주소로 확인 메일을 보냈어요.`);
} else {
setShake(false);
requestAnimationFrame(() => setShake(true));
setMsg("이메일 형식을 다시 확인해 주세요.");
}
}
return (
<form className={styles.nl} style={vars} onSubmit={onSubmit} noValidate>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="22" height="22">
<g fill="currentColor">
<path d="m224 56l-96 88l-96-88Z" opacity=".2" />
<path d="M224 48H32a8 8 0 0 0-8 8v136a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a8 8 0 0 0-8-8m-20.57 16L128 133.15L52.57 64ZM216 192H40V74.19l82.59 75.71a8 8 0 0 0 10.82 0L216 74.19z" />
</g>
</svg>
</span>
<p className={styles.title}>새 컴포넌트 소식 받기</p>
<div className={styles.row}>
<label htmlFor="nl-email-r" className={styles.srOnly}>이메일 주소</label>
<input
id="nl-email-r"
className={`${styles.input} ${shake ? styles.shake : ""}`}
type="email"
placeholder="[email protected]"
autoComplete="email"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
onAnimationEnd={() => setShake(false)}
/>
<button type="submit" className={`${styles.submit} ${done ? styles.done : ""}`}>
<span>{done ? "구독 완료" : "구독하기"}</span>
<svg className={styles.check} viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<polyline points="4 12 9 17 20 6" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<p className={styles.msg} role="status">{msg}</p>
</form>
);
}05 Newsletter signup band — Measured area 0.22% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · 9 Landing Page Sections That Build Trust
The post this demo lives in
9 Landing Page Sections That Build TrustLanding page sections like these are the trust-building pieces that usually end up scattered across templates. Nine go h See all demos in this post


