07Contact form
이름·이메일을 넣고 제출하면 실제로 값을 검사해, 빠진 칸은 흔들리며 포커스를 받고 통과하면 제출 버튼에 체크가 그려집니다.
Best for
focusstroke-dashoffsetsubmit

<main class="stage">
<form class="cf" novalidate>
<h2 class="cf__title">Contact</h2>
<label class="cf__field">
<span class="cf__label-text">Name</span>
<input class="cf__input" name="name" type="text" placeholder="Jane Doe">
</label>
<label class="cf__field">
<span class="cf__label-text">Email</span>
<input class="cf__input" name="email" type="email" placeholder="[email protected]">
</label>
<button class="cf__submit" type="submit">
<span class="cf__submit-label">Send message</span>
<svg class="cf__check" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</button>
<p class="cf__status" role="status" aria-live="polite"></p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 제출 버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // 제출 버튼·체크 색
@include stage($stage-orange);
.cf {
width: min(320px, 92vw);
border-radius: $r-card;
padding: $sp-4;
box-sizing: border-box;
background: rgba(255, 255, 255, .95);
}
.cf__title { margin: 0 0 $sp-3; font-weight: 800; font-size: clamp(13px, 3.8vw, 15px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.cf__field { display: block; margin: 0 0 $sp-2; }
.cf__label-text { display: block; margin: 0 0 4px; font-weight: 700; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); } // 크림 면 위 7.08:1
.cf__input {
width: 100%;
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
border: 1px solid rgba(23, 20, 26, .18);
font-weight: 500; font-size: 11px; line-height: 1.6; font-family: inherit;
color: $subject-ink;
}
.cf__input.is-shake { animation: cf-shake $dur-quick $easing; }
@keyframes cf-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.cf__submit {
position: relative;
display: inline-flex; align-items: center; gap: $sp-1;
margin-top: $sp-1;
padding: $sp-1 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
transition: background $duration $easing;
animation: cf-glow 2s $easing infinite;
}
// ── 제출 버튼 글로우가 계속 반복되는 장식(항상 재생) ──────────────
@keyframes cf-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); }
}
.cf.is-done .cf__submit { animation: none; }
.cf__check {
width: 12px; height: 12px;
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;
}
.cf.is-done .cf__submit { background: #51a474; }
.cf.is-done .cf__check { stroke-dashoffset: 0; }
.cf__status { min-height: 14px; margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba($subject-ink, .72); } // 스캔 땐 빈 칸이라 안 잡혔지만 제출 뒤 드러나는 같은 실패(3.97)라 같이 올렸다
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.cf { padding: $sp-3; }
.cf__title { margin-bottom: $sp-2; }
.cf__field { margin-bottom: $sp-1; }
.cf__label-text { margin-bottom: 0; }
.cf__input { padding: $sp-1 $sp-2; line-height: 1.3; }
.cf__submit { padding: $sp-1 $sp-3; line-height: 1.3; margin-top: 0; }
.cf__status { min-height: 12px; margin-top: 0; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cf__input.is-shake { animation: none !important; }
.cf__submit { animation: none !important; transition: none; }
.cf__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
width: min(320px, 92vw);
border-radius: 16px;
padding: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.95);
}
.cf__title {
margin: 0 0 12px;
font-weight: 800;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.cf__field {
display: block;
margin: 0 0 8px;
}
.cf__label-text {
display: block;
margin: 0 0 4px;
font-weight: 700;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.cf__input {
width: 100%;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(23, 20, 26, 0.18);
font-weight: 500;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #17141a;
}
.cf__input.is-shake {
animation: cf-shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes cf-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-6px);
}
75% {
transform: translateX(6px);
}
}
.cf__submit {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
padding: 4px 16px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
animation: cf-glow 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cf-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);
}
}
.cf.is-done .cf__submit {
animation: none;
}
.cf__check {
width: 12px;
height: 12px;
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);
}
.cf.is-done .cf__submit {
background: #51a474;
}
.cf.is-done .cf__check {
stroke-dashoffset: 0;
}
.cf__status {
min-height: 14px;
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.cf {
padding: 12px;
}
.cf__title {
margin-bottom: 8px;
}
.cf__field {
margin-bottom: 4px;
}
.cf__label-text {
margin-bottom: 0;
}
.cf__input {
padding: 4px 8px;
line-height: 1.3;
}
.cf__submit {
padding: 4px 12px;
line-height: 1.3;
margin-top: 0;
}
.cf__status {
min-height: 12px;
margin-top: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__input.is-shake {
animation: none !important;
}
.cf__submit {
animation: none !important;
transition: none;
}
.cf__check {
transition: none;
}
}// 실제 동작: 제출 시 Name·이메일을 실제로 검사한다. 빠지면 그 칸이 흔들리며 포커스를 받고, 통과하면 체크가 그려진다.
var form = document.querySelector('.cf');
var name = form.querySelector('[name="name"]');
var email = form.querySelector('[name="email"]');
var status = form.querySelector('.cf__status');
function shakeField(el) {
el.classList.remove('is-shake');
void el.offsetWidth;
el.classList.add('is-shake');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var hasName = name.value.trim().length > 0;
var isEmailValid = /^\S+@\S+\.\S+$/.test(email.value);
if (!hasName) {
shakeField(name);
name.focus({ preventScroll: true });
status.textContent = 'Please enter your name.';
return;
}
if (!isEmailValid) {
shakeField(email);
email.focus({ preventScroll: true });
status.textContent = 'Please enter a valid email address.';
return;
}
form.classList.add('is-done');
status.textContent = 'Your message has been sent.';
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectContactForm.module.scss";
export type ProjectContactFormProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ProjectContactForm({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#0a4fe8" }: ProjectContactFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [status, setStatus] = useState("");
const [shakeField, setShakeField] = useState<"name" | "email" | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 제출 시 이름·이메일을 실제로 검사한다. 빠지면 그 칸이 흔들리며 포커스를 받는다({ preventScroll: true } — 홈 목록 iframe 안에서 강제 스크롤을 막는다).
function onSubmit(e: any) {
e.preventDefault();
if (!name.trim()) {
setShakeField("name");
nameRef.current?.focus({ preventScroll: true });
setStatus("이름을 입력해 주세요.");
return;
}
if (!/^\S+@\S+\.\S+$/.test(email)) {
setShakeField("email");
emailRef.current?.focus({ preventScroll: true });
setStatus("올바른 이메일을 입력해 주세요.");
return;
}
setDone(true);
setStatus("메시지를 보냈습니다.");
}
return (
<form className={styles.cf} style={vars} noValidate onSubmit={onSubmit}>
<h2 className={styles.title}>연락하기</h2>
<label className={styles.field}>
<span className={styles.labelText}>이름</span>
<input
ref={nameRef}
className={`${styles.input} ${shakeField === "name" ? styles.shake : ""}`}
type="text"
placeholder="홍길동"
value={name}
onChange={(e: any) => setName(e.target.value)}
onAnimationEnd={() => setShakeField(null)}
/>
</label>
<label className={styles.field}>
<span className={styles.labelText}>이메일</span>
<input
ref={emailRef}
className={`${styles.input} ${shakeField === "email" ? styles.shake : ""}`}
type="email"
placeholder="[email protected]"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
onAnimationEnd={() => setShakeField(null)}
/>
</label>
<button type="submit" className={`${styles.submit} ${done ? styles.done : ""}`}>
<span>메시지 보내기</span>
<svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5" /></svg>
</button>
<p className={styles.status} role="status" aria-live="polite">{status}</p>
</form>
);
}07 Contact form — Measured area 0.917% · 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


