08送信時に揺れて知らせる欄
無効なまま送信すると、空の欄が左右に揺れ、ラベルがエラー色に熱く点灯します。直してもう一度押すと、今度は全ラベルが確定色に変わります。
使いどころ複数の欄を一度に検査する最終確認
translateXaria-invalidcubic-bezier

<main class="stage">
<form class="sk is-demo" id="sk" novalidate>
<div class="sk__row" data-row="1">
<label class="sk__label" for="sk-a">メール</label>
<span class="sk__box">
<input class="sk__input" id="sk-a" type="email" autocomplete="off" required>
<span class="sk__ghost" aria-hidden="true">[email protected]</span>
</span>
</div>
<div class="sk__row" data-row="2">
<label class="sk__label" for="sk-b">連絡先</label>
<span class="sk__box">
<input class="sk__input" id="sk-b" type="tel" autocomplete="off" required>
<span class="sk__ghost" aria-hidden="true">010-1234-5678</span>
</span>
</div>
<button class="sk__btn" type="submit">登録する</button>
<p class="sk__say" id="sk-say" role="status" aria-live="polite">両方の欄を入力してください</p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: cubic-bezier(.36, .07, .19, .97);
$color: $subject-blue;
@include stage($stage-paper);
$ok: #479076;
.sk {
box-sizing: border-box;
width: min(300px, 86vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__row {
display: flex;
align-items: center;
gap: $sp-2;
}
.sk__label {
flex: 0 0 auto;
width: 48px;
color: rgba(23, 20, 26, .72);
font-size: 11px; line-height: 14px; font-weight: 700;
transition: color $dur-quick $ease-out;
}
.sk__row.is-bad .sk__label { color: $error; }
.sk__row.is-ok .sk__label { color: $ok; }
.sk__box {
position: relative;
flex: 1 1 auto;
display: block;
}
.sk__ghost {
position: absolute;
left: 14px; top: 0;
color: $subject-ink;
font-size: 13px; line-height: 34px; font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.sk__input {
box-sizing: border-box;
display: block;
width: 100%; height: 34px;
padding: 0 $sp-3;
border: 2px solid rgba(23, 20, 26, .2);
border-radius: $r-control;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 13px; line-height: 30px; font-weight: 700;
transition: border-color $dur-quick $ease-out;
}
.sk__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sk__row.is-bad .sk__input { border-color: $error; background: $error-soft; }
.sk__row.is-ok .sk__input { border-color: $ok; }
// 비어 있는 칸만 좌우로 흔든다 — 한 번만 돌고 멈춘다
.sk__row.is-bad {
animation-name: sk-shake;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
.sk__btn {
height: 34px;
border: 0;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 13px; line-height: 34px; font-weight: 700;
cursor: pointer;
transition: transform $dur-instant $ease-out;
}
.sk__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sk__btn:active { transform: scale(.97); }
.sk__say {
// 흔들리는 칸이 눈으로 말해 주는 것을 낭독기에도 같은 뜻으로 보낸다
position: absolute;
width: 1px; height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~24% 빈 폼 → 26% 버튼 눌림 → 28·30% 두 줄이 차례로 흔들리며 오류로 켜짐
// → 56% 위 칸 채움 · 64% 아래 칸 채움(두 줄 다 확정 색) → 84·88% 흩어서 복귀.
// 흔들림은 translateX 라 레이아웃을 다시 계산하지 않는다.
.sk.is-demo .sk__row {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] { animation-name: sk-row-1; }
.sk.is-demo .sk__row[data-row="2"] { animation-name: sk-row-2; }
.sk.is-demo .sk__label,
.sk.is-demo .sk__input {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__label { animation-name: sk-lb-1; }
.sk.is-demo .sk__row[data-row="2"] .sk__label { animation-name: sk-lb-2; }
.sk.is-demo .sk__row[data-row="1"] .sk__input { animation-name: sk-in-1; }
.sk.is-demo .sk__row[data-row="2"] .sk__input { animation-name: sk-in-2; }
.sk.is-demo .sk__ghost {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__ghost { animation-name: sk-gh-1; }
.sk.is-demo .sk__row[data-row="2"] .sk__ghost { animation-name: sk-gh-2; }
.sk.is-demo .sk__btn {
transition: none;
animation-name: sk-btn;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes sk-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(2px); }
}
@keyframes sk-row-1 {
0% { transform: translateX(0); }
28% { transform: translateX(0); }
31% { transform: translateX(-6px); }
34% { transform: translateX(6px); }
37% { transform: translateX(-4px); }
40% { transform: translateX(2px); }
43% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes sk-row-2 {
0% { transform: translateX(0); }
30% { transform: translateX(0); }
33% { transform: translateX(-6px); }
36% { transform: translateX(6px); }
39% { transform: translateX(-4px); }
42% { transform: translateX(2px); }
45% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes sk-lb-1 {
0% { color: rgba(23, 20, 26, .72); }
28% { color: $error; }
56% { color: $ok; }
84% { color: rgba(23, 20, 26, .72); }
100% { color: rgba(23, 20, 26, .72); }
}
@keyframes sk-lb-2 {
0% { color: rgba(23, 20, 26, .72); }
30% { color: $error; }
64% { color: $ok; }
88% { color: rgba(23, 20, 26, .72); }
100% { color: rgba(23, 20, 26, .72); }
}
@keyframes sk-in-1 {
0% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
28% { border-color: $error; background: $error-soft; }
56% { border-color: $ok; background: $stage-paper; }
84% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
100% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
}
@keyframes sk-in-2 {
0% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
30% { border-color: $error; background: $error-soft; }
64% { border-color: $ok; background: $stage-paper; }
88% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
100% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
}
@keyframes sk-gh-1 {
0% { opacity: 0; }
56% { opacity: 1; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sk-gh-2 {
0% { opacity: 0; }
64% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sk-btn {
0% { transform: scale(1); }
24% { transform: scale(1); }
26% { transform: scale(.97); }
30% { transform: scale(1); }
100% { transform: scale(1); }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.sk { gap: $sp-1; }
.sk__label { width: 40px; }
.sk__input, .sk__btn { height: 32px; }
.sk__ghost { line-height: 32px; }
.sk__btn { line-height: 32px; }
.sk__input { line-height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.sk.is-demo .sk__row,
.sk.is-demo .sk__label,
.sk.is-demo .sk__input,
.sk.is-demo .sk__btn,
.sk.is-demo .sk__ghost,
.sk__row.is-bad { animation: none !important; }
.sk.is-demo .sk__label { color: $error; }
.sk.is-demo .sk__input { border-color: $error; background: $error-soft; }
.sk__label, .sk__input, .sk__btn { transition: none; }
}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);
}
.sk {
box-sizing: border-box;
width: min(300px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__row {
display: flex;
align-items: center;
gap: 8px;
}
.sk__label {
flex: 0 0 auto;
width: 48px;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
line-height: 14px;
font-weight: 700;
transition: color 220ms ease-out;
}
.sk__row.is-bad .sk__label {
color: #c2361a;
}
.sk__row.is-ok .sk__label {
color: #479076;
}
.sk__box {
position: relative;
flex: 1 1 auto;
display: block;
}
.sk__ghost {
position: absolute;
left: 14px;
top: 0;
color: #17141a;
font-size: 13px;
line-height: 34px;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.sk__input {
box-sizing: border-box;
display: block;
width: 100%;
height: 34px;
padding: 0 12px;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 30px;
font-weight: 700;
transition: border-color 220ms ease-out;
}
.sk__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sk__row.is-bad .sk__input {
border-color: #c2361a;
background: #ffe0d8;
}
.sk__row.is-ok .sk__input {
border-color: #479076;
}
.sk__row.is-bad {
animation-name: sk-shake;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97);
animation-iteration-count: 1;
}
.sk__btn {
height: 34px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 34px;
font-weight: 700;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sk__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sk__btn:active {
transform: scale(0.97);
}
.sk__say {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sk.is-demo .sk__row {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] {
animation-name: sk-row-1;
}
.sk.is-demo .sk__row[data-row="2"] {
animation-name: sk-row-2;
}
.sk.is-demo .sk__label,
.sk.is-demo .sk__input {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__label {
animation-name: sk-lb-1;
}
.sk.is-demo .sk__row[data-row="2"] .sk__label {
animation-name: sk-lb-2;
}
.sk.is-demo .sk__row[data-row="1"] .sk__input {
animation-name: sk-in-1;
}
.sk.is-demo .sk__row[data-row="2"] .sk__input {
animation-name: sk-in-2;
}
.sk.is-demo .sk__ghost {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__ghost {
animation-name: sk-gh-1;
}
.sk.is-demo .sk__row[data-row="2"] .sk__ghost {
animation-name: sk-gh-2;
}
.sk.is-demo .sk__btn {
transition: none;
animation-name: sk-btn;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes sk-shake {
0%, 100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(2px);
}
}
@keyframes sk-row-1 {
0% {
transform: translateX(0);
}
28% {
transform: translateX(0);
}
31% {
transform: translateX(-6px);
}
34% {
transform: translateX(6px);
}
37% {
transform: translateX(-4px);
}
40% {
transform: translateX(2px);
}
43% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes sk-row-2 {
0% {
transform: translateX(0);
}
30% {
transform: translateX(0);
}
33% {
transform: translateX(-6px);
}
36% {
transform: translateX(6px);
}
39% {
transform: translateX(-4px);
}
42% {
transform: translateX(2px);
}
45% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes sk-lb-1 {
0% {
color: rgba(23, 20, 26, 0.72);
}
28% {
color: #c2361a;
}
56% {
color: #479076;
}
84% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes sk-lb-2 {
0% {
color: rgba(23, 20, 26, 0.72);
}
30% {
color: #c2361a;
}
64% {
color: #479076;
}
88% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes sk-in-1 {
0% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
28% {
border-color: #c2361a;
background: #ffe0d8;
}
56% {
border-color: #479076;
background: #ffffff;
}
84% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
100% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
}
@keyframes sk-in-2 {
0% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
30% {
border-color: #c2361a;
background: #ffe0d8;
}
64% {
border-color: #479076;
background: #ffffff;
}
88% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
100% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
}
@keyframes sk-gh-1 {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sk-gh-2 {
0% {
opacity: 0;
}
64% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sk-btn {
0% {
transform: scale(1);
}
24% {
transform: scale(1);
}
26% {
transform: scale(0.97);
}
30% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 360px) {
.sk {
gap: 4px;
}
.sk__label {
width: 40px;
}
.sk__input, .sk__btn {
height: 32px;
}
.sk__ghost {
line-height: 32px;
}
.sk__btn {
line-height: 32px;
}
.sk__input {
line-height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.sk.is-demo .sk__row,
.sk.is-demo .sk__label,
.sk.is-demo .sk__input,
.sk.is-demo .sk__btn,
.sk.is-demo .sk__ghost,
.sk__row.is-bad {
animation: none !important;
}
.sk.is-demo .sk__label {
color: #c2361a;
}
.sk.is-demo .sk__input {
border-color: #c2361a;
background: #ffe0d8;
}
.sk__label, .sk__input, .sk__btn {
transition: none;
}
}// 실제 동작: 제출을 눌러야 검사한다. 비어 있는 칸만 흔들리고 라벨이 켜지며, 다 채우면 같은 라벨이 확정 색으로 넘어간다.
var sk = document.getElementById('sk');
var skSay = document.getElementById('sk-say');
var skRows = [].slice.call(document.querySelectorAll('.sk__row'));
sk.addEventListener('submit', function (e) {
e.preventDefault();
sk.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var bad = 0;
skRows.forEach(function (row) {
var input = row.querySelector('.sk__input');
var empty = input.value.trim().length === 0;
input.setAttribute('aria-invalid', empty ? 'true' : 'false');
row.classList.toggle('is-ok', !empty);
row.classList.remove('is-bad');
if (empty) {
void row.offsetWidth; // 리플로를 강제해야 같은 흔들림이 다시 돈다
row.classList.add('is-bad');
bad += 1;
}
});
skSay.textContent = bad ? bad + 'つの欄が空です' : '両方の欄が埋まりました';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SubmitShakeLabel.module.scss";
export type ShakeField = { label: string; type: string; sample: string };
export type SubmitShakeLabelProps = {
fields?: ShakeField[];
submitLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
const DEFAULT_FIELDS: ShakeField[] = [
{ label: "Email", type: "email", sample: "[email protected]" },
{ label: "Phone", type: "tel", sample: "010-1234-5678" },
];
export default function SubmitShakeLabel({
fields = DEFAULT_FIELDS,
submitLabel = "Sign up",
demo = true,
duration = 300,
easing = "cubic-bezier(.36,.07,.19,.97)",
color = "#2f6df6",
}: SubmitShakeLabelProps) {
const [values, setValues] = useState<string[]>(fields.map(() => ""));
const [tries, setTries] = useState(0);
const [live, setLive] = useState(demo);
const empty = values.map((v) => v.trim().length === 0);
const badCount = empty.filter(Boolean).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function set(i: number, v: string) {
setLive(false);
setValues((prev) => prev.map((old, k) => (k === i ? v : old)));
}
return (
<form
className={`${styles.root} ${live ? styles.isDemo : ""}`}
style={vars}
noValidate
onSubmit={(e: { preventDefault: () => void }) => { e.preventDefault(); setLive(false); setTries((n) => n + 1); }}
>
{fields.map((f, i) => (
// key 에 시도 횟수를 섞어 두면 같은 칸이 다시 마운트되어 흔들림이 매번 새로 돈다
<div
key={`${f.label}-${tries}`}
className={`${styles.row} ${tries && empty[i] ? styles.isBad : ""} ${tries && !empty[i] ? styles.isOk : ""}`}
data-row={i + 1}
>
<label className={styles.label} htmlFor={`sk-${i}`}>{f.label}</label>
<span className={styles.box}>
<input
className={styles.input}
id={`sk-${i}`}
type={f.type}
autoComplete="off"
required
aria-invalid={tries > 0 && empty[i]}
value={values[i]}
onChange={(e: ChangeEvt) => set(i, e.target.value)}
/>
<span className={styles.ghost} aria-hidden="true">{f.sample}</span>
</span>
</div>
))}
<button className={styles.btn} type="submit">{submitLabel}</button>
<p className={styles.say} role="status" aria-live="polite">
{tries === 0 ? "Fill in both fields" : badCount ? `${badCount} field(s) still empty` : "Both fields are filled"}
</p>
</form>
);
}08 送信時に揺れて知らせる欄 — 計測 累積面積 13.172% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · フォーム バリデーション UI 9選 — 打ち込んだ瞬間に判定するエラー表示
このデモが属する記事
フォーム バリデーション UI 9選 — 打ち込んだ瞬間に判定するエラー表示フォーム バリデーションは、打ち込まれた値が規則に合うかをその場で測り、結果を画面で知らせる仕組みです。9つとも実際の正規表現が動いていて、打ち込んだ瞬間に判定する側と、送信ボタンを押すまで黙っている側が混ざっています。 この記事のデモをすべて見る


