09Contact form (split layout)
이름·이메일·문의 내용을 다 채우고 보내면 종이비행기 아이콘이 실제로 날아가며 사라지고 전송 완료로 바뀝니다. 빈 칸은 흔들립니다.
Best for
정규식translate+opacitysubmit

<main class="stage">
<section class="cf">
<div class="cf__info">
<p class="cf__info-t">Contact us</p>
<p class="cf__info-row"><span aria-hidden="true"><svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-4-1.08a7.9 7.9 0 0 0-2.53.42L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216m40-104a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>Avg. reply in 1 business day</p>
<p class="cf__info-row"><span aria-hidden="true"><svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M128 24a80 80 0 0 0-80 80c0 72 80 128 80 128s80-56 80-128a80 80 0 0 0-80-80m0 112a32 32 0 1 1 32-32a32 32 0 0 1-32 32" opacity=".2"/><path d="M200 224h-49.46A267 267 0 0 0 174 200.25c27.45-31.57 42-64.85 42-96.25a88 88 0 0 0-176 0c0 31.4 14.51 64.68 42 96.25A267 267 0 0 0 105.46 224H56a8 8 0 0 0 0 16h144a8 8 0 0 0 0-16M56 104a72 72 0 0 1 144 0c0 57.23-55.47 105-72 118c-16.53-13-72-60.77-72-118m112 0a40 40 0 1 0-40 40a40 40 0 0 0 40-40m-64 0a24 24 0 1 1 24 24a24 24 0 0 1-24-24"/></g></svg></span>Seoul · remote-first</p>
</div>
<form class="cf__form" novalidate>
<label class="cf__field">
<input type="text" required placeholder=" ">
<span>Name</span>
</label>
<label class="cf__field">
<input type="email" required placeholder=" ">
<span>Email</span>
</label>
<label class="cf__field">
<input type="text" required placeholder=" ">
<span>Message</span>
</label>
<button type="submit" class="cf__send">
<span class="cf__send-text">Send message</span>
<svg class="cf__plane" viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><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.66"/></g></svg>
</button>
<p class="cf__msg" role="status"></p>
</form>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.cf { display: flex; width: min(420px, 94vw); border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; }
.cf__info {
flex: 0 0 38%;
background: $subject-ink;
color: $stage-paper;
padding: $sp-4;
}
.cf__info-t { margin: 0 0 $sp-3; font-weight: 800; font-size: 13px; line-height: 1.2; font-family: inherit; }
.cf__info-row { display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-2; font-size: 10px; line-height: 1.3; font-family: inherit; color: rgba(255, 255, 255, .72); }
.cf__info-row span { color: $color; flex: 0 0 auto; display: grid; place-items: center; }
.cf__form { flex: 1 1 auto; background: $stage-paper; padding: $sp-4; display: flex; flex-direction: column; gap: $sp-3; }
.cf__field { position: relative; display: block; }
.cf__field input {
width: 100%; box-sizing: border-box;
padding: $sp-2 $sp-2 4px;
border: 0; border-bottom: 1.5px solid rgba(23, 20, 26, .2);
font-weight: 500; font-size: 12px; line-height: 1.3;
color: $subject-ink;
background: transparent;
}
.cf__field input:focus-visible { outline: none; border-color: $color; }
.cf__field span {
position: absolute; left: $sp-2; top: $sp-2;
font-size: 12px; color: rgba(23, 20, 26, .45); font-family: inherit;
transform-origin: left top; // 규칙8: 라벨 좌상단 앵커 계산
transition: transform $duration $easing, color $duration $easing;
pointer-events: none;
}
.cf__field input:focus + span,
.cf__field input:not(:placeholder-shown) + span {
transform: translateY(-14px) scale(.8);
color: $color;
}
.cf__field input.is-shake { animation: cf-shake .32s $ease-out; }
@keyframes cf-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.cf__send {
display: inline-flex; align-items: center; justify-content: center; gap: $sp-1;
padding: $sp-2 $sp-3;
border: 0; border-radius: $r-control;
background: $color; color: $stage-paper;
font-weight: 800; font-size: 12px; line-height: 1;
cursor: pointer;
}
.cf__plane { transition: transform $duration $easing, opacity $duration $easing; }
.cf__form.is-sent .cf__plane { transform: translate(6px, -4px); opacity: 0; }
.cf__form.is-sent .cf__send { background: #50a26e; }
.cf__msg { margin: 0; min-height: 14px; font-size: 10px; line-height: 1.3; color: rgba(23, 20, 26, .6); font-family: inherit; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cf__field span, .cf__field input.is-shake, .cf__plane { transition: none !important; animation: none !important; }
}
// ── 반응형(320×200) ──────────────────────────────────
// 스택(column)으로 바꾸면 패널이 위아래로 쌓여 320×200 안에 못 들어간다 — 좌우 분할을 유지하고 안쪽만 줄인다.
@media (max-width: 340px), (max-height: 220px) {
.cf { width: min(290px, 92vw); }
.cf__info { padding: $sp-2; }
.cf__info-t { margin: 0 0 4px; font-size: 10px; }
.cf__info-row { margin: 0 0 $sp-1; font-size: 8px; gap: $sp-1; }
.cf__form { padding: $sp-2; gap: $sp-2; }
.cf__field input { padding: 4px $sp-1 $sp-1; font-size: 10px; }
.cf__field span { left: $sp-1; top: 4px; font-size: 10px; }
.cf__send { padding: 4px $sp-2; font-size: 10px; }
.cf__msg { min-height: 0; font-size: 8px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
display: flex;
width: min(420px, 94vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cf__info {
flex: 0 0 38%;
background: #17141a;
color: #ffffff;
padding: 16px;
}
.cf__info-t {
margin: 0 0 12px;
font-weight: 800;
font-size: 13px;
line-height: 1.2;
font-family: inherit;
}
.cf__info-row {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
font-size: 10px;
line-height: 1.3;
font-family: inherit;
color: rgba(255, 255, 255, 0.72);
}
.cf__info-row span {
color: #2f6df6;
flex: 0 0 auto;
display: grid;
place-items: center;
}
.cf__form {
flex: 1 1 auto;
background: #ffffff;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.cf__field {
position: relative;
display: block;
}
.cf__field input {
width: 100%;
box-sizing: border-box;
padding: 8px 8px 4px;
border: 0;
border-bottom: 1.5px solid rgba(23, 20, 26, 0.2);
font-weight: 500;
font-size: 12px;
line-height: 1.3;
color: #17141a;
background: transparent;
}
.cf__field input:focus-visible {
outline: none;
border-color: #2f6df6;
}
.cf__field span {
position: absolute;
left: 8px;
top: 8px;
font-size: 12px;
color: rgba(23, 20, 26, 0.45);
font-family: inherit;
transform-origin: left top;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.cf__field input:focus + span,
.cf__field input:not(:placeholder-shown) + span {
transform: translateY(-14px) scale(0.8);
color: #2f6df6;
}
.cf__field input.is-shake {
animation: cf-shake 0.32s ease-out;
}
@keyframes cf-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.cf__send {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
font-weight: 800;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.cf__plane {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__form.is-sent .cf__plane {
transform: translate(6px, -4px);
opacity: 0;
}
.cf__form.is-sent .cf__send {
background: #50a26e;
}
.cf__msg {
margin: 0;
min-height: 14px;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.6);
font-family: inherit;
}
@media (prefers-reduced-motion: reduce) {
.cf__field span, .cf__field input.is-shake, .cf__plane {
transition: none !important;
animation: none !important;
}
}
@media (max-width: 340px), (max-height: 220px) {
.cf {
width: min(290px, 92vw);
}
.cf__info {
padding: 8px;
}
.cf__info-t {
margin: 0 0 4px;
font-size: 10px;
}
.cf__info-row {
margin: 0 0 4px;
font-size: 8px;
gap: 4px;
}
.cf__form {
padding: 8px;
gap: 8px;
}
.cf__field input {
padding: 4px 4px 4px;
font-size: 10px;
}
.cf__field span {
left: 4px;
top: 4px;
font-size: 10px;
}
.cf__send {
padding: 4px 8px;
font-size: 10px;
}
.cf__msg {
min-height: 0;
font-size: 8px;
}
}// 실제 동작: Name·Email·문의 내용을 다 채우고 보내면 종이비행기 아이콘이 실제로 translateX+opacity 로 날아가며 "Sent"로 바뀐다. 빈 칸이 있으면 그 칸이 흔들린다.
var form = document.querySelector('.cf__form');
var msg = document.querySelector('.cf__msg');
form.addEventListener('submit', function (e) {
e.preventDefault();
var inputs = form.querySelectorAll('input');
var ok = true;
inputs.forEach(function (inp) {
if (!inp.value.trim() || (inp.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inp.value))) {
ok = false;
inp.classList.remove('is-shake'); void inp.offsetWidth; inp.classList.add('is-shake');
}
});
if (ok) {
form.classList.add('is-sent');
form.querySelector('.cf__send-text').textContent = 'Sent';
msg.textContent = 'We got your message. We will reply within 1 business day.';
} else {
msg.textContent = 'Please fill in every field.';
}
});
// .is-demo 자동 Play: 입력칸을 하나씩 돌아가며 focus 해 라벨이 위로 떠오르는 모습을 보여준다. preventScroll 로 부모 페이지를 끌어당기지 않는다(BD18).
var fields = form.querySelectorAll('input');
var fi = 0;
setInterval(function () {
fields[fi].blur();
fi = (fi + 1) % fields.length;
fields[fi].focus({ preventScroll: true });
}, 900);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ContactFormSplit.module.scss";
export type ContactFormSplitProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function ContactFormSplit({ duration = 220, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: ContactFormSplitProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [sent, setSent] = useState(false);
const [shake, setShake] = useState({ name: false, email: false, message: false });
const [msg, setMsg] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fieldRefs = useRef<(HTMLInputElement | null)[]>([]);
// 자동 재생: 입력칸을 하나씩 돌아가며 focus 해 라벨이 떠오르는 모습을 보여준다. preventScroll 로 부모 페이지를 끌어당기지 않는다(BD18).
useEffect(() => {
let fi = 0;
const id = setInterval(() => {
const refs = fieldRefs.current || [];
refs[fi]?.blur();
fi = (fi + 1) % refs.length;
refs[fi]?.focus({ preventScroll: true });
}, 900);
return () => clearInterval(id);
}, []);
function onSubmit(e: any) {
e.preventDefault();
const bad = { name: !name.trim(), email: !RE.test(email), message: !message.trim() };
if (bad.name || bad.email || bad.message) {
setShake({ name: false, email: false, message: false });
requestAnimationFrame(() => setShake(bad));
setMsg("빈 칸을 모두 채워 주세요.");
return;
}
setSent(true);
setMsg("문의가 접수됐어요. 1영업일 안에 답장 드려요.");
}
return (
<section className={styles.cf} style={vars}>
<div className={styles.info}>
<p className={styles.infoT}>상담 문의</p>
<p className={styles.infoRow}><span aria-hidden="true">⏱</span>평균 응답 1영업일</p>
<p className={styles.infoRow}><span aria-hidden="true">📍</span>서울 · 원격 우선</p>
</div>
<form className={`${styles.form} ${sent ? styles.sent : ""}`} onSubmit={onSubmit} noValidate>
<label className={styles.field}>
<input type="text" placeholder=" " value={name} onChange={(e: any) => setName(e.target.value)}
ref={(el: HTMLInputElement | null) => { const r = fieldRefs.current; if (r) r[0] = el; }}
className={shake.name ? styles.shake : ""} onAnimationEnd={() => setShake((s) => ({ ...s, name: false }))} />
<span>이름</span>
</label>
<label className={styles.field}>
<input type="email" placeholder=" " value={email} onChange={(e: any) => setEmail(e.target.value)}
ref={(el: HTMLInputElement | null) => { const r = fieldRefs.current; if (r) r[1] = el; }}
className={shake.email ? styles.shake : ""} onAnimationEnd={() => setShake((s) => ({ ...s, email: false }))} />
<span>이메일</span>
</label>
<label className={styles.field}>
<input type="text" placeholder=" " value={message} onChange={(e: any) => setMessage(e.target.value)}
ref={(el: HTMLInputElement | null) => { const r = fieldRefs.current; if (r) r[2] = el; }}
className={shake.message ? styles.shake : ""} onAnimationEnd={() => setShake((s) => ({ ...s, message: false }))} />
<span>문의 내용</span>
</label>
<button type="submit" className={styles.send}>
<span>{sent ? "전송 완료" : "문의 보내기"}</span>
<svg className={styles.plane} viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<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.66" />
</g>
</svg>
</button>
<p className={styles.msg} role="status">{msg}</p>
</form>
</section>
);
}09 Contact form (split layout) — Measured area 1.571% · moving frames 7/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


