9 Form Validation UI Patterns — As You Type
Form validation UI is the layer that checks a typed value against a rule and reports the verdict on screen.
01 validation-required-float-label
02 validation-pattern-hint-tooltip
03 validation-inline-live-message
04 validation-requirement-checklist
05 validation-password-score-gauge
06 validation-char-limit-counter
08 validation-submit-shake-label
09 validation-first-error-scroll
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Floating label with a required mark
- 02 Focus-triggered format example tooltip
- 03 Inline hint that flips while you type
- 04 Requirements checklist that fills in one by one
- 05 Password strength gauge that scores as you type
- 06 Character counter that turns warning near the limit
- 07 Status icon that waits, then rules
- 08 Fields that shake when submit catches them
- 09 Focus ring that walks you to the first error
The order is not popularity. It follows one person filling out a form and getting stuck. You see which boxes are mandatory before you start (01), you see the shape a value should take (02), and you learn whether it fits while you type (03). The middle three are boxes with several rules at once: a checklist you clear item by item (04), a score that rates strength (05), and a length cap you approach (06). The last three are verdicts that arrive late: one that waits for a server (07), one that checks everything at the press of a button (08), and one that carries you to the first error sitting off-screen (09). Every rule of this form validation error ui lives in the file as a regular expression, and the color and the motion only paint its result. Gather the inputs into a signup screen and you get Register Form Design; split them into steps and Multi Step Form UI Patterns continues.
01Floating label with a required mark
What this form validation ui design shows
The label sits inside the box and, once you type, rises 8px and shrinks to 78%; when a required box loses focus while empty, the label, its asterisk, and the border all turn the error color. The verdict looks at one more piece of state besides the value: touched.

<main class="stage">
<form class="fl is-demo" id="fl" novalidate>
<div class="fl__box" id="fl-box">
<input class="fl__input" id="fl-input" type="text" autocomplete="off" aria-required="true" aria-describedby="fl-msg">
<span class="fl__ghost" aria-hidden="true">Bitna Kim</span>
<label class="fl__label" for="fl-input"><span class="fl__text">Name</span><span class="fl__star" aria-hidden="true">*</span></label>
</div>
<p class="fl__msg" id="fl-msg" role="status">
<span class="fl__m1">Enter your name to continue</span>
<span class="fl__m2">Name looks good</span>
</p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$ok: $subject-mint;
.fl {
position: relative;
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;
}
// ── 입력 상자 ─────────────────────────────────────────
.fl__box {
position: relative;
box-sizing: border-box;
height: 52px;
border: 2px solid rgba(255, 255, 255, .18);
border-radius: $r-control;
background: rgba(255, 255, 255, .06);
transition: border-color $duration $easing, background $duration $easing;
}
.fl.is-up .fl__box { border-color: $color; }
.fl.is-ok .fl__box { border-color: $ok; }
.fl.is-bad .fl__box { border-color: $error; background: rgba($error, .16); }
.fl__input {
box-sizing: border-box;
width: 100%; height: 100%;
padding: $sp-5 $sp-3 0;
border: 0;
border-radius: $r-control;
background: transparent;
color: $subject-cream;
font-family: inherit;
font-size: 14px; line-height: 20px; font-weight: 600;
}
.fl__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 자동 루프에서만 보이는 "쳐 넣는 글자"
.fl__ghost {
position: absolute;
left: 14px; top: 26px;
color: $subject-cream;
font-size: 14px; line-height: 20px; font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 라벨 — 안에 앉아 있다가 위로 떠오른다 ───────────────
.fl__label {
position: absolute;
left: 14px; top: 16px;
display: inline-flex;
align-items: center;
gap: $sp-1;
color: rgba(255, 255, 255, .52);
font-size: 14px; line-height: 20px; font-weight: 600;
pointer-events: none;
transform-origin: left top; // 규칙8: 플로트 라벨 좌상단 고정
transform: translateY(0) scale(1);
transition: transform $duration $easing, color $duration $easing;
}
.fl.is-up .fl__label { transform: translateY(-8px) scale(.78); color: $color; }
.fl.is-ok .fl__label { transform: translateY(-8px) scale(.78); color: $ok; }
.fl.is-bad .fl__label { transform: translateY(0) scale(1); color: $error; }
.fl__star { color: $error; }
.fl.is-bad .fl__star { color: $error; }
// ── 아래 한 줄 — 자리를 미리 잡아 둔다 ──────────────────
.fl__msg {
position: relative;
height: 16px;
margin: 0;
font-size: 11px; line-height: 16px; font-weight: 600;
}
.fl__m1, .fl__m2 {
position: absolute;
left: 0; top: 0;
opacity: 0;
}
.fl__m1 { color: $error; }
.fl__m2 { color: $ok; }
.fl.is-bad .fl__m1 { opacity: 1; }
.fl.is-ok .fl__m2 { opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~8% 빈 칸 → 12~40% 한 자씩 타이핑(라벨은 8% 에 이미 떠 있다) → 44~56% 통과 표시
// → 60% 비움(컷) + 오류 상태 → 76% 문구 · 82% 테두리 · 87% 라벨 순으로 흩어서 복귀한다.
// 복귀를 한 프레임에 몰면 그 프레임이 poster 로 뽑혀 정작 오류 장면이 대표 그림에서 밀린다 (실측).
.fl.is-demo .fl__ghost {
opacity: 1;
animation-name: fl-type;
animation-duration: $dur-loop;
animation-timing-function: steps(3, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__label {
transition: none;
animation-name: fl-label;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fl.is-demo .fl__box {
transition: none;
animation-name: fl-box;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__m1 {
animation-name: fl-m1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__m2 {
animation-name: fl-m2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__star {
animation-name: fl-star;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fl-type {
0% { clip-path: inset(0 100% 0 0); }
12% { clip-path: inset(0 100% 0 0); }
40% { clip-path: inset(0 0 0 0); }
56% { clip-path: inset(0 0 0 0); }
60% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes fl-label {
0% { transform: translateY(0) scale(1); color: rgba(255, 255, 255, .52); }
8% { transform: translateY(-8px) scale(.78); color: $color; }
44% { transform: translateY(-8px) scale(.78); color: $ok; }
60% { transform: translateY(0) scale(1); color: $error; }
87% { transform: translateY(0) scale(1); color: rgba(255, 255, 255, .52); }
100% { transform: translateY(0) scale(1); color: rgba(255, 255, 255, .52); }
}
@keyframes fl-box {
0% { border-color: rgba(255, 255, 255, .18); background: rgba(255, 255, 255, .06); }
8% { border-color: $color; background: rgba(255, 255, 255, .06); }
44% { border-color: $ok; background: rgba(255, 255, 255, .06); }
60% { border-color: $error; background: rgba($error, .16); }
82% { border-color: rgba(255, 255, 255, .18); background: rgba(255, 255, 255, .06); }
100% { border-color: rgba(255, 255, 255, .18); background: rgba(255, 255, 255, .06); }
}
@keyframes fl-m1 {
0% { opacity: 0; }
60% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fl-m2 {
0% { opacity: 0; }
44% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fl-star {
0% { color: $error; }
44% { color: $ok; }
60% { color: $error; }
100% { color: $error; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.fl { gap: $sp-1; }
.fl__box { height: 48px; }
.fl__ghost { top: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.fl.is-demo .fl__ghost,
.fl.is-demo .fl__label,
.fl.is-demo .fl__box,
.fl.is-demo .fl__m1,
.fl.is-demo .fl__m2,
.fl.is-demo .fl__star { animation: none !important; }
.fl.is-demo .fl__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.fl.is-demo .fl__label { transform: translateY(-8px) scale(.78); color: $ok; }
.fl.is-demo .fl__box { border-color: $ok; }
.fl.is-demo .fl__m2 { opacity: 1; }
.fl__label, .fl__box { 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);
}
.fl {
position: relative;
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;
}
.fl__box {
position: relative;
box-sizing: border-box;
height: 52px;
border: 2px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl.is-up .fl__box {
border-color: #2f6df6;
}
.fl.is-ok .fl__box {
border-color: #4cd4a6;
}
.fl.is-bad .fl__box {
border-color: #c2361a;
background: rgba(194, 54, 26, 0.16);
}
.fl__input {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 20px 12px 0;
border: 0;
border-radius: 12px;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.fl__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fl__ghost {
position: absolute;
left: 14px;
top: 26px;
color: #fff7e6;
font-size: 14px;
line-height: 20px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.fl__label {
position: absolute;
left: 14px;
top: 16px;
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.52);
font-size: 14px;
line-height: 20px;
font-weight: 600;
pointer-events: none;
transform-origin: left top;
transform: translateY(0) scale(1);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fl.is-up .fl__label {
transform: translateY(-8px) scale(0.78);
color: #2f6df6;
}
.fl.is-ok .fl__label {
transform: translateY(-8px) scale(0.78);
color: #4cd4a6;
}
.fl.is-bad .fl__label {
transform: translateY(0) scale(1);
color: #c2361a;
}
.fl__star {
color: #c2361a;
}
.fl.is-bad .fl__star {
color: #c2361a;
}
.fl__msg {
position: relative;
height: 16px;
margin: 0;
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.fl__m1, .fl__m2 {
position: absolute;
left: 0;
top: 0;
opacity: 0;
}
.fl__m1 {
color: #c2361a;
}
.fl__m2 {
color: #4cd4a6;
}
.fl.is-bad .fl__m1 {
opacity: 1;
}
.fl.is-ok .fl__m2 {
opacity: 1;
}
.fl.is-demo .fl__ghost {
opacity: 1;
animation-name: fl-type;
animation-duration: 2s;
animation-timing-function: steps(3, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__label {
transition: none;
animation-name: fl-label;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__box {
transition: none;
animation-name: fl-box;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__m1 {
animation-name: fl-m1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__m2 {
animation-name: fl-m2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fl.is-demo .fl__star {
animation-name: fl-star;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fl-type {
0% {
clip-path: inset(0 100% 0 0);
}
12% {
clip-path: inset(0 100% 0 0);
}
40% {
clip-path: inset(0 0 0 0);
}
56% {
clip-path: inset(0 0 0 0);
}
60% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes fl-label {
0% {
transform: translateY(0) scale(1);
color: rgba(255, 255, 255, 0.52);
}
8% {
transform: translateY(-8px) scale(0.78);
color: #2f6df6;
}
44% {
transform: translateY(-8px) scale(0.78);
color: #4cd4a6;
}
60% {
transform: translateY(0) scale(1);
color: #c2361a;
}
87% {
transform: translateY(0) scale(1);
color: rgba(255, 255, 255, 0.52);
}
100% {
transform: translateY(0) scale(1);
color: rgba(255, 255, 255, 0.52);
}
}
@keyframes fl-box {
0% {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
8% {
border-color: #2f6df6;
background: rgba(255, 255, 255, 0.06);
}
44% {
border-color: #4cd4a6;
background: rgba(255, 255, 255, 0.06);
}
60% {
border-color: #c2361a;
background: rgba(194, 54, 26, 0.16);
}
82% {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
100% {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
}
@keyframes fl-m1 {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fl-m2 {
0% {
opacity: 0;
}
44% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fl-star {
0% {
color: #c2361a;
}
44% {
color: #4cd4a6;
}
60% {
color: #c2361a;
}
100% {
color: #c2361a;
}
}
@media (max-width: 360px) {
.fl {
gap: 4px;
}
.fl__box {
height: 48px;
}
.fl__ghost {
top: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.fl.is-demo .fl__ghost,
.fl.is-demo .fl__label,
.fl.is-demo .fl__box,
.fl.is-demo .fl__m1,
.fl.is-demo .fl__m2,
.fl.is-demo .fl__star {
animation: none !important;
}
.fl.is-demo .fl__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.fl.is-demo .fl__label {
transform: translateY(-8px) scale(0.78);
color: #4cd4a6;
}
.fl.is-demo .fl__box {
border-color: #4cd4a6;
}
.fl.is-demo .fl__m2 {
opacity: 1;
}
.fl__label, .fl__box {
transition: none;
}
}// 실제 동작: 치면 라벨이 위에 떠 있고, 비운 채 벗어나면 라벨·별표·테두리가 오류색으로 바뀐다.
var fl = document.getElementById('fl');
var flInput = document.getElementById('fl-input');
var flTouched = false;
function flState() {
fl.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var focused = document.activeElement === flInput;
var filled = flInput.value.length > 0;
var bad = flTouched && !filled;
fl.classList.toggle('is-up', focused || filled);
fl.classList.toggle('is-bad', bad);
fl.classList.toggle('is-ok', filled && !bad);
flInput.setAttribute('aria-invalid', bad ? 'true' : 'false');
}
flInput.addEventListener('focus', flState);
flInput.addEventListener('input', flState);
flInput.addEventListener('blur', function () { flTouched = true; flState(); });
fl.addEventListener('submit', function (e) {
e.preventDefault(); flTouched = true; flState();
flInput.focus({ preventScroll: true });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RequiredFloatLabel.module.scss";
export type RequiredFloatLabelProps = {
label?: string;
emptyMessage?: string;
filledMessage?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
export default function RequiredFloatLabel({
label = "Name",
emptyMessage = "Enter your name to continue",
filledMessage = "Name looks good",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RequiredFloatLabelProps) {
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
const [touched, setTouched] = useState(false);
const [live, setLive] = useState(demo);
const filled = value.length > 0;
const bad = touched && !filled;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [
styles.root,
live ? styles.isDemo : "",
focused || filled ? styles.isUp : "",
bad ? styles.isBad : "",
filled && !bad ? styles.isOk : "",
].join(" ");
return (
<form
className={cls}
style={vars}
noValidate
onSubmit={(e: { preventDefault: () => void }) => { e.preventDefault(); setTouched(true); setLive(false); }}
>
<div className={styles.box}>
<input
className={styles.input}
id="fl-input"
type="text"
autoComplete="off"
aria-required="true"
aria-invalid={bad}
aria-describedby="fl-msg"
value={value}
onChange={(e: ChangeEvt) => { setValue(e.target.value); setLive(false); }}
onFocus={() => { setFocused(true); setLive(false); }}
onBlur={() => { setFocused(false); setTouched(true); }}
/>
<span className={styles.ghost} aria-hidden="true">Bitna Kim</span>
<label className={styles.label} htmlFor="fl-input">
<span className={styles.text}>{label}</span>
<span className={styles.star} aria-hidden="true">*</span>
</label>
</div>
<p className={styles.msg} id="fl-msg" role="status">
<span className={styles.m1}>{emptyMessage}</span>
<span className={styles.m2}>{filledMessage}</span>
</p>
</form>
);
}01 · Floating label with a required mark — Measured area 11.549% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Paint a box red before anyone has touched it and the form is a wall of red the moment it opens.
function flState() {
var focused = document.activeElement === flInput;
var filled = flInput.value.length > 0;
var bad = flTouched && !filled;
fl.classList.toggle('is-up', focused || filled);
fl.classList.toggle('is-bad', bad);
fl.classList.toggle('is-ok', filled && !bad);
flInput.setAttribute('aria-invalid', bad ? 'true' : 'false');
}
flInput.addEventListener('blur', function () { flTouched = true; flState(); });
02Focus-triggered format example tooltip
What happens on screen
Focusing the phone box slides in a tooltip that reads 010-1234-5678. The example is cut into three pieces, and each piece locks into the confirmed color the moment your value clears that piece's pattern.

<main class="stage">
<div class="ph is-demo" id="ph">
<div class="ph__tip" id="ph-tip" role="tooltip">
<span class="ph__seg" data-seg="0">010</span><span class="ph__dash" data-seg="0">-</span><span class="ph__seg" data-seg="1">1234</span><span class="ph__dash" data-seg="1">-</span><span class="ph__seg" data-seg="2">5678</span>
</div>
<label class="ph__label" for="ph-input">Mobile number</label>
<div class="ph__field">
<input class="ph__input" id="ph-input" type="tel" autocomplete="off" inputmode="numeric" aria-describedby="ph-tip" placeholder="Digits only">
<span class="ph__ghost" aria-hidden="true">010-1234-5678</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-yellow);
$ok: #479075; // 노랑 무대에서 읽히는 확정 색 (민트는 노랑 위에서 흐려진다)
.ph {
position: relative;
box-sizing: border-box;
width: min(300px, 86vw);
// 툴팁은 절대배치라 뿌리 높이에 안 잡힌다 — 그만큼 위쪽을 미리 비워 둔다
padding-top: $sp-10;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 예시 형식 툴팁 (입력칸 위로 내려온다) ────────────────
.ph__tip {
position: absolute;
left: 0; top: 0;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $stage-ink;
color: rgba(255, 247, 230, .45);
font-size: 13px; line-height: 16px; font-weight: 700;
letter-spacing: .04em;
white-space: nowrap;
transform-origin: left bottom; // 규칙8: 참조 변(좌하) 툴팁 전개
transform: translateY(-6px) scale(.94);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.ph__tip::after {
content: "";
position: absolute;
left: 18px; bottom: -5px;
width: 10px; height: 10px;
background: $stage-ink;
transform: rotate(45deg);
}
.ph.is-open .ph__tip { transform: translateY(0) scale(1); opacity: 1; }
// 조각이 굳는다 — 통과한 조각만 확정 색으로
.ph__seg, .ph__dash { transition: color $duration $easing; }
.ph[data-done="1"] .ph__seg[data-seg="0"],
.ph[data-done="2"] .ph__seg[data-seg="0"],
.ph[data-done="2"] .ph__seg[data-seg="1"],
.ph[data-done="2"] .ph__dash[data-seg="0"],
.ph[data-done="3"] .ph__seg,
.ph[data-done="3"] .ph__dash { color: $subject-mint; }
// ── 입력칸 ───────────────────────────────────────────
.ph__label {
display: block;
margin-bottom: $sp-1;
color: rgba(23, 20, 26, .72);
font-size: 11px; line-height: 14px; font-weight: 700;
}
.ph__field { position: relative; }
.ph__input {
box-sizing: border-box;
width: 100%; height: 40px;
padding: 0 $sp-3;
border: 2px solid rgba(23, 20, 26, .22);
border-radius: $r-control;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 14px; line-height: 36px; font-weight: 700;
letter-spacing: .04em;
}
.ph__input::placeholder { color: rgba(23, 20, 26, .38); font-weight: 600; }
.ph__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ph.is-open .ph__input { border-color: $color; }
.ph[data-done="3"] .ph__input { border-color: $ok; }
// 자동 루프에서만 보이는 "쳐 넣는 숫자". 진짜 placeholder 와 겹치지 않게 지운다
.ph.is-demo .ph__input::placeholder { color: transparent; }
.ph__ghost {
position: absolute;
left: 14px; top: 0;
color: $subject-ink;
font-size: 14px; line-height: 40px; font-weight: 700;
letter-spacing: .04em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~17% 앞자리만 친 칸(첫 조각은 이미 굳어 있다) → 20% 툴팁이 내려온다 = 가장 큰 변화 = poster
// 여닫기 구간(17~20%·76~79%)은 24프레임 샘플(4.1667% 간격) **사이**에 둔다 — 걸치면 반투명 유령이 poster 로 뽑힌다.
// → 30~62% 남은 두 조각 타이핑, 조각마다 예시가 굳는다 → 78% 툴팁 접힘 → 84% 앞자리로 되감기.
// 되감기를 툴팁이 닫힌 뒤로 미뤄야 조각 색 복귀가 프레임에 안 잡힌다 (최대 변화 프레임 = 툴팁 등장).
.ph.is-demo .ph__tip {
transition: none;
animation-name: ph-tip;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ph.is-demo .ph__ghost {
opacity: 1;
animation-name: ph-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__input {
transition: none;
animation-name: ph-input;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__seg,
.ph.is-demo .ph__dash {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__seg[data-seg="0"] { animation-name: ph-s0; }
.ph.is-demo .ph__dash[data-seg="0"] { animation-name: ph-s1; }
.ph.is-demo .ph__seg[data-seg="1"] { animation-name: ph-s1; }
.ph.is-demo .ph__dash[data-seg="1"] { animation-name: ph-s2; }
.ph.is-demo .ph__seg[data-seg="2"] { animation-name: ph-s2; }
@keyframes ph-tip {
0% { opacity: 0; transform: translateY(-6px) scale(.94); }
17% { opacity: 0; transform: translateY(-6px) scale(.94); }
20% { opacity: 1; transform: translateY(0) scale(1); }
76% { opacity: 1; transform: translateY(0) scale(1); }
79% { opacity: 0; transform: translateY(-6px) scale(.94); }
100% { opacity: 0; transform: translateY(-6px) scale(.94); }
}
@keyframes ph-type {
0% { clip-path: inset(0 54% 0 0); }
30% { clip-path: inset(0 38% 0 0); }
46% { clip-path: inset(0 20% 0 0); }
62% { clip-path: inset(0 0 0 0); }
84% { clip-path: inset(0 54% 0 0); }
100% { clip-path: inset(0 54% 0 0); }
}
@keyframes ph-input {
0% { border-color: rgba(23, 20, 26, .22); }
20% { border-color: $color; }
62% { border-color: $ok; }
84% { border-color: rgba(23, 20, 26, .22); }
100% { border-color: rgba(23, 20, 26, .22); }
}
@keyframes ph-s0 {
0% { color: $subject-mint; }
100% { color: $subject-mint; }
}
@keyframes ph-s1 {
0% { color: rgba(255, 247, 230, .45); }
46% { color: $subject-mint; }
84% { color: rgba(255, 247, 230, .45); }
100% { color: rgba(255, 247, 230, .45); }
}
@keyframes ph-s2 {
0% { color: rgba(255, 247, 230, .45); }
62% { color: $subject-mint; }
84% { color: rgba(255, 247, 230, .45); }
100% { color: rgba(255, 247, 230, .45); }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.ph { padding-top: $sp-8; }
.ph__input { height: 36px; line-height: 32px; }
.ph__ghost { line-height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__tip,
.ph.is-demo .ph__ghost,
.ph.is-demo .ph__input,
.ph.is-demo .ph__seg,
.ph.is-demo .ph__dash { animation: none !important; }
.ph.is-demo .ph__tip { opacity: 1; transform: translateY(0) scale(1); }
.ph.is-demo .ph__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.ph.is-demo .ph__seg,
.ph.is-demo .ph__dash { color: $subject-mint; }
.ph.is-demo .ph__input { border-color: $ok; }
.ph__tip, .ph__seg, .ph__dash, .ph__input { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ph {
position: relative;
box-sizing: border-box;
width: min(300px, 86vw);
padding-top: 40px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ph__tip {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
padding: 8px 12px;
border-radius: 12px;
background: #17141a;
color: rgba(255, 247, 230, 0.45);
font-size: 13px;
line-height: 16px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
transform-origin: left bottom;
transform: translateY(-6px) scale(0.94);
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ph__tip::after {
content: "";
position: absolute;
left: 18px;
bottom: -5px;
width: 10px;
height: 10px;
background: #17141a;
transform: rotate(45deg);
}
.ph.is-open .ph__tip {
transform: translateY(0) scale(1);
opacity: 1;
}
.ph__seg, .ph__dash {
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ph[data-done="1"] .ph__seg[data-seg="0"],
.ph[data-done="2"] .ph__seg[data-seg="0"],
.ph[data-done="2"] .ph__seg[data-seg="1"],
.ph[data-done="2"] .ph__dash[data-seg="0"],
.ph[data-done="3"] .ph__seg,
.ph[data-done="3"] .ph__dash {
color: #4cd4a6;
}
.ph__label {
display: block;
margin-bottom: 4px;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.ph__field {
position: relative;
}
.ph__input {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 12px;
border: 2px solid rgba(23, 20, 26, 0.22);
border-radius: 12px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 14px;
line-height: 36px;
font-weight: 700;
letter-spacing: 0.04em;
}
.ph__input::placeholder {
color: rgba(23, 20, 26, 0.38);
font-weight: 600;
}
.ph__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ph.is-open .ph__input {
border-color: #2f6df6;
}
.ph[data-done="3"] .ph__input {
border-color: #479075;
}
.ph.is-demo .ph__input::placeholder {
color: transparent;
}
.ph__ghost {
position: absolute;
left: 14px;
top: 0;
color: #17141a;
font-size: 14px;
line-height: 40px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.ph.is-demo .ph__tip {
transition: none;
animation-name: ph-tip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__ghost {
opacity: 1;
animation-name: ph-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__input {
transition: none;
animation-name: ph-input;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__seg,
.ph.is-demo .ph__dash {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ph.is-demo .ph__seg[data-seg="0"] {
animation-name: ph-s0;
}
.ph.is-demo .ph__dash[data-seg="0"] {
animation-name: ph-s1;
}
.ph.is-demo .ph__seg[data-seg="1"] {
animation-name: ph-s1;
}
.ph.is-demo .ph__dash[data-seg="1"] {
animation-name: ph-s2;
}
.ph.is-demo .ph__seg[data-seg="2"] {
animation-name: ph-s2;
}
@keyframes ph-tip {
0% {
opacity: 0;
transform: translateY(-6px) scale(0.94);
}
17% {
opacity: 0;
transform: translateY(-6px) scale(0.94);
}
20% {
opacity: 1;
transform: translateY(0) scale(1);
}
76% {
opacity: 1;
transform: translateY(0) scale(1);
}
79% {
opacity: 0;
transform: translateY(-6px) scale(0.94);
}
100% {
opacity: 0;
transform: translateY(-6px) scale(0.94);
}
}
@keyframes ph-type {
0% {
clip-path: inset(0 54% 0 0);
}
30% {
clip-path: inset(0 38% 0 0);
}
46% {
clip-path: inset(0 20% 0 0);
}
62% {
clip-path: inset(0 0 0 0);
}
84% {
clip-path: inset(0 54% 0 0);
}
100% {
clip-path: inset(0 54% 0 0);
}
}
@keyframes ph-input {
0% {
border-color: rgba(23, 20, 26, 0.22);
}
20% {
border-color: #2f6df6;
}
62% {
border-color: #479075;
}
84% {
border-color: rgba(23, 20, 26, 0.22);
}
100% {
border-color: rgba(23, 20, 26, 0.22);
}
}
@keyframes ph-s0 {
0% {
color: #4cd4a6;
}
100% {
color: #4cd4a6;
}
}
@keyframes ph-s1 {
0% {
color: rgba(255, 247, 230, 0.45);
}
46% {
color: #4cd4a6;
}
84% {
color: rgba(255, 247, 230, 0.45);
}
100% {
color: rgba(255, 247, 230, 0.45);
}
}
@keyframes ph-s2 {
0% {
color: rgba(255, 247, 230, 0.45);
}
62% {
color: #4cd4a6;
}
84% {
color: rgba(255, 247, 230, 0.45);
}
100% {
color: rgba(255, 247, 230, 0.45);
}
}
@media (max-width: 360px) {
.ph {
padding-top: 32px;
}
.ph__input {
height: 36px;
line-height: 32px;
}
.ph__ghost {
line-height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__tip,
.ph.is-demo .ph__ghost,
.ph.is-demo .ph__input,
.ph.is-demo .ph__seg,
.ph.is-demo .ph__dash {
animation: none !important;
}
.ph.is-demo .ph__tip {
opacity: 1;
transform: translateY(0) scale(1);
}
.ph.is-demo .ph__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.ph.is-demo .ph__seg,
.ph.is-demo .ph__dash {
color: #4cd4a6;
}
.ph.is-demo .ph__input {
border-color: #479075;
}
.ph__tip, .ph__seg, .ph__dash, .ph__input {
transition: none;
}
}// 실제 동작: 포커스에 예시 툴팁이 열리고, 친 값이 세 조각 정규식을 통과할 때마다 그 조각이 확정 색으로 굳는다.
var ph = document.getElementById('ph');
var phInput = document.getElementById('ph-input');
var PH_RULES = [/^010/, /^010-?\d{4}/, /^010-?\d{4}-?\d{4}$/];
function phPaint() {
ph.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var v = phInput.value;
var done = 0;
PH_RULES.forEach(function (re, i) { if (re.test(v)) done = i + 1; });
ph.setAttribute('data-done', String(done));
phInput.setAttribute('aria-invalid', done === 3 ? 'false' : 'true');
}
phInput.addEventListener('focus', function () { ph.classList.remove('is-demo'); ph.classList.add('is-open'); phPaint(); });
phInput.addEventListener('blur', function () { ph.classList.remove('is-open'); });
phInput.addEventListener('input', function () {
var d = phInput.value.replace(/\D/g, '').slice(0, 11);
phInput.value = d.length > 7 ? d.slice(0, 3) + '-' + d.slice(3, 7) + '-' + d.slice(7)
: d.length > 3 ? d.slice(0, 3) + '-' + d.slice(3) : d;
phPaint();
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PatternHintTooltip.module.scss";
export type PatternHintTooltipProps = {
label?: string;
placeholder?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
const RULES: RegExp[] = [/^010/, /^010-?\d{4}/, /^010-?\d{4}-?\d{4}$/];
function format(raw: string) {
const d = raw.replace(/\D/g, "").slice(0, 11);
if (d.length > 7) return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7)}`;
if (d.length > 3) return `${d.slice(0, 3)}-${d.slice(3)}`;
return d;
}
export default function PatternHintTooltip({
label = "Mobile number",
placeholder = "Digits only",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PatternHintTooltipProps) {
const [value, setValue] = useState("010-12");
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
let done = 0;
RULES.forEach((re, i) => { if (re.test(value)) done = i + 1; });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ");
return (
<div className={cls} style={vars} data-done={done}>
<div className={styles.tip} id="ph-tip" role="tooltip">
<span className={styles.seg} data-seg="0">010</span>
<span className={styles.dash} data-seg="0">-</span>
<span className={styles.seg} data-seg="1">1234</span>
<span className={styles.dash} data-seg="1">-</span>
<span className={styles.seg} data-seg="2">5678</span>
</div>
<label className={styles.label} htmlFor="ph-input">{label}</label>
<div className={styles.field}>
<input
className={styles.input}
id="ph-input"
type="tel"
inputMode="numeric"
autoComplete="off"
placeholder={placeholder}
aria-describedby="ph-tip"
aria-invalid={done !== 3}
value={value}
onChange={(e: ChangeEvt) => { setValue(format(e.target.value)); setLive(false); }}
onFocus={() => { setOpen(true); setLive(false); }}
onBlur={() => setOpen(false)}
/>
<span className={styles.ghost} aria-hidden="true">010-1234-5678</span>
</div>
</div>
);
}02 · Focus-triggered format example tooltip — Measured area 3.906% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The value itself is stripped down to digits and reassembled in groups of three and four, so the hyphens appear on their own.
var PH_RULES = [/^010/, /^010-?\d{4}/, /^010-?\d{4}-?\d{4}$/];
phInput.addEventListener('input', function () {
var d = phInput.value.replace(/\D/g, '').slice(0, 11);
phInput.value = d.length > 7 ? d.slice(0, 3) + '-' + d.slice(3, 7) + '-' + d.slice(7)
: d.length > 3 ? d.slice(0, 3) + '-' + d.slice(3) : d;
var done = 0;
PH_RULES.forEach(function (re, i) { if (re.test(phInput.value)) done = i + 1; });
ph.setAttribute('data-done', String(done));
});
03Inline hint that flips while you type
What moves
A 16px line is reserved under the email box before anything is written into it, so nothing below shifts when a message appears. The not-yet-valid and the valid sentence sit stacked in that same spot, and only opacity switches between them.

<main class="stage">
<div class="il is-demo" id="il">
<label class="il__label" for="il-input">Email</label>
<div class="il__field">
<input class="il__input" id="il-input" type="email" autocomplete="off" aria-describedby="il-msg" placeholder="Type an email address">
<span class="il__ghost" aria-hidden="true">[email protected]</span>
</div>
<p class="il__msg" id="il-msg" aria-live="polite">
<span class="il__bad">The part after @ is still missing</span>
<span class="il__good">This address can receive mail</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$ok: #479075; // 주황 무대 위에서 읽히는 확정 색
$bad: #4a0f04; // 주황 위 오류는 더 짙게 (코럴은 무대에 묻힌다)
.il {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.il__label {
display: block;
margin-bottom: $sp-1;
color: $subject-ink; // 주황 무대 위 크림 2.51:1 → 잉크 5.50:1
font-size: 11px; line-height: 14px; font-weight: 700;
}
.il__field { position: relative; }
.il__input {
box-sizing: border-box;
width: 100%; height: 40px;
padding: 0 $sp-3;
border: 2px solid rgba(255, 247, 230, .38);
border-radius: $r-control;
background: rgba(23, 20, 26, .18);
color: $color;
font-family: inherit;
font-size: 14px; line-height: 36px; font-weight: 700;
transition: border-color $duration $easing;
}
.il__input::placeholder { color: rgba(255, 247, 230, .5); font-weight: 600; }
.il__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.il.is-bad .il__input { border-color: $bad; }
.il.is-good .il__input { border-color: $ok; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 진짜 placeholder 와 겹치지 않게 지운다
.il.is-demo .il__input::placeholder { color: transparent; }
.il__ghost {
position: absolute;
left: 14px; top: 0;
color: $color;
font-size: 14px; line-height: 40px; font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 안내 한 줄 — 자리를 미리 잡아 두고 문구만 갈아 끼운다 ──
.il__msg {
position: relative;
height: 16px;
margin: $sp-2 0 0;
font-size: 11px; line-height: 16px; font-weight: 700;
}
.il__bad, .il__good {
position: absolute;
left: 0; top: 0;
white-space: nowrap;
opacity: 0;
}
.il__bad { color: $bad; }
.il__good { color: $ok; }
.il.is-bad .il__bad { opacity: 1; }
.il.is-good .il__good { opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 절반만 친 주소(오류 문구) → 22~58% 한 조각씩 마저 타이핑 → 62% 통과 문구로 교체(컷)
// → 78% 문구 내림 · 84% 되감기. 88% 안에 처음 상태로 돌아온다.
// 문구 교체는 steps(1, end) 로 컷한다 — opacity 로 겹치면 두 문장이 반투명하게 포개진 프레임이 남는다.
.il.is-demo .il__ghost {
opacity: 1;
animation-name: il-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.il.is-demo .il__input {
transition: none;
animation-name: il-input;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.il.is-demo .il__bad {
animation-name: il-bad;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.il.is-demo .il__good {
animation-name: il-good;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes il-type {
0% { clip-path: inset(0 62% 0 0); }
22% { clip-path: inset(0 46% 0 0); }
38% { clip-path: inset(0 30% 0 0); }
50% { clip-path: inset(0 14% 0 0); }
62% { clip-path: inset(0 0 0 0); }
84% { clip-path: inset(0 62% 0 0); }
100% { clip-path: inset(0 62% 0 0); }
}
@keyframes il-input {
0% { border-color: $bad; }
62% { border-color: $ok; }
84% { border-color: $bad; }
100% { border-color: $bad; }
}
@keyframes il-bad {
0% { opacity: 1; }
62% { opacity: 0; }
84% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes il-good {
0% { opacity: 0; }
62% { opacity: 1; }
78% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.il__input { height: 36px; line-height: 32px; }
.il__ghost { line-height: 36px; }
.il__msg { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.il.is-demo .il__ghost,
.il.is-demo .il__input,
.il.is-demo .il__bad,
.il.is-demo .il__good { animation: none !important; }
.il.is-demo .il__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.il.is-demo .il__input { border-color: $ok; }
.il.is-demo .il__good { opacity: 1; }
.il__input { 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);
}
.il {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.il__label {
display: block;
margin-bottom: 4px;
color: #17141a;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.il__field {
position: relative;
}
.il__input {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 12px;
border: 2px solid rgba(255, 247, 230, 0.38);
border-radius: 12px;
background: rgba(23, 20, 26, 0.18);
color: #fff7e6;
font-family: inherit;
font-size: 14px;
line-height: 36px;
font-weight: 700;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.il__input::placeholder {
color: rgba(255, 247, 230, 0.5);
font-weight: 600;
}
.il__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.il.is-bad .il__input {
border-color: #4a0f04;
}
.il.is-good .il__input {
border-color: #479075;
}
.il.is-demo .il__input::placeholder {
color: transparent;
}
.il__ghost {
position: absolute;
left: 14px;
top: 0;
color: #fff7e6;
font-size: 14px;
line-height: 40px;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.il__msg {
position: relative;
height: 16px;
margin: 8px 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.il__bad, .il__good {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
opacity: 0;
}
.il__bad {
color: #4a0f04;
}
.il__good {
color: #479075;
}
.il.is-bad .il__bad {
opacity: 1;
}
.il.is-good .il__good {
opacity: 1;
}
.il.is-demo .il__ghost {
opacity: 1;
animation-name: il-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.il.is-demo .il__input {
transition: none;
animation-name: il-input;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.il.is-demo .il__bad {
animation-name: il-bad;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.il.is-demo .il__good {
animation-name: il-good;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes il-type {
0% {
clip-path: inset(0 62% 0 0);
}
22% {
clip-path: inset(0 46% 0 0);
}
38% {
clip-path: inset(0 30% 0 0);
}
50% {
clip-path: inset(0 14% 0 0);
}
62% {
clip-path: inset(0 0 0 0);
}
84% {
clip-path: inset(0 62% 0 0);
}
100% {
clip-path: inset(0 62% 0 0);
}
}
@keyframes il-input {
0% {
border-color: #4a0f04;
}
62% {
border-color: #479075;
}
84% {
border-color: #4a0f04;
}
100% {
border-color: #4a0f04;
}
}
@keyframes il-bad {
0% {
opacity: 1;
}
62% {
opacity: 0;
}
84% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes il-good {
0% {
opacity: 0;
}
62% {
opacity: 1;
}
78% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.il__input {
height: 36px;
line-height: 32px;
}
.il__ghost {
line-height: 36px;
}
.il__msg {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.il.is-demo .il__ghost,
.il.is-demo .il__input,
.il.is-demo .il__bad,
.il.is-demo .il__good {
animation: none !important;
}
.il.is-demo .il__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.il.is-demo .il__input {
border-color: #479075;
}
.il.is-demo .il__good {
opacity: 1;
}
.il__input {
transition: none;
}
}// 실제 동작: 한 글자마다 정규식을 다시 재고, 미리 확보해 둔 한 줄이 오류 문구와 완료 문구를 갈아 끼운다.
var il = document.getElementById('il');
var ilInput = document.getElementById('il-input');
var IL_MAIL = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function ilPaint() {
il.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var ok = IL_MAIL.test(ilInput.value);
il.classList.toggle('is-good', ok);
il.classList.toggle('is-bad', !ok && ilInput.value.length > 0);
ilInput.setAttribute('aria-invalid', ok ? 'false' : 'true');
}
ilInput.addEventListener('input', ilPaint);
ilInput.addEventListener('focus', ilPaint);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InlineLiveMessage.module.scss";
export type InlineLiveMessageProps = {
label?: string;
placeholder?: string;
badMessage?: string;
goodMessage?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
const MAIL: RegExp = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
export default function InlineLiveMessage({
label = "Email",
placeholder = "Type an email address",
badMessage = "The part after @ is still missing",
goodMessage = "This address can receive mail",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: InlineLiveMessageProps) {
const [value, setValue] = useState("");
const [live, setLive] = useState(demo);
const good = MAIL.test(value);
const bad = !good && value.length > 0;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", bad ? styles.isBad : "", good ? styles.isGood : ""].join(" ");
return (
<div className={cls} style={vars}>
<label className={styles.label} htmlFor="il-input">{label}</label>
<div className={styles.field}>
<input
className={styles.input}
id="il-input"
type="email"
autoComplete="off"
placeholder={placeholder}
aria-describedby="il-msg"
aria-invalid={!good}
value={value}
onChange={(e: ChangeEvt) => { setValue(e.target.value); setLive(false); }}
onFocus={() => setLive(false)}
/>
<span className={styles.ghost} aria-hidden="true">[email protected]</span>
</div>
<p className={styles.msg} id="il-msg" aria-live="polite">
<span className={styles.bad}>{badMessage}</span>
<span className={styles.good}>{goodMessage}</span>
</p>
</div>
);
}03 · Inline hint that flips while you type — Measured area 1.919% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Putting aria-live="polite" on that line makes the sentence people read and the sentence a screen reader announces one and the same.
var IL_MAIL = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function ilPaint() {
var ok = IL_MAIL.test(ilInput.value);
il.classList.toggle('is-good', ok);
il.classList.toggle('is-bad', !ok && ilInput.value.length > 0);
ilInput.setAttribute('aria-invalid', ok ? 'false' : 'true');
}
ilInput.addEventListener('input', ilPaint);
04Requirements checklist that fills in one by one
What you see
Each of the three rows under the password carries its own pattern. When a row passes, only that row crosses into the confirmed color and the check inside its circle pops from scale(0) through 1.3 to 1.

<main class="stage">
<div class="rq is-demo" id="rq">
<div class="rq__field">
<input class="rq__input" id="rq-input" type="text" autocomplete="off" aria-describedby="rq-list" placeholder="Create a password">
<span class="rq__ghost" aria-hidden="true">••••••••</span>
</div>
<ul class="rq__list" id="rq-list">
<li class="rq__row" data-rule="0">
<span class="rq__mark" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
<span class="rq__txt">Over eight characters</span>
</li>
<li class="rq__row" data-rule="1">
<span class="rq__mark" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
<span class="rq__txt">Include a digit</span>
</li>
<li class="rq__row" data-rule="2">
<span class="rq__mark" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
<span class="rq__txt">Include a symbol</span>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
$ok: #479076;
$off: rgba(23, 20, 26, .72); // 흐린 글자 알파 하한 — 흰 면 위 7.27:1
.rq {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rq__field { position: relative; }
.rq__input {
box-sizing: border-box;
width: 100%; height: 40px;
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: 14px; line-height: 36px; font-weight: 700;
letter-spacing: .08em;
transition: border-color $duration $easing;
}
.rq__input::placeholder { color: rgba(23, 20, 26, .36); font-weight: 600; letter-spacing: normal; }
.rq__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 진짜 placeholder 와 겹치지 않게 지운다
.rq.is-demo .rq__input::placeholder { color: transparent; }
.rq__ghost {
position: absolute;
left: 14px; top: 0;
color: $subject-ink;
font-size: 14px; line-height: 40px; font-weight: 700;
letter-spacing: .08em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 요건 세 줄 ───────────────────────────────────────
.rq__list {
margin: $sp-3 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.rq__row {
display: flex;
align-items: center;
gap: $sp-2;
color: $off;
font-size: 12px; line-height: 16px; font-weight: 700;
transition: color $duration $easing;
}
.rq__row.is-pass { color: $ok; }
// 체크는 동그라미 안에서 튀어나온다 — 아이콘은 Phosphor Duotone SVG 를 그대로 인라인
.rq__mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 18px; height: 18px;
border: 2px solid currentColor;
border-radius: 50%;
}
.rq__mark > svg {
transform: scale(0);
transform-origin: center;
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.rq__row.is-pass .rq__mark > svg { transform: scale(1); opacity: 1; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~20% 짧은 값(전부 미달) → 24% 길이 통과 → 40% 숫자 통과 → 56% 기호 통과(세 줄 다 켜진 장면이 poster)
// → 74·78·82% 한 줄씩 흩어서 끄고 86% 되감기. 복귀를 한 프레임에 몰면 그 프레임이 poster 로 뽑힌다.
.rq.is-demo .rq__ghost {
opacity: 1;
animation-name: rq-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__input {
transition: none;
animation-name: rq-input;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__row {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__mark > svg {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.rq.is-demo .rq__row[data-rule="0"] { animation-name: rq-row-0; }
.rq.is-demo .rq__row[data-rule="1"] { animation-name: rq-row-1; }
.rq.is-demo .rq__row[data-rule="2"] { animation-name: rq-row-2; }
.rq.is-demo .rq__row[data-rule="0"] .rq__mark > svg { animation-name: rq-pop-0; }
.rq.is-demo .rq__row[data-rule="1"] .rq__mark > svg { animation-name: rq-pop-1; }
.rq.is-demo .rq__row[data-rule="2"] .rq__mark > svg { animation-name: rq-pop-2; }
@keyframes rq-type {
0% { clip-path: inset(0 62% 0 0); }
24% { clip-path: inset(0 38% 0 0); }
40% { clip-path: inset(0 20% 0 0); }
56% { clip-path: inset(0 0 0 0); }
86% { clip-path: inset(0 62% 0 0); }
100% { clip-path: inset(0 62% 0 0); }
}
@keyframes rq-input {
0% { border-color: rgba(23, 20, 26, .2); }
56% { border-color: $ok; }
82% { border-color: rgba(23, 20, 26, .2); }
100% { border-color: rgba(23, 20, 26, .2); }
}
@keyframes rq-row-0 { 0% { color: $off; } 24% { color: $ok; } 74% { color: $off; } 100% { color: $off; } }
@keyframes rq-row-1 { 0% { color: $off; } 40% { color: $ok; } 78% { color: $off; } 100% { color: $off; } }
@keyframes rq-row-2 { 0% { color: $off; } 56% { color: $ok; } 82% { color: $off; } 100% { color: $off; } }
@keyframes rq-pop-0 {
0% { transform: scale(0); opacity: 0; }
24% { transform: scale(0); opacity: 0; }
30% { transform: scale(1.3); opacity: 1; }
34% { transform: scale(1); opacity: 1; }
74% { transform: scale(1); opacity: 1; }
75% { transform: scale(0); opacity: 0; }
100% { transform: scale(0); opacity: 0; }
}
@keyframes rq-pop-1 {
0% { transform: scale(0); opacity: 0; }
40% { transform: scale(0); opacity: 0; }
46% { transform: scale(1.3); opacity: 1; }
50% { transform: scale(1); opacity: 1; }
78% { transform: scale(1); opacity: 1; }
79% { transform: scale(0); opacity: 0; }
100% { transform: scale(0); opacity: 0; }
}
@keyframes rq-pop-2 {
0% { transform: scale(0); opacity: 0; }
56% { transform: scale(0); opacity: 0; }
62% { transform: scale(1.3); opacity: 1; }
66% { transform: scale(1); opacity: 1; }
82% { transform: scale(1); opacity: 1; }
83% { transform: scale(0); opacity: 0; }
100% { transform: scale(0); opacity: 0; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.rq__input { height: 36px; line-height: 32px; }
.rq__ghost { line-height: 36px; }
.rq__list { margin-top: $sp-2; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rq.is-demo .rq__ghost,
.rq.is-demo .rq__input,
.rq.is-demo .rq__row,
.rq.is-demo .rq__mark > svg { animation: none !important; }
.rq.is-demo .rq__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.rq.is-demo .rq__input { border-color: $ok; }
.rq.is-demo .rq__row { color: $ok; }
.rq.is-demo .rq__mark > svg { transform: scale(1); opacity: 1; }
.rq__row, .rq__input, .rq__mark > svg { 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);
}
.rq {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rq__field {
position: relative;
}
.rq__input {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 12px;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 14px;
line-height: 36px;
font-weight: 700;
letter-spacing: 0.08em;
transition: border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rq__input::placeholder {
color: rgba(23, 20, 26, 0.36);
font-weight: 600;
letter-spacing: normal;
}
.rq__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rq.is-demo .rq__input::placeholder {
color: transparent;
}
.rq__ghost {
position: absolute;
left: 14px;
top: 0;
color: #17141a;
font-size: 14px;
line-height: 40px;
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.rq__list {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.rq__row {
display: flex;
align-items: center;
gap: 8px;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
line-height: 16px;
font-weight: 700;
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rq__row.is-pass {
color: #479076;
}
.rq__mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 18px;
height: 18px;
border: 2px solid currentColor;
border-radius: 50%;
}
.rq__mark > svg {
transform: scale(0);
transform-origin: center;
opacity: 0;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rq__row.is-pass .rq__mark > svg {
transform: scale(1);
opacity: 1;
}
.rq.is-demo .rq__ghost {
opacity: 1;
animation-name: rq-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__input {
transition: none;
animation-name: rq-input;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__row {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__mark > svg {
transition: none;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.rq.is-demo .rq__row[data-rule="0"] {
animation-name: rq-row-0;
}
.rq.is-demo .rq__row[data-rule="1"] {
animation-name: rq-row-1;
}
.rq.is-demo .rq__row[data-rule="2"] {
animation-name: rq-row-2;
}
.rq.is-demo .rq__row[data-rule="0"] .rq__mark > svg {
animation-name: rq-pop-0;
}
.rq.is-demo .rq__row[data-rule="1"] .rq__mark > svg {
animation-name: rq-pop-1;
}
.rq.is-demo .rq__row[data-rule="2"] .rq__mark > svg {
animation-name: rq-pop-2;
}
@keyframes rq-type {
0% {
clip-path: inset(0 62% 0 0);
}
24% {
clip-path: inset(0 38% 0 0);
}
40% {
clip-path: inset(0 20% 0 0);
}
56% {
clip-path: inset(0 0 0 0);
}
86% {
clip-path: inset(0 62% 0 0);
}
100% {
clip-path: inset(0 62% 0 0);
}
}
@keyframes rq-input {
0% {
border-color: rgba(23, 20, 26, 0.2);
}
56% {
border-color: #479076;
}
82% {
border-color: rgba(23, 20, 26, 0.2);
}
100% {
border-color: rgba(23, 20, 26, 0.2);
}
}
@keyframes rq-row-0 {
0% {
color: rgba(23, 20, 26, 0.72);
}
24% {
color: #479076;
}
74% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes rq-row-1 {
0% {
color: rgba(23, 20, 26, 0.72);
}
40% {
color: #479076;
}
78% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes rq-row-2 {
0% {
color: rgba(23, 20, 26, 0.72);
}
56% {
color: #479076;
}
82% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes rq-pop-0 {
0% {
transform: scale(0);
opacity: 0;
}
24% {
transform: scale(0);
opacity: 0;
}
30% {
transform: scale(1.3);
opacity: 1;
}
34% {
transform: scale(1);
opacity: 1;
}
74% {
transform: scale(1);
opacity: 1;
}
75% {
transform: scale(0);
opacity: 0;
}
100% {
transform: scale(0);
opacity: 0;
}
}
@keyframes rq-pop-1 {
0% {
transform: scale(0);
opacity: 0;
}
40% {
transform: scale(0);
opacity: 0;
}
46% {
transform: scale(1.3);
opacity: 1;
}
50% {
transform: scale(1);
opacity: 1;
}
78% {
transform: scale(1);
opacity: 1;
}
79% {
transform: scale(0);
opacity: 0;
}
100% {
transform: scale(0);
opacity: 0;
}
}
@keyframes rq-pop-2 {
0% {
transform: scale(0);
opacity: 0;
}
56% {
transform: scale(0);
opacity: 0;
}
62% {
transform: scale(1.3);
opacity: 1;
}
66% {
transform: scale(1);
opacity: 1;
}
82% {
transform: scale(1);
opacity: 1;
}
83% {
transform: scale(0);
opacity: 0;
}
100% {
transform: scale(0);
opacity: 0;
}
}
@media (max-width: 360px) {
.rq__input {
height: 36px;
line-height: 32px;
}
.rq__ghost {
line-height: 36px;
}
.rq__list {
margin-top: 8px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rq.is-demo .rq__ghost,
.rq.is-demo .rq__input,
.rq.is-demo .rq__row,
.rq.is-demo .rq__mark > svg {
animation: none !important;
}
.rq.is-demo .rq__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.rq.is-demo .rq__input {
border-color: #479076;
}
.rq.is-demo .rq__row {
color: #479076;
}
.rq.is-demo .rq__mark > svg {
transform: scale(1);
opacity: 1;
}
.rq__row, .rq__input, .rq__mark > svg {
transition: none;
}
}// 실제 동작: 요건마다 자기 정규식을 갖고, 한 글자 칠 때마다 통과한 줄만 확정 색으로 넘어간다.
var rq = document.getElementById('rq');
var rqInput = document.getElementById('rq-input');
var rqRows = [].slice.call(document.querySelectorAll('.rq__row'));
var RQ_RULES = [/^.{9,}$/, /\d/, /[^\w\s]/];
function rqPaint() {
rq.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var done = 0;
rqRows.forEach(function (row, i) {
var pass = RQ_RULES[i].test(rqInput.value);
row.classList.toggle('is-pass', pass);
if (pass) done += 1;
});
rqInput.setAttribute('aria-invalid', done === RQ_RULES.length ? 'false' : 'true');
}
rqInput.addEventListener('input', rqPaint);
rqInput.addEventListener('focus', rqPaint);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RequirementChecklist.module.scss";
export type Requirement = { text: string; test: RegExp };
export type RequirementChecklistProps = {
placeholder?: string;
rules?: Requirement[];
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
const DEFAULT_RULES: Requirement[] = [
{ text: "Over eight characters", test: /^.{9,}$/ },
{ text: "Include a digit", test: /\d/ },
{ text: "Include a symbol", test: /[^\w\s]/ },
];
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
export default function RequirementChecklist({
placeholder = "Create a password",
rules = DEFAULT_RULES,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: RequirementChecklistProps) {
const [value, setValue] = useState("");
const [live, setLive] = useState(demo);
const passed = rules.map((r) => r.test.test(value));
const allDone = passed.every(Boolean);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars}>
<div className={styles.field}>
<input
className={styles.input}
id="rq-input"
type="text"
autoComplete="off"
placeholder={placeholder}
aria-describedby="rq-list"
aria-invalid={!allDone}
value={value}
onChange={(e: ChangeEvt) => { setValue(e.target.value); setLive(false); }}
onFocus={() => setLive(false)}
/>
<span className={styles.ghost} aria-hidden="true">••••••••</span>
</div>
<ul className={styles.list} id="rq-list">
{rules.map((r, i) => (
<li key={r.text} className={`${styles.row} ${passed[i] ? styles.isPass : ""}`} data-rule={i}>
<span className={styles.mark} aria-hidden="true"><CheckIcon /></span>
<span className={styles.txt}>{r.text}</span>
</li>
))}
</ul>
</div>
);
}04 · Requirements checklist that fills in one by one — Measured area 2.318% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Keeping the rules in an array means that adding a requirement or reordering them never touches the markup.
var RQ_RULES = [/^.{9,}$/, /\d/, /[^\w\s]/];
function rqPaint() {
var done = 0;
rqRows.forEach(function (row, i) {
var pass = RQ_RULES[i].test(rqInput.value);
row.classList.toggle('is-pass', pass);
if (pass) done += 1;
});
rqInput.setAttribute('aria-invalid', done === RQ_RULES.length ? 'false' : 'true');
}
05Password strength gauge that scores as you type
What happens on screen
Four conditions are measured separately and collapsed into a single score from 0 to 4, and the screen reads that one number to decide how many cells light up, which word appears, and what color both take. Cells grow from the left with scaleX, and the track is split evenly by grid-template-columns: repeat(4, 1fr).

<main class="stage">
<div class="pg is-demo" id="pg" data-score="1">
<div class="pg__field">
<input class="pg__input" id="pg-input" type="text" autocomplete="off" aria-describedby="pg-out" placeholder="Type a password">
<span class="pg__ghost" aria-hidden="true">Bit9na!Shop</span>
</div>
<div class="pg__gauge" aria-hidden="true">
<span class="pg__cell" data-cell="1"></span>
<span class="pg__cell" data-cell="2"></span>
<span class="pg__cell" data-cell="3"></span>
<span class="pg__cell" data-cell="4"></span>
</div>
<p class="pg__out" id="pg-out">
<span class="pg__words" aria-hidden="true">
<span class="pg__w" data-w="low">Weak</span><span class="pg__w" data-w="mid">Fair</span><span class="pg__w" data-w="high">Strong</span>
</span>
<span class="pg__nums" aria-hidden="true">
<span class="pg__n" data-n="0">0 / 4</span><span class="pg__n" data-n="1">1 / 4</span><span class="pg__n" data-n="2">2 / 4</span><span class="pg__n" data-n="3">3 / 4</span><span class="pg__n" data-n="4">4 / 4</span>
</span>
<span class="pg__say" id="pg-say" role="status" aria-live="polite">Only one of the four rules is met</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$low: $error;
$mid: #cab493;
$high: $subject-mint;
.pg {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pg__field { position: relative; }
.pg__input {
box-sizing: border-box;
width: 100%; height: 40px;
padding: 0 $sp-3;
border: 2px solid rgba(255, 255, 255, .18);
border-radius: $r-control;
background: rgba(255, 255, 255, .06);
color: $subject-cream;
font-family: inherit;
font-size: 14px; line-height: 36px; font-weight: 700;
letter-spacing: .04em;
}
.pg__input::placeholder { color: rgba(255, 255, 255, .38); font-weight: 600; letter-spacing: normal; }
.pg__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 진짜 placeholder 와 겹치지 않게 지운다
.pg.is-demo .pg__input::placeholder { color: transparent; }
.pg__ghost {
position: absolute;
left: 14px; top: 0;
color: $subject-cream;
font-size: 14px; line-height: 40px; font-weight: 700;
letter-spacing: .04em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 네 칸 게이지 — 칸마다 왼쪽에서 자란다 ────────────────
.pg__gauge {
// 격자로 넷을 똑같이 나눈다 — flex 균등분배(1 1 0)는 고정 px 지오메트리와 섞이면 어긋난다
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
margin-top: $sp-3;
}
.pg__cell {
position: relative;
height: 6px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
overflow: hidden;
}
.pg__cell::after {
content: "";
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $low;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 네 칸 게이지 좌측 채움
transition: transform $duration $easing, background $duration $easing;
}
.pg[data-score="1"] .pg__cell[data-cell="1"]::after,
.pg[data-score="2"] .pg__cell[data-cell="1"]::after,
.pg[data-score="2"] .pg__cell[data-cell="2"]::after,
.pg[data-score="3"] .pg__cell[data-cell="1"]::after,
.pg[data-score="3"] .pg__cell[data-cell="2"]::after,
.pg[data-score="3"] .pg__cell[data-cell="3"]::after,
.pg[data-score="4"] .pg__cell::after { transform: scaleX(1); }
.pg[data-score="2"] .pg__cell::after,
.pg[data-score="3"] .pg__cell::after { background: $mid; }
.pg[data-score="4"] .pg__cell::after { background: $high; }
// ── 등급 한 줄 — 낱말과 점수를 겹쳐 두고 하나만 켠다 ──────
.pg__out {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
height: 16px;
margin: $sp-2 0 0;
font-size: 11px; line-height: 16px; font-weight: 700;
}
.pg__words, .pg__nums {
position: relative;
display: inline-block;
flex: 0 0 auto;
height: 16px;
}
.pg__words { width: 28px; }
.pg__nums { width: 32px; color: rgba(255, 255, 255, .52); }
.pg__w, .pg__n {
position: absolute;
left: 0; top: 0;
white-space: nowrap;
opacity: 0;
}
.pg__w[data-w="low"] { color: $low; }
.pg__w[data-w="mid"] { color: $mid; }
.pg__w[data-w="high"] { color: $high; }
.pg[data-score="0"] .pg__w[data-w="low"],
.pg[data-score="1"] .pg__w[data-w="low"],
.pg[data-score="2"] .pg__w[data-w="mid"],
.pg[data-score="3"] .pg__w[data-w="mid"],
.pg[data-score="4"] .pg__w[data-w="high"] { opacity: 1; }
.pg[data-score="0"] .pg__n[data-n="0"],
.pg[data-score="1"] .pg__n[data-n="1"],
.pg[data-score="2"] .pg__n[data-n="2"],
.pg[data-score="3"] .pg__n[data-n="3"],
.pg[data-score="4"] .pg__n[data-n="4"] { opacity: 1; }
.pg__say {
// 등급은 눈으로 칸이 말해 준다 — 문장은 낭독기 쪽으로만 보낸다
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~16% 1점 → 24% 2점 → 42% 3점 → 60% 4점(강함이 가장 큰 변화 = poster)
// → 76·80·84% 칸을 하나씩 흩어서 끄고 88% 되감기. 등급 낱말 교체는 steps(1, end) 컷 — 겹치면 두 낱말이 반투명하게 포개진다.
.pg.is-demo .pg__ghost {
opacity: 1;
animation-name: pg-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pg.is-demo .pg__cell::after {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pg.is-demo .pg__cell[data-cell="1"]::after { animation-name: pg-c1; }
.pg.is-demo .pg__cell[data-cell="2"]::after { animation-name: pg-c2; }
.pg.is-demo .pg__cell[data-cell="3"]::after { animation-name: pg-c3; }
.pg.is-demo .pg__cell[data-cell="4"]::after { animation-name: pg-c4; }
.pg.is-demo .pg__w,
.pg.is-demo .pg__n {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pg.is-demo .pg__w[data-w="low"] { animation-name: pg-w-low; }
.pg.is-demo .pg__w[data-w="mid"] { animation-name: pg-w-mid; }
.pg.is-demo .pg__w[data-w="high"] { animation-name: pg-w-high; }
.pg.is-demo .pg__n[data-n="1"] { animation-name: pg-n1; }
.pg.is-demo .pg__n[data-n="2"] { animation-name: pg-n2; }
.pg.is-demo .pg__n[data-n="3"] { animation-name: pg-n3; }
.pg.is-demo .pg__n[data-n="4"] { animation-name: pg-n4; }
@keyframes pg-type {
0% { clip-path: inset(0 64% 0 0); }
24% { clip-path: inset(0 46% 0 0); }
42% { clip-path: inset(0 22% 0 0); }
60% { clip-path: inset(0 0 0 0); }
88% { clip-path: inset(0 64% 0 0); }
100% { clip-path: inset(0 64% 0 0); }
}
@keyframes pg-c1 {
0% { transform: scaleX(1); background: $low; }
23.4% { transform: scaleX(1); background: $low; }
24% { transform: scaleX(1); background: $mid; }
59.4% { transform: scaleX(1); background: $mid; }
60% { transform: scaleX(1); background: $high; }
84.4% { transform: scaleX(1); background: $high; }
85% { transform: scaleX(1); background: $low; }
100% { transform: scaleX(1); background: $low; }
}
@keyframes pg-c2 {
0% { transform: scaleX(0); background: $mid; }
20% { transform: scaleX(0); background: $mid; }
24% { transform: scaleX(1); background: $mid; }
30% { transform: scaleX(1); background: $mid; }
59.4% { transform: scaleX(1); background: $mid; }
60% { transform: scaleX(1); background: $high; }
83% { transform: scaleX(1); background: $high; }
84% { transform: scaleX(0); background: $high; }
100% { transform: scaleX(0); background: $high; }
}
@keyframes pg-c3 {
0% { transform: scaleX(0); background: $mid; }
38% { transform: scaleX(0); background: $mid; }
42% { transform: scaleX(1); background: $mid; }
59.4% { transform: scaleX(1); background: $mid; }
60% { transform: scaleX(1); background: $high; }
79% { transform: scaleX(1); background: $high; }
80% { transform: scaleX(0); background: $high; }
100% { transform: scaleX(0); background: $high; }
}
@keyframes pg-c4 {
0% { transform: scaleX(0); background: $high; }
56% { transform: scaleX(0); background: $high; }
60% { transform: scaleX(1); background: $high; }
75% { transform: scaleX(1); background: $high; }
76% { transform: scaleX(0); background: $high; }
100% { transform: scaleX(0); background: $high; }
}
@keyframes pg-w-low { 0% { opacity: 1; } 24% { opacity: 0; } 85% { opacity: 1; } 100% { opacity: 1; } }
@keyframes pg-w-mid { 0% { opacity: 0; } 24% { opacity: 1; } 60% { opacity: 0; } 100% { opacity: 0; } }
@keyframes pg-w-high { 0% { opacity: 0; } 60% { opacity: 1; } 85% { opacity: 0; } 100% { opacity: 0; } }
@keyframes pg-n1 { 0% { opacity: 1; } 24% { opacity: 0; } 85% { opacity: 1; } 100% { opacity: 1; } }
@keyframes pg-n2 { 0% { opacity: 0; } 24% { opacity: 1; } 42% { opacity: 0; } 100% { opacity: 0; } }
@keyframes pg-n3 { 0% { opacity: 0; } 42% { opacity: 1; } 60% { opacity: 0; } 100% { opacity: 0; } }
@keyframes pg-n4 { 0% { opacity: 0; } 60% { opacity: 1; } 85% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.pg__input { height: 36px; line-height: 32px; }
.pg__ghost { line-height: 36px; }
.pg__gauge { margin-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pg.is-demo .pg__ghost,
.pg.is-demo .pg__cell::after,
.pg.is-demo .pg__w,
.pg.is-demo .pg__n { animation: none !important; }
.pg.is-demo .pg__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.pg.is-demo .pg__cell::after { transform: scaleX(1); background: $high; }
.pg.is-demo .pg__w[data-w="high"],
.pg.is-demo .pg__n[data-n="4"] { opacity: 1; }
.pg__cell::after { 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);
}
.pg {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pg__field {
position: relative;
}
.pg__input {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 12px;
border: 2px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-family: inherit;
font-size: 14px;
line-height: 36px;
font-weight: 700;
letter-spacing: 0.04em;
}
.pg__input::placeholder {
color: rgba(255, 255, 255, 0.38);
font-weight: 600;
letter-spacing: normal;
}
.pg__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pg.is-demo .pg__input::placeholder {
color: transparent;
}
.pg__ghost {
position: absolute;
left: 14px;
top: 0;
color: #fff7e6;
font-size: 14px;
line-height: 40px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.pg__gauge {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-top: 12px;
}
.pg__cell {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.pg__cell::after {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: #c2361a;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pg[data-score="1"] .pg__cell[data-cell="1"]::after,
.pg[data-score="2"] .pg__cell[data-cell="1"]::after,
.pg[data-score="2"] .pg__cell[data-cell="2"]::after,
.pg[data-score="3"] .pg__cell[data-cell="1"]::after,
.pg[data-score="3"] .pg__cell[data-cell="2"]::after,
.pg[data-score="3"] .pg__cell[data-cell="3"]::after,
.pg[data-score="4"] .pg__cell::after {
transform: scaleX(1);
}
.pg[data-score="2"] .pg__cell::after,
.pg[data-score="3"] .pg__cell::after {
background: #cab493;
}
.pg[data-score="4"] .pg__cell::after {
background: #4cd4a6;
}
.pg__out {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 16px;
margin: 8px 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.pg__words, .pg__nums {
position: relative;
display: inline-block;
flex: 0 0 auto;
height: 16px;
}
.pg__words {
width: 28px;
}
.pg__nums {
width: 32px;
color: rgba(255, 255, 255, 0.52);
}
.pg__w, .pg__n {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
opacity: 0;
}
.pg__w[data-w=low] {
color: #c2361a;
}
.pg__w[data-w=mid] {
color: #cab493;
}
.pg__w[data-w=high] {
color: #4cd4a6;
}
.pg[data-score="0"] .pg__w[data-w=low],
.pg[data-score="1"] .pg__w[data-w=low],
.pg[data-score="2"] .pg__w[data-w=mid],
.pg[data-score="3"] .pg__w[data-w=mid],
.pg[data-score="4"] .pg__w[data-w=high] {
opacity: 1;
}
.pg[data-score="0"] .pg__n[data-n="0"],
.pg[data-score="1"] .pg__n[data-n="1"],
.pg[data-score="2"] .pg__n[data-n="2"],
.pg[data-score="3"] .pg__n[data-n="3"],
.pg[data-score="4"] .pg__n[data-n="4"] {
opacity: 1;
}
.pg__say {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.pg.is-demo .pg__ghost {
opacity: 1;
animation-name: pg-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pg.is-demo .pg__cell::after {
transition: none;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pg.is-demo .pg__cell[data-cell="1"]::after {
animation-name: pg-c1;
}
.pg.is-demo .pg__cell[data-cell="2"]::after {
animation-name: pg-c2;
}
.pg.is-demo .pg__cell[data-cell="3"]::after {
animation-name: pg-c3;
}
.pg.is-demo .pg__cell[data-cell="4"]::after {
animation-name: pg-c4;
}
.pg.is-demo .pg__w,
.pg.is-demo .pg__n {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pg.is-demo .pg__w[data-w=low] {
animation-name: pg-w-low;
}
.pg.is-demo .pg__w[data-w=mid] {
animation-name: pg-w-mid;
}
.pg.is-demo .pg__w[data-w=high] {
animation-name: pg-w-high;
}
.pg.is-demo .pg__n[data-n="1"] {
animation-name: pg-n1;
}
.pg.is-demo .pg__n[data-n="2"] {
animation-name: pg-n2;
}
.pg.is-demo .pg__n[data-n="3"] {
animation-name: pg-n3;
}
.pg.is-demo .pg__n[data-n="4"] {
animation-name: pg-n4;
}
@keyframes pg-type {
0% {
clip-path: inset(0 64% 0 0);
}
24% {
clip-path: inset(0 46% 0 0);
}
42% {
clip-path: inset(0 22% 0 0);
}
60% {
clip-path: inset(0 0 0 0);
}
88% {
clip-path: inset(0 64% 0 0);
}
100% {
clip-path: inset(0 64% 0 0);
}
}
@keyframes pg-c1 {
0% {
transform: scaleX(1);
background: #c2361a;
}
23.4% {
transform: scaleX(1);
background: #c2361a;
}
24% {
transform: scaleX(1);
background: #cab493;
}
59.4% {
transform: scaleX(1);
background: #cab493;
}
60% {
transform: scaleX(1);
background: #4cd4a6;
}
84.4% {
transform: scaleX(1);
background: #4cd4a6;
}
85% {
transform: scaleX(1);
background: #c2361a;
}
100% {
transform: scaleX(1);
background: #c2361a;
}
}
@keyframes pg-c2 {
0% {
transform: scaleX(0);
background: #cab493;
}
20% {
transform: scaleX(0);
background: #cab493;
}
24% {
transform: scaleX(1);
background: #cab493;
}
30% {
transform: scaleX(1);
background: #cab493;
}
59.4% {
transform: scaleX(1);
background: #cab493;
}
60% {
transform: scaleX(1);
background: #4cd4a6;
}
83% {
transform: scaleX(1);
background: #4cd4a6;
}
84% {
transform: scaleX(0);
background: #4cd4a6;
}
100% {
transform: scaleX(0);
background: #4cd4a6;
}
}
@keyframes pg-c3 {
0% {
transform: scaleX(0);
background: #cab493;
}
38% {
transform: scaleX(0);
background: #cab493;
}
42% {
transform: scaleX(1);
background: #cab493;
}
59.4% {
transform: scaleX(1);
background: #cab493;
}
60% {
transform: scaleX(1);
background: #4cd4a6;
}
79% {
transform: scaleX(1);
background: #4cd4a6;
}
80% {
transform: scaleX(0);
background: #4cd4a6;
}
100% {
transform: scaleX(0);
background: #4cd4a6;
}
}
@keyframes pg-c4 {
0% {
transform: scaleX(0);
background: #4cd4a6;
}
56% {
transform: scaleX(0);
background: #4cd4a6;
}
60% {
transform: scaleX(1);
background: #4cd4a6;
}
75% {
transform: scaleX(1);
background: #4cd4a6;
}
76% {
transform: scaleX(0);
background: #4cd4a6;
}
100% {
transform: scaleX(0);
background: #4cd4a6;
}
}
@keyframes pg-w-low {
0% {
opacity: 1;
}
24% {
opacity: 0;
}
85% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes pg-w-mid {
0% {
opacity: 0;
}
24% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pg-w-high {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pg-n1 {
0% {
opacity: 1;
}
24% {
opacity: 0;
}
85% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes pg-n2 {
0% {
opacity: 0;
}
24% {
opacity: 1;
}
42% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pg-n3 {
0% {
opacity: 0;
}
42% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pg-n4 {
0% {
opacity: 0;
}
60% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.pg__input {
height: 36px;
line-height: 32px;
}
.pg__ghost {
line-height: 36px;
}
.pg__gauge {
margin-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg.is-demo .pg__ghost,
.pg.is-demo .pg__cell::after,
.pg.is-demo .pg__w,
.pg.is-demo .pg__n {
animation: none !important;
}
.pg.is-demo .pg__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.pg.is-demo .pg__cell::after {
transform: scaleX(1);
background: #4cd4a6;
}
.pg.is-demo .pg__w[data-w=high],
.pg.is-demo .pg__n[data-n="4"] {
opacity: 1;
}
.pg__cell::after {
transition: none;
}
}// 실제 동작: 길이·대소문자·숫자·기호 네 조건을 각각 재서 점수를 만들고, 그 점수 하나가 칸 수와 등급을 함께 정한다.
var pg = document.getElementById('pg');
var pgInput = document.getElementById('pg-input');
var pgSay = document.getElementById('pg-say');
var PG_SAY = ['None of the four rules is met yet', 'Only one of the four rules is met', 'Two of the four rules are met',
'One more rule and it turns strong', 'All four rules are met'];
function pgScore(v) {
var n = 0;
if (/^.{10,}$/.test(v)) n += 1;
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) n += 1;
if (/\d/.test(v)) n += 1;
if (/[^\w\s]/.test(v)) n += 1;
return n;
}
function pgPaint() {
pg.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var n = pgScore(pgInput.value);
pg.setAttribute('data-score', String(n));
pgSay.textContent = PG_SAY[n];
}
pgInput.addEventListener('input', pgPaint);
pgInput.addEventListener('focus', pgPaint);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PasswordScoreGauge.module.scss";
export type PasswordScoreGaugeProps = {
placeholder?: string;
words?: [string, string, string];
messages?: 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_MESSAGES = [
"None of the four rules are met",
"Only one of the four rules is met",
"Two rules are met",
"One more rule and it is strong",
"All four rules are met",
];
export function scoreOf(v: string) {
let n = 0;
if (/^.{10,}$/.test(v)) n += 1;
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) n += 1;
if (/\d/.test(v)) n += 1;
if (/[^\w\s]/.test(v)) n += 1;
return n;
}
export default function PasswordScoreGauge({
placeholder = "Type a password",
words = ["Weak", "Fair", "Strong"],
messages = DEFAULT_MESSAGES,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PasswordScoreGaugeProps) {
const [value, setValue] = useState("");
const [live, setLive] = useState(demo);
const score = scoreOf(value);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} data-score={score}>
<div className={styles.field}>
<input
className={styles.input}
id="pg-input"
type="text"
autoComplete="off"
placeholder={placeholder}
aria-describedby="pg-out"
value={value}
onChange={(e: ChangeEvt) => { setValue(e.target.value); setLive(false); }}
onFocus={() => setLive(false)}
/>
<span className={styles.ghost} aria-hidden="true">Bit9na!Shop</span>
</div>
<div className={styles.gauge} aria-hidden="true">
{[1, 2, 3, 4].map((n) => (
<span key={n} className={styles.cell} data-cell={n} />
))}
</div>
<p className={styles.out} id="pg-out">
<span className={styles.words} aria-hidden="true">
<span className={styles.w} data-w="low">{words[0]}</span>
<span className={styles.w} data-w="mid">{words[1]}</span>
<span className={styles.w} data-w="high">{words[2]}</span>
</span>
<span className={styles.nums} aria-hidden="true">
{[0, 1, 2, 3, 4].map((n) => (
<span key={n} className={styles.n} data-n={n}>{n} / 4</span>
))}
</span>
<span className={styles.say} role="status" aria-live="polite">{messages[score]}</span>
</p>
</div>
);
}05 · Password strength gauge that scores as you type — Measured area 1.597% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The three grade words sit stacked, with only one turned on at a time.
function pgScore(v) {
var n = 0;
if (/^.{10,}$/.test(v)) n += 1;
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) n += 1;
if (/\d/.test(v)) n += 1;
if (/[^\w\s]/.test(v)) n += 1;
return n;
}
pg.setAttribute('data-score', String(pgScore(pgInput.value)));
06Character counter that turns warning near the limit
What moves
maxlength stops the overflow in the browser, so the script only counts what is left and watches for a threshold crossing. Twelve characters left flips it to warning, zero flips it to stop, and the digits, the bar, and the border all follow that one level.

<main class="stage">
<div class="cc is-demo" id="cc" data-level="ok">
<div class="cc__field">
<textarea class="cc__input" id="cc-input" rows="2" maxlength="60" aria-describedby="cc-out" placeholder="Leave a one-line review"></textarea>
<span class="cc__ghost" aria-hidden="true">It arrived in a day and the packaging was careful</span>
</div>
<div class="cc__bar" aria-hidden="true"><span class="cc__fill"></span></div>
<p class="cc__out" id="cc-out">
<span class="cc__nums" aria-hidden="true">
<span class="cc__n" data-n="0">44 left</span><span class="cc__n" data-n="1">26 left</span><span class="cc__n" data-n="2">8 left</span><span class="cc__n" data-n="3">0 left</span>
</span>
<span class="cc__live" id="cc-left">44 left</span>
<span class="cc__notes" aria-hidden="true">
<span class="cc__note" data-note="near">Almost at the limit</span><span class="cc__note" data-note="full">No room left</span>
</span>
<span class="cc__say" id="cc-say" role="status" aria-live="polite">16 of 60 characters used</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
$warn: #96734a;
$stop: $error;
.cc {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cc__field { position: relative; }
.cc__input {
box-sizing: border-box;
display: block;
width: 100%;
padding: $sp-2 $sp-3;
border: 2px solid rgba(23, 20, 26, .22);
border-radius: $r-card;
background: $stage-paper;
color: $color;
font-family: inherit;
font-size: 13px; line-height: 18px; font-weight: 600;
resize: none;
}
.cc__input::placeholder { color: rgba(23, 20, 26, .38); }
.cc__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.cc[data-level="near"] .cc__input { border-color: $warn; }
.cc[data-level="full"] .cc__input { border-color: $stop; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 진짜 placeholder 와 겹치지 않게 지운다
.cc.is-demo .cc__input::placeholder { color: transparent; }
.cc__ghost {
position: absolute;
left: 14px; top: 10px;
width: 268px;
color: $color;
font-size: 13px; line-height: 18px; font-weight: 600;
pointer-events: none;
opacity: 0;
}
// ── 진행 막대 ────────────────────────────────────────
.cc__bar {
position: relative;
height: 6px;
margin-top: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
}
.cc__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $color;
transform: scaleX(.27);
transform-origin: left center; // 규칙8: 진행 막대 좌측 채움
transition: transform $duration $easing, background $duration $easing;
}
.cc[data-level="near"] .cc__fill { background: $warn; }
.cc[data-level="full"] .cc__fill { background: $stop; }
// ── 남은 글자 줄 — 숫자와 경고 문구를 겹쳐 두고 하나만 켠다 ─
.cc__out {
display: flex;
align-items: center;
gap: $sp-2;
height: 16px;
margin: $sp-2 0 0;
font-size: 11px; line-height: 16px; font-weight: 700;
}
.cc__nums, .cc__notes {
position: relative;
display: inline-block;
flex: 0 0 auto;
height: 16px;
}
.cc__nums { width: 56px; }
.cc__notes { width: 88px; }
.cc__n, .cc__note {
position: absolute;
left: 0; top: 0;
white-space: nowrap;
opacity: 0;
}
.cc__n { color: rgba(23, 20, 26, .72); }
.cc__note[data-note="near"] { color: $warn; }
.cc__note[data-note="full"] { color: $stop; }
.cc[data-level="near"] .cc__note[data-note="near"],
.cc[data-level="full"] .cc__note[data-note="full"] { opacity: 1; }
// 자동 루프는 겹쳐 둔 숫자를 갈아 끼우고, 사람이 칠 때는 JS 가 적어 넣는 한 줄만 남긴다
.cc__live {
display: none;
flex: 0 0 auto;
width: 56px;
color: rgba(23, 20, 26, .72);
}
.cc[data-level="near"] .cc__live { color: $warn; }
.cc[data-level="full"] .cc__live { color: $stop; }
.cc:not(.is-demo) .cc__live { display: inline-block; }
.cc:not(.is-demo) .cc__nums { display: none; }
.cc__say {
// 숫자는 눈으로 읽고, 문장은 낭독기 쪽으로만 보낸다
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~18% 넉넉한 상태 → 26% 절반 → 46% 경계를 지나 경고색(컷) → 62% 한도에 닿아 정지색
// → 76% 경고 문구 내리고 82% 막대·색 되돌린 뒤 86% 글자 되감기. 셋으로 흩어야 복귀 프레임이 poster 를 뺏지 않는다.
.cc.is-demo .cc__ghost {
opacity: 1;
animation-name: cc-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__fill {
transition: none;
animation-name: cc-fill;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cc.is-demo .cc__input {
animation-name: cc-input;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__n,
.cc.is-demo .cc__note {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__n[data-n="0"] { animation-name: cc-n0; }
.cc.is-demo .cc__n[data-n="1"] { animation-name: cc-n1; }
.cc.is-demo .cc__n[data-n="2"] { animation-name: cc-n2; }
.cc.is-demo .cc__n[data-n="3"] { animation-name: cc-n3; }
.cc.is-demo .cc__note[data-note="near"] { animation-name: cc-near; }
.cc.is-demo .cc__note[data-note="full"] { animation-name: cc-full; }
@keyframes cc-type {
0% { clip-path: inset(0 74% 0 0); }
26% { clip-path: inset(0 46% 0 0); }
46% { clip-path: inset(0 18% 0 0); }
62% { clip-path: inset(0 0 0 0); }
86% { clip-path: inset(0 74% 0 0); }
100% { clip-path: inset(0 74% 0 0); }
}
@keyframes cc-fill {
0% { transform: scaleX(.27); background: $color; }
26% { transform: scaleX(.57); background: $color; }
45.4% { transform: scaleX(.86); background: $color; }
46% { transform: scaleX(.86); background: $warn; }
61.4% { transform: scaleX(1); background: $warn; }
62% { transform: scaleX(1); background: $stop; }
81% { transform: scaleX(1); background: $stop; }
82% { transform: scaleX(.27); background: $color; }
100% { transform: scaleX(.27); background: $color; }
}
@keyframes cc-input {
0% { border-color: rgba(23, 20, 26, .22); }
46% { border-color: $warn; }
62% { border-color: $stop; }
82% { border-color: rgba(23, 20, 26, .22); }
100% { border-color: rgba(23, 20, 26, .22); }
}
@keyframes cc-n0 {
0% { opacity: 1; color: rgba(23, 20, 26, .72); }
26% { opacity: 0; color: rgba(23, 20, 26, .72); }
82% { opacity: 1; color: rgba(23, 20, 26, .72); }
100% { opacity: 1; color: rgba(23, 20, 26, .72); }
}
@keyframes cc-n1 {
0% { opacity: 0; }
26% { opacity: 1; }
46% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cc-n2 {
0% { opacity: 0; color: $warn; }
46% { opacity: 1; color: $warn; }
62% { opacity: 0; color: $warn; }
100% { opacity: 0; color: $warn; }
}
@keyframes cc-n3 {
0% { opacity: 0; color: $stop; }
62% { opacity: 1; color: $stop; }
82% { opacity: 0; color: $stop; }
100% { opacity: 0; color: $stop; }
}
@keyframes cc-near {
0% { opacity: 0; }
46% { opacity: 1; }
62% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cc-full {
0% { opacity: 0; }
62% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.cc__ghost { width: 228px; }
.cc__bar { margin-top: $sp-1; }
.cc__out { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cc.is-demo .cc__ghost,
.cc.is-demo .cc__fill,
.cc.is-demo .cc__input,
.cc.is-demo .cc__n,
.cc.is-demo .cc__note { animation: none !important; }
.cc.is-demo .cc__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.cc.is-demo .cc__fill { transform: scaleX(1); background: $stop; }
.cc.is-demo .cc__input { border-color: $stop; }
.cc.is-demo .cc__n[data-n="3"] { opacity: 1; color: $stop; }
.cc.is-demo .cc__note[data-note="full"] { opacity: 1; }
.cc__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cc {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cc__field {
position: relative;
}
.cc__input {
box-sizing: border-box;
display: block;
width: 100%;
padding: 8px 12px;
border: 2px solid rgba(23, 20, 26, 0.22);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 18px;
font-weight: 600;
resize: none;
}
.cc__input::placeholder {
color: rgba(23, 20, 26, 0.38);
}
.cc__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cc[data-level=near] .cc__input {
border-color: #96734a;
}
.cc[data-level=full] .cc__input {
border-color: #c2361a;
}
.cc.is-demo .cc__input::placeholder {
color: transparent;
}
.cc__ghost {
position: absolute;
left: 14px;
top: 10px;
width: 268px;
color: #17141a;
font-size: 13px;
line-height: 18px;
font-weight: 600;
pointer-events: none;
opacity: 0;
}
.cc__bar {
position: relative;
height: 6px;
margin-top: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
overflow: hidden;
}
.cc__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: #17141a;
transform: scaleX(0.27);
transform-origin: left center;
transition: transform 220ms ease-out, background 220ms ease-out;
}
.cc[data-level=near] .cc__fill {
background: #96734a;
}
.cc[data-level=full] .cc__fill {
background: #c2361a;
}
.cc__out {
display: flex;
align-items: center;
gap: 8px;
height: 16px;
margin: 8px 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.cc__nums, .cc__notes {
position: relative;
display: inline-block;
flex: 0 0 auto;
height: 16px;
}
.cc__nums {
width: 56px;
}
.cc__notes {
width: 88px;
}
.cc__n, .cc__note {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
opacity: 0;
}
.cc__n {
color: rgba(23, 20, 26, 0.72);
}
.cc__note[data-note=near] {
color: #96734a;
}
.cc__note[data-note=full] {
color: #c2361a;
}
.cc[data-level=near] .cc__note[data-note=near],
.cc[data-level=full] .cc__note[data-note=full] {
opacity: 1;
}
.cc__live {
display: none;
flex: 0 0 auto;
width: 56px;
color: rgba(23, 20, 26, 0.72);
}
.cc[data-level=near] .cc__live {
color: #96734a;
}
.cc[data-level=full] .cc__live {
color: #c2361a;
}
.cc:not(.is-demo) .cc__live {
display: inline-block;
}
.cc:not(.is-demo) .cc__nums {
display: none;
}
.cc__say {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.cc.is-demo .cc__ghost {
opacity: 1;
animation-name: cc-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__fill {
transition: none;
animation-name: cc-fill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.cc.is-demo .cc__input {
animation-name: cc-input;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__n,
.cc.is-demo .cc__note {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cc.is-demo .cc__n[data-n="0"] {
animation-name: cc-n0;
}
.cc.is-demo .cc__n[data-n="1"] {
animation-name: cc-n1;
}
.cc.is-demo .cc__n[data-n="2"] {
animation-name: cc-n2;
}
.cc.is-demo .cc__n[data-n="3"] {
animation-name: cc-n3;
}
.cc.is-demo .cc__note[data-note=near] {
animation-name: cc-near;
}
.cc.is-demo .cc__note[data-note=full] {
animation-name: cc-full;
}
@keyframes cc-type {
0% {
clip-path: inset(0 74% 0 0);
}
26% {
clip-path: inset(0 46% 0 0);
}
46% {
clip-path: inset(0 18% 0 0);
}
62% {
clip-path: inset(0 0 0 0);
}
86% {
clip-path: inset(0 74% 0 0);
}
100% {
clip-path: inset(0 74% 0 0);
}
}
@keyframes cc-fill {
0% {
transform: scaleX(0.27);
background: #17141a;
}
26% {
transform: scaleX(0.57);
background: #17141a;
}
45.4% {
transform: scaleX(0.86);
background: #17141a;
}
46% {
transform: scaleX(0.86);
background: #96734a;
}
61.4% {
transform: scaleX(1);
background: #96734a;
}
62% {
transform: scaleX(1);
background: #c2361a;
}
81% {
transform: scaleX(1);
background: #c2361a;
}
82% {
transform: scaleX(0.27);
background: #17141a;
}
100% {
transform: scaleX(0.27);
background: #17141a;
}
}
@keyframes cc-input {
0% {
border-color: rgba(23, 20, 26, 0.22);
}
46% {
border-color: #96734a;
}
62% {
border-color: #c2361a;
}
82% {
border-color: rgba(23, 20, 26, 0.22);
}
100% {
border-color: rgba(23, 20, 26, 0.22);
}
}
@keyframes cc-n0 {
0% {
opacity: 1;
color: rgba(23, 20, 26, 0.72);
}
26% {
opacity: 0;
color: rgba(23, 20, 26, 0.72);
}
82% {
opacity: 1;
color: rgba(23, 20, 26, 0.72);
}
100% {
opacity: 1;
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes cc-n1 {
0% {
opacity: 0;
}
26% {
opacity: 1;
}
46% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cc-n2 {
0% {
opacity: 0;
color: #96734a;
}
46% {
opacity: 1;
color: #96734a;
}
62% {
opacity: 0;
color: #96734a;
}
100% {
opacity: 0;
color: #96734a;
}
}
@keyframes cc-n3 {
0% {
opacity: 0;
color: #c2361a;
}
62% {
opacity: 1;
color: #c2361a;
}
82% {
opacity: 0;
color: #c2361a;
}
100% {
opacity: 0;
color: #c2361a;
}
}
@keyframes cc-near {
0% {
opacity: 0;
}
46% {
opacity: 1;
}
62% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cc-full {
0% {
opacity: 0;
}
62% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.cc__ghost {
width: 228px;
}
.cc__bar {
margin-top: 4px;
}
.cc__out {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc.is-demo .cc__ghost,
.cc.is-demo .cc__fill,
.cc.is-demo .cc__input,
.cc.is-demo .cc__n,
.cc.is-demo .cc__note {
animation: none !important;
}
.cc.is-demo .cc__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.cc.is-demo .cc__fill {
transform: scaleX(1);
background: #c2361a;
}
.cc.is-demo .cc__input {
border-color: #c2361a;
}
.cc.is-demo .cc__n[data-n="3"] {
opacity: 1;
color: #c2361a;
}
.cc.is-demo .cc__note[data-note=full] {
opacity: 1;
}
.cc__fill {
transition: none;
}
}// 실제 동작: maxlength 가 더 못 치게 막고, 남은 글자가 경계를 지나면 숫자와 막대가 함께 경고색으로 넘어간다.
var cc = document.getElementById('cc');
var ccInput = document.getElementById('cc-input');
var ccFill = document.querySelector('.cc__fill');
var ccSay = document.getElementById('cc-say');
var ccLeft = document.getElementById('cc-left');
var CC_MAX = 60, CC_NEAR = 12;
function ccPaint() {
cc.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var used = ccInput.value.length, left = CC_MAX - used;
cc.setAttribute('data-level', left === 0 ? 'full' : left <= CC_NEAR ? 'near' : 'ok');
ccFill.style.transform = 'scaleX(' + (used / CC_MAX).toFixed(3) + ')';
ccLeft.textContent = left + ' left';
ccSay.textContent = CC_MAX + ' characters, ' + used + ' used';
}
ccInput.addEventListener('input', ccPaint);
ccInput.addEventListener('focus', ccPaint);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CharLimitCounter.module.scss";
export type CharLimitCounterProps = {
placeholder?: string;
max?: number;
/** 남은 글자가 이 수 이하로 떨어지면 경고색으로 넘어간다 */
nearAt?: number;
nearNote?: string;
fullNote?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
export default function CharLimitCounter({
placeholder = "Leave a one-line review",
max = 60,
nearAt = 12,
nearNote = "Almost at the limit",
fullNote = "No room left",
demo = true,
duration = 220,
easing = "ease-out",
color = "#17141a",
}: CharLimitCounterProps) {
const [value, setValue] = useState("");
const [live, setLive] = useState(demo);
const used = value.length;
const left = max - used;
const level = left === 0 ? "full" : left <= nearAt ? "near" : "ok";
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const fill: CSSProperties = { transform: `scaleX(${(used / max).toFixed(3)})` };
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} data-level={level}>
<div className={styles.field}>
<textarea
className={styles.input}
id="cc-input"
rows={2}
maxLength={max}
placeholder={placeholder}
aria-describedby="cc-out"
value={value}
onChange={(e: ChangeEvt) => { setValue(e.target.value); setLive(false); }}
onFocus={() => setLive(false)}
/>
<span className={styles.ghost} aria-hidden="true">It arrived in a day and the packaging was careful</span>
</div>
<div className={styles.bar} aria-hidden="true"><span className={styles.fill} style={fill} /></div>
<p className={styles.out} id="cc-out">
<span className={styles.nums} aria-hidden="true">
{[44, 26, 8, 0].map((n, i) => (
<span key={n} className={styles.n} data-n={i}>{n} left</span>
))}
</span>
<span className={styles.live}>{`${left} left`}</span>
<span className={styles.notes} aria-hidden="true">
<span className={styles.note} data-note="near">{nearNote}</span>
<span className={styles.note} data-note="full">{fullNote}</span>
</span>
<span className={styles.say} role="status" aria-live="polite">{`${used} of ${max} characters used`}</span>
</p>
</div>
);
}06 · Character counter that turns warning near the limit — Measured area 3.722% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The bar animates with scaleX rather than width.
var CC_MAX = 60, CC_NEAR = 12;
function ccPaint() {
var used = ccInput.value.length, left = CC_MAX - used;
cc.setAttribute('data-level', left === 0 ? 'full' : left <= CC_NEAR ? 'near' : 'ok');
ccFill.style.transform = 'scaleX(' + (used / CC_MAX).toFixed(3) + ')';
ccLeft.textContent = left + ' left';
ccSay.textContent = used + ' of ' + CC_MAX + ' characters used';
}
07Status icon that waits, then rules
What you see
A box that has to ask a server, like a username check, must not ask on every keystroke, so a setTimeout fires once 400ms after typing stops and any new keystroke throws the pending call away with clearTimeout.
<main class="stage">
<div class="dm is-demo" id="dm" data-state="idle">
<label class="dm__label" for="dm-input">Username</label>
<div class="dm__field">
<input class="dm__input" id="dm-input" type="text" autocomplete="off" aria-describedby="dm-say" placeholder="Type more than four characters">
<span class="dm__ghost" aria-hidden="true">bitna shop</span>
<span class="dm__icons" aria-hidden="true">
<span class="dm__ic dm__ic--wait"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg></span>
<span class="dm__ic dm__ic--ok"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="dm__ic dm__ic--no"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</span>
</div>
<p class="dm__out">
<span class="dm__msgs" aria-hidden="true">
<span class="dm__m" data-m="wait">Checking</span><span class="dm__m" data-m="ok">This username is available</span><span class="dm__m" data-m="no">This username does not fit the rule</span>
</span>
<span class="dm__say" id="dm-say" role="status" aria-live="polite">Enter a username</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
$ok: #479075;
$no: #4a0f04;
.dm {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dm__label {
display: block;
margin-bottom: $sp-1;
color: $subject-ink; // 주황 무대 위 크림 2.51:1 → 잉크 5.50:1
font-size: 11px; line-height: 14px; font-weight: 700;
}
.dm__field { position: relative; }
.dm__input {
box-sizing: border-box;
width: 100%; height: 44px;
padding: 0 $sp-10 0 $sp-3;
border: 2px solid rgba(255, 247, 230, .38);
border-radius: $r-control;
background: rgba(23, 20, 26, .18);
color: $color;
font-family: inherit;
font-size: 14px; line-height: 40px; font-weight: 700;
}
.dm__input::placeholder { color: rgba(255, 247, 230, .5); font-weight: 600; }
.dm__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 진짜 placeholder 와 겹치지 않게 지운다
.dm.is-demo .dm__input::placeholder { color: transparent; }
.dm__ghost {
position: absolute;
left: 14px; top: 0;
color: $color;
font-size: 14px; line-height: 44px; font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 상태 아이콘 세 겹 — 같은 자리에 포개 두고 하나만 켠다 ──
.dm__icons {
position: absolute;
right: 12px; top: 50%;
width: 20px; height: 20px;
transform: translateY(-50%);
}
.dm__ic {
position: absolute;
left: 0; top: 0;
display: block;
transform: scale(.4);
opacity: 0;
}
.dm__ic--wait { color: rgba(255, 247, 230, .82); }
.dm__ic--ok { color: $ok; }
.dm__ic--no { color: $no; }
.dm[data-state="wait"] .dm__ic--wait,
.dm[data-state="ok"] .dm__ic--ok,
.dm[data-state="no"] .dm__ic--no { transform: scale(1); opacity: 1; }
.dm[data-state="wait"] .dm__ic--wait {
animation-name: dm-spin;
animation-duration: $dur-enter;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.dm[data-state="ok"] .dm__input { border-color: $ok; }
.dm[data-state="no"] .dm__input { border-color: $no; }
// ── 안내 한 줄 — 문구 셋을 겹쳐 두고 하나만 켠다 ─────────
.dm__out {
position: relative;
height: 16px;
margin: $sp-2 0 0;
font-size: 11px; line-height: 16px; font-weight: 700;
}
.dm__msgs { position: relative; display: block; height: 16px; }
.dm__m {
position: absolute;
left: 0; top: 0;
white-space: nowrap;
opacity: 0;
}
.dm__m[data-m="wait"] { color: rgba(255, 247, 230, .72); }
.dm__m[data-m="ok"] { color: $ok; }
.dm__m[data-m="no"] { color: $no; }
.dm[data-state="wait"] .dm__m[data-m="wait"],
.dm[data-state="ok"] .dm__m[data-m="ok"],
.dm[data-state="no"] .dm__m[data-m="no"] { opacity: 1; }
.dm__say {
// 아이콘이 보여 주는 것을 낭독기에도 같은 뜻으로 보낸다
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 빈 칸 → 18~34% 타이핑 → 36% 회전 시작(기다리는 구간) → 54% 체크가 튀어나옴
// → 70% 뒤에 빈칸 섞인 값으로 이어 쳐 엑스가 흔들리며 들어옴 → 82·86% 흩어서 복귀.
// 아이콘 교체는 steps(1, end) 컷 — opacity 로 겹치면 체크와 엑스가 반투명하게 포개진 프레임이 남는다.
.dm.is-demo .dm__ghost {
opacity: 1;
animation-name: dm-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__input {
animation-name: dm-input;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--wait {
animation-name: dm-wait, dm-spin;
animation-duration: $dur-loop, $dur-enter;
animation-timing-function: steps(1, end), linear;
animation-iteration-count: infinite, infinite;
}
.dm.is-demo .dm__ic--ok {
animation-name: dm-ok;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--no {
animation-name: dm-no;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m[data-m="wait"] { animation-name: dm-m-wait; }
.dm.is-demo .dm__m[data-m="ok"] { animation-name: dm-m-ok; }
.dm.is-demo .dm__m[data-m="no"] { animation-name: dm-m-no; }
@keyframes dm-spin {
0% { rotate: 0deg; }
100% { rotate: 360deg; }
}
@keyframes dm-type {
0% { clip-path: inset(0 100% 0 0); }
18% { clip-path: inset(0 100% 0 0); }
26% { clip-path: inset(0 70% 0 0); }
34% { clip-path: inset(0 50% 0 0); }
66% { clip-path: inset(0 50% 0 0); }
70% { clip-path: inset(0 0 0 0); }
86% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes dm-input {
0% { border-color: rgba(255, 247, 230, .38); }
54% { border-color: $ok; }
70% { border-color: $no; }
82% { border-color: rgba(255, 247, 230, .38); }
100% { border-color: rgba(255, 247, 230, .38); }
}
@keyframes dm-wait {
0% { opacity: 0; transform: scale(.4); }
36% { opacity: 1; transform: scale(1); }
54% { opacity: 0; transform: scale(.4); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes dm-ok {
0% { opacity: 0; transform: scale(.4); }
53.9% { opacity: 0; transform: scale(.4); }
54% { opacity: 1; transform: scale(1.35); }
60% { opacity: 1; transform: scale(1); }
69.9% { opacity: 1; transform: scale(1); }
70% { opacity: 0; transform: scale(.4); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes dm-no {
0% { opacity: 0; transform: scale(.4) translateX(0); }
69.9% { opacity: 0; transform: scale(.4) translateX(0); }
70% { opacity: 1; transform: scale(1) translateX(5px); }
74% { opacity: 1; transform: scale(1) translateX(-5px); }
78% { opacity: 1; transform: scale(1) translateX(3px); }
81.9% { opacity: 1; transform: scale(1) translateX(0); }
82% { opacity: 0; transform: scale(.4) translateX(0); }
100% { opacity: 0; transform: scale(.4) translateX(0); }
}
@keyframes dm-m-wait { 0% { opacity: 0; } 36% { opacity: 1; } 54% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dm-m-ok { 0% { opacity: 0; } 54% { opacity: 1; } 70% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dm-m-no { 0% { opacity: 0; } 70% { opacity: 1; } 86% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.dm__input { height: 40px; line-height: 36px; }
.dm__ghost { line-height: 40px; }
.dm__out { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dm.is-demo .dm__ghost,
.dm.is-demo .dm__input,
.dm.is-demo .dm__ic,
.dm.is-demo .dm__m,
.dm[data-state="wait"] .dm__ic--wait { animation: none !important; }
.dm.is-demo .dm__ghost { opacity: 1; clip-path: inset(0 50% 0 0); }
.dm.is-demo .dm__ic--ok { opacity: 1; transform: scale(1); }
.dm.is-demo .dm__input { border-color: $ok; }
.dm.is-demo .dm__m[data-m="ok"] { opacity: 1; }
}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);
}
.dm {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dm__label {
display: block;
margin-bottom: 4px;
color: #17141a;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.dm__field {
position: relative;
}
.dm__input {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 0 40px 0 12px;
border: 2px solid rgba(255, 247, 230, 0.38);
border-radius: 12px;
background: rgba(23, 20, 26, 0.18);
color: #fff7e6;
font-family: inherit;
font-size: 14px;
line-height: 40px;
font-weight: 700;
}
.dm__input::placeholder {
color: rgba(255, 247, 230, 0.5);
font-weight: 600;
}
.dm__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.dm.is-demo .dm__input::placeholder {
color: transparent;
}
.dm__ghost {
position: absolute;
left: 14px;
top: 0;
color: #fff7e6;
font-size: 14px;
line-height: 44px;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.dm__icons {
position: absolute;
right: 12px;
top: 50%;
width: 20px;
height: 20px;
transform: translateY(-50%);
}
.dm__ic {
position: absolute;
left: 0;
top: 0;
display: block;
transform: scale(0.4);
opacity: 0;
}
.dm__ic--wait {
color: rgba(255, 247, 230, 0.82);
}
.dm__ic--ok {
color: #479075;
}
.dm__ic--no {
color: #4a0f04;
}
.dm[data-state=wait] .dm__ic--wait,
.dm[data-state=ok] .dm__ic--ok,
.dm[data-state=no] .dm__ic--no {
transform: scale(1);
opacity: 1;
}
.dm[data-state=wait] .dm__ic--wait {
animation-name: dm-spin;
animation-duration: 600ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.dm[data-state=ok] .dm__input {
border-color: #479075;
}
.dm[data-state=no] .dm__input {
border-color: #4a0f04;
}
.dm__out {
position: relative;
height: 16px;
margin: 8px 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.dm__msgs {
position: relative;
display: block;
height: 16px;
}
.dm__m {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
opacity: 0;
}
.dm__m[data-m=wait] {
color: rgba(255, 247, 230, 0.72);
}
.dm__m[data-m=ok] {
color: #479075;
}
.dm__m[data-m=no] {
color: #4a0f04;
}
.dm[data-state=wait] .dm__m[data-m=wait],
.dm[data-state=ok] .dm__m[data-m=ok],
.dm[data-state=no] .dm__m[data-m=no] {
opacity: 1;
}
.dm__say {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.dm.is-demo .dm__ghost {
opacity: 1;
animation-name: dm-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__input {
animation-name: dm-input;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--wait {
animation-name: dm-wait, dm-spin;
animation-duration: 2s, 600ms;
animation-timing-function: steps(1, end), linear;
animation-iteration-count: infinite, infinite;
}
.dm.is-demo .dm__ic--ok {
animation-name: dm-ok;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--no {
animation-name: dm-no;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m[data-m=wait] {
animation-name: dm-m-wait;
}
.dm.is-demo .dm__m[data-m=ok] {
animation-name: dm-m-ok;
}
.dm.is-demo .dm__m[data-m=no] {
animation-name: dm-m-no;
}
@keyframes dm-spin {
0% {
rotate: 0deg;
}
100% {
rotate: 360deg;
}
}
@keyframes dm-type {
0% {
clip-path: inset(0 100% 0 0);
}
18% {
clip-path: inset(0 100% 0 0);
}
26% {
clip-path: inset(0 70% 0 0);
}
34% {
clip-path: inset(0 50% 0 0);
}
66% {
clip-path: inset(0 50% 0 0);
}
70% {
clip-path: inset(0 0 0 0);
}
86% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes dm-input {
0% {
border-color: rgba(255, 247, 230, 0.38);
}
54% {
border-color: #479075;
}
70% {
border-color: #4a0f04;
}
82% {
border-color: rgba(255, 247, 230, 0.38);
}
100% {
border-color: rgba(255, 247, 230, 0.38);
}
}
@keyframes dm-wait {
0% {
opacity: 0;
transform: scale(0.4);
}
36% {
opacity: 1;
transform: scale(1);
}
54% {
opacity: 0;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes dm-ok {
0% {
opacity: 0;
transform: scale(0.4);
}
53.9% {
opacity: 0;
transform: scale(0.4);
}
54% {
opacity: 1;
transform: scale(1.35);
}
60% {
opacity: 1;
transform: scale(1);
}
69.9% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes dm-no {
0% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
69.9% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
70% {
opacity: 1;
transform: scale(1) translateX(5px);
}
74% {
opacity: 1;
transform: scale(1) translateX(-5px);
}
78% {
opacity: 1;
transform: scale(1) translateX(3px);
}
81.9% {
opacity: 1;
transform: scale(1) translateX(0);
}
82% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
100% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
}
@keyframes dm-m-wait {
0% {
opacity: 0;
}
36% {
opacity: 1;
}
54% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dm-m-ok {
0% {
opacity: 0;
}
54% {
opacity: 1;
}
70% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dm-m-no {
0% {
opacity: 0;
}
70% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.dm__input {
height: 40px;
line-height: 36px;
}
.dm__ghost {
line-height: 40px;
}
.dm__out {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dm.is-demo .dm__ghost,
.dm.is-demo .dm__input,
.dm.is-demo .dm__ic,
.dm.is-demo .dm__m,
.dm[data-state=wait] .dm__ic--wait {
animation: none !important;
}
.dm.is-demo .dm__ghost {
opacity: 1;
clip-path: inset(0 50% 0 0);
}
.dm.is-demo .dm__ic--ok {
opacity: 1;
transform: scale(1);
}
.dm.is-demo .dm__input {
border-color: #479075;
}
.dm.is-demo .dm__m[data-m=ok] {
opacity: 1;
}
}// 실제 동작: 타이핑이 멈춘 뒤 400ms 를 기다렸다가(setTimeout) 규칙을 재고, 통과면 체크·어기면 엑스로 넘어간다.
var dm = document.getElementById('dm');
var dmInput = document.getElementById('dm-input');
var dmSay = document.getElementById('dm-say');
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
var dmTimer = 0;
function dmSet(state, say) {
dm.setAttribute('data-state', state);
dmInput.setAttribute('aria-invalid', state === 'no' ? 'true' : 'false');
dmSay.textContent = say;
}
dmInput.addEventListener('input', function () {
dm.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', 'Enter a username'); return; }
dmSet('wait', 'Checking it now');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? 'This username is available' : 'This username does not fit the rule');
}, 400);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DebounceStatusIcon.module.scss";
export type DebounceStatusIconProps = {
label?: string;
placeholder?: string;
waitMessage?: string;
okMessage?: string;
noMessage?: string;
/** 타이핑이 멎고 나서 판정까지 기다리는 시간 (ms) */
debounce?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
type State = "idle" | "wait" | "ok" | "no";
const RULE = /^[a-z][a-z0-9_]{4,}$/;
function Ring() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128" />
</g>
</svg>
);
}
function CheckCircle() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
function XCircle() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
export default function DebounceStatusIcon({
label = "Username",
placeholder = "Type more than four characters",
waitMessage = "Checking",
okMessage = "This username is available",
noMessage = "This username does not fit the rule",
debounce = 400,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: DebounceStatusIconProps) {
const [value, setValue] = useState("");
const [state, setState] = useState<State>("idle");
const [live, setLive] = useState(demo);
const timer = useRef<number>(0);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
function onType(next: string) {
setLive(false);
setValue(next);
if (timer.current) clearTimeout(timer.current);
if (!next) { setState("idle"); return; }
setState("wait");
timer.current = setTimeout(() => setState(RULE.test(next) ? "ok" : "no"), debounce) as unknown as number;
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} data-state={state}>
<label className={styles.label} htmlFor="dm-input">{label}</label>
<div className={styles.field}>
<input
className={styles.input}
id="dm-input"
type="text"
autoComplete="off"
placeholder={placeholder}
aria-describedby="dm-say"
aria-invalid={state === "no"}
value={value}
onChange={(e: ChangeEvt) => onType(e.target.value)}
/>
<span className={styles.ghost} aria-hidden="true">bitna shop</span>
<span className={styles.icons} aria-hidden="true">
<span className={`${styles.ic} ${styles.icWait}`}><Ring /></span>
<span className={`${styles.ic} ${styles.icOk}`}><CheckCircle /></span>
<span className={`${styles.ic} ${styles.icNo}`}><XCircle /></span>
</span>
</div>
<p className={styles.out}>
<span className={styles.msgs} aria-hidden="true">
<span className={styles.m} data-m="wait">{waitMessage}</span>
<span className={styles.m} data-m="ok">{okMessage}</span>
<span className={styles.m} data-m="no">{noMessage}</span>
</span>
<span className={styles.say} id="dm-say" role="status" aria-live="polite">
{state === "ok" ? okMessage : state === "no" ? noMessage : state === "wait" ? waitMessage : "Enter a username"}
</span>
</p>
</div>
);
}07 · Status icon that waits, then rules — Measured area 2.357% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A spinner turns while the wait lasts, and when the verdict lands, a check pops in or an X shakes its way in.
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
dmInput.addEventListener('input', function () {
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', 'Enter a username'); return; }
dmSet('wait', 'Checking');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? 'This username is available' : 'This username does not fit the rule');
}, 400);
});
08Fields that shake when submit catches them
What happens on screen
This one holds its judgment until the submit button is pressed. Only the empty boxes shake sideways through 6px, 4px, and 2px with translateX, and the label on the same row lights up in the error color.

<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">Email</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">Phone</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">Sign up</button>
<p class="sk__say" id="sk-say" role="status" aria-live="polite">Fill in both fields</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 + ' field(s) still empty' : 'Both fields are filled in';
});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 · Fields that shake when submit catches them — Measured area 13.172% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
To make the same box shake again on a second press, the class has to come off and a reflow has to be forced before it goes back on.
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; // force a reflow so the same shake runs again
row.classList.add('is-bad');
}
});
09Focus ring that walks you to the first error
What moves
On a form too tall for one screen, telling someone what is wrong is not enough. When submission is blocked, the first empty box is found, scrollIntoView carries the view to it, a 3px ring lights up on it, and focus is handed over.

<main class="stage">
<form class="fe is-demo" id="fe" novalidate>
<div class="fe__viewport" id="fe-viewport">
<div class="fe__track" id="fe-track">
<div class="fe__row"><span class="fe__cap">Name</span><span class="fe__slot" data-filled="1">Bitna Kim</span></div>
<div class="fe__row"><span class="fe__cap">Birth date</span><span class="fe__slot" data-filled="1">1996-04-02</span></div>
<div class="fe__row" id="fe-bad"><span class="fe__cap">Email</span><span class="fe__slot" data-filled="0" tabindex="0" aria-invalid="true" aria-describedby="fe-say"></span></div>
<div class="fe__row"><span class="fe__cap">Address</span><span class="fe__slot" data-filled="1">Seongsu-dong 12-gil</span></div>
</div>
</div>
<button class="fe__btn" type="submit">Submit form</button>
<p class="fe__say" id="fe-say" role="status" aria-live="polite">Moved to the empty email field</p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fe {
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;
}
// 스크롤 상자(viewport)와 줄 묶음(track)을 나눈다 — 움직이는 것은 언제나 track 쪽뿐이다
.fe__viewport {
position: relative;
height: 96px;
overflow-y: auto;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
.fe__track {
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
}
.fe__row {
display: flex;
align-items: center;
gap: $sp-2;
}
.fe__cap {
flex: 0 0 auto;
width: 52px;
color: rgba(255, 255, 255, .5);
font-size: 11px; line-height: 14px; font-weight: 700;
}
.fe__slot {
box-sizing: border-box;
flex: 1 1 auto;
height: 32px;
padding: 0 $sp-3;
border: 2px solid rgba(255, 255, 255, .14);
border-radius: $r-control;
color: $subject-cream;
font-size: 13px; line-height: 28px; font-weight: 700;
outline: 0 solid transparent;
transition: outline-color $dur-quick $ease-out, border-color $dur-quick $ease-out;
}
// 색만 애니메이션하려면 굵기가 먼저 있어야 한다 — 0px 짜리 outline 은 색을 바꿔도 안 보인다
.fe__slot[data-filled="0"] { border-color: $error; outline: 3px solid transparent; outline-offset: 2px; }
.fe__slot.is-hit,
.fe__slot:focus-visible {
outline: 3px solid $color;
outline-offset: 2px;
}
.fe__btn {
height: 34px;
border: 0;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-size: 13px; line-height: 34px; font-weight: 700;
cursor: pointer;
transition: transform $dur-instant $ease-out;
}
.fe__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.fe__btn:active { transform: scale(.97); }
.fe__say {
// 어디로 옮겼는지는 링이 보여 준다 — 문장은 낭독기 쪽으로만 보낸다
position: absolute;
width: 1px; height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~24% 첫 두 줄만 보이는 상태 → 26% 버튼 눌림 → 30~50% 줄 묶음이 이메일 칸까지 올라간다
// → 40% 굵은 링이 켜짐(컷, 움직이는 도중에 켜야 대표 프레임에 링이 남는다) → 74% 링 끔 · 88% 제자리.
// transform 은 스크롤 상자가 아니라 track 에만 건다 — 상자에 걸면 100% 가 상자 자기 폭 기준이라 내용이 통째로 날아간다.
.fe.is-demo .fe__track {
animation-name: fe-scroll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fe.is-demo .fe__slot[data-filled="0"] {
transition: none;
animation-name: fe-ring;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fe.is-demo .fe__btn {
transition: none;
animation-name: fe-btn;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes fe-scroll {
0% { transform: translateY(0); }
30% { transform: translateY(0); }
50% { transform: translateY(-40px); }
76% { transform: translateY(-40px); }
88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes fe-ring {
0% { outline-color: transparent; }
40% { outline-color: $color; }
74% { outline-color: transparent; }
100% { outline-color: transparent; }
}
@keyframes fe-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) {
.fe { gap: $sp-1; }
.fe__viewport { height: 88px; }
.fe__cap { width: 44px; }
.fe__slot { height: 28px; line-height: 24px; }
.fe__btn { height: 32px; line-height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.fe.is-demo .fe__track,
.fe.is-demo .fe__slot[data-filled="0"],
.fe.is-demo .fe__btn { animation: none !important; }
.fe.is-demo .fe__track { transform: translateY(-40px); }
.fe.is-demo .fe__slot[data-filled="0"] { outline: 3px solid $color; outline-offset: 2px; }
.fe__slot, .fe__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
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;
}
.fe__viewport {
position: relative;
height: 96px;
overflow-y: auto;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.fe__track {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.fe__row {
display: flex;
align-items: center;
gap: 8px;
}
.fe__cap {
flex: 0 0 auto;
width: 52px;
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.fe__slot {
box-sizing: border-box;
flex: 1 1 auto;
height: 32px;
padding: 0 12px;
border: 2px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
color: #fff7e6;
font-size: 13px;
line-height: 28px;
font-weight: 700;
outline: 0 solid transparent;
transition: outline-color 220ms ease-out, border-color 220ms ease-out;
}
.fe__slot[data-filled="0"] {
border-color: #c2361a;
outline: 3px solid transparent;
outline-offset: 2px;
}
.fe__slot.is-hit,
.fe__slot:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.fe__btn {
height: 34px;
border: 0;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 34px;
font-weight: 700;
cursor: pointer;
transition: transform 140ms ease-out;
}
.fe__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.fe__btn:active {
transform: scale(0.97);
}
.fe__say {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.fe.is-demo .fe__track {
animation-name: fe-scroll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.fe.is-demo .fe__slot[data-filled="0"] {
transition: none;
animation-name: fe-ring;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fe.is-demo .fe__btn {
transition: none;
animation-name: fe-btn;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fe-scroll {
0% {
transform: translateY(0);
}
30% {
transform: translateY(0);
}
50% {
transform: translateY(-40px);
}
76% {
transform: translateY(-40px);
}
88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes fe-ring {
0% {
outline-color: transparent;
}
40% {
outline-color: #2f6df6;
}
74% {
outline-color: transparent;
}
100% {
outline-color: transparent;
}
}
@keyframes fe-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) {
.fe {
gap: 4px;
}
.fe__viewport {
height: 88px;
}
.fe__cap {
width: 44px;
}
.fe__slot {
height: 28px;
line-height: 24px;
}
.fe__btn {
height: 32px;
line-height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-demo .fe__track,
.fe.is-demo .fe__slot[data-filled="0"],
.fe.is-demo .fe__btn {
animation: none !important;
}
.fe.is-demo .fe__track {
transform: translateY(-40px);
}
.fe.is-demo .fe__slot[data-filled="0"] {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.fe__slot, .fe__btn {
transition: none;
}
}// 실제 동작: 제출이 막히면 첫 오류 칸으로 스크롤 상자를 내리고, 화면이 튀지 않도록 포커스는 스크롤 없이 따로 준다.
var fe = document.getElementById('fe');
var feBad = document.getElementById('fe-bad');
var feSlots = [].slice.call(document.querySelectorAll('.fe__slot'));
fe.addEventListener('submit', function (e) {
e.preventDefault();
fe.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var first = null;
feSlots.forEach(function (s) { if (!first && s.getAttribute('data-filled') === '0') first = s; });
if (!first) return;
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
first.classList.add('is-hit');
first.focus({ preventScroll: true }); // preventScroll 이 없으면 이 데모가 든 부모 페이지까지 끌려간다
feBad.classList.add('is-hit');
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FirstErrorScroll.module.scss";
export type ScrollRow = { caption: string; value: string };
export type FirstErrorScrollProps = {
rows?: ScrollRow[];
submitLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_ROWS: ScrollRow[] = [
{ caption: "Name", value: "Bitna Kim" },
{ caption: "Birth date", value: "1996-04-02" },
{ caption: "Email", value: "" },
{ caption: "Address", value: "Seongsu-dong 12-gil" },
];
export default function FirstErrorScroll({
rows = DEFAULT_ROWS,
submitLabel = "Submit form",
demo = true,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FirstErrorScrollProps) {
const [hit, setHit] = useState(-1);
const [live, setLive] = useState(demo);
// ambient useRef 는 항상 T | null 을 돌려준다 — 지역 별칭으로 한 번만 좁혀 쓴다
const slotsRef = useRef<(HTMLElement | null)[]>([]);
const slots: (HTMLElement | null)[] = slotsRef.current || [];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const firstBad = rows.findIndex((r) => r.value.length === 0);
function onSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
setLive(false);
if (firstBad < 0) return;
const el = slots[firstBad];
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
// preventScroll 이 없으면 이 부품이 iframe 안에 있을 때 부모 페이지까지 끌려 내려간다
el.focus({ preventScroll: true });
setHit(firstBad);
}
return (
<form className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} noValidate onSubmit={onSubmit}>
<div className={styles.viewport}>
<div className={styles.track}>
{rows.map((r, i) => (
<div key={r.caption} className={styles.row}>
<span className={styles.cap}>{r.caption}</span>
<span
ref={(el: HTMLElement | null) => { slots[i] = el; }}
className={`${styles.slot} ${hit === i ? styles.isHit : ""}`}
data-filled={r.value ? 1 : 0}
tabIndex={r.value ? undefined : 0}
aria-invalid={r.value ? undefined : true}
aria-describedby={r.value ? undefined : "fe-say"}
>
{r.value}
</span>
</div>
))}
</div>
</div>
<button className={styles.btn} type="submit">{submitLabel}</button>
<p className={styles.say} id="fe-say" role="status" aria-live="polite">
{hit < 0 ? "" : `Moved to the empty ${rows[hit].caption} field`}
</p>
</form>
);
}09 · Focus ring that walks you to the first error — Measured area 13.372% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Passing preventScroll: true along with that focus call is the whole point of this item.
fe.addEventListener('submit', function (e) {
e.preventDefault();
var first = null;
feSlots.forEach(function (s) { if (!first && s.getAttribute('data-filled') === '0') first = s; });
if (!first) return;
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
first.classList.add('is-hit');
first.focus({ preventScroll: true }); // without it the parent page is dragged down
});
Where it breaks — the trap
The part of a validation UI that breaks most often is not the pattern but the place the message will occupy. Insert an error line after the fact and everything below drops by the height of that line, which is how a button runs away from under the finger about to press it. That is why 03, 05, 06, and 07 give the message row a height: 16px first and stack the sentences inside it with position: absolute. Whether a line appears or disappears, the outer height stays 16px.
The second trap shows up when a message changes gradually. Cross-fading an error sentence into a success sentence with opacity leaves frames where both are half-visible and neither can be read, and in the 24-frame preview one of those frames was the one picked as the cover image. Message swaps therefore use animation-timing-function: steps(1, end) and land in a single frame. Borders and gauges are the only things here allowed to change color gradually.
The third is the narrow screen. These demos go into the article as 480×300 iframes and shrink to 320×200 on a phone. Take the stage padding out and 174px of height is all that is left, and measured at 320px, the nine components stood between 68px and 124px tall. The three-row checklist in 04 came to 118px and the scroll box in 09 to 124px, the two tallest. With only 50px of slack, a narrow screen is where gaps go down, not up — which is why the checklist rows in 04 drop from 8px to 4px. When the value being judged is a color the rules change, and Custom Color Picker previews those rules. The nine rule sets are impossible to keep straight when every screen spells them out differently, so they sit in one folder whose archive password is x97m5gb8, and it holds the same vanilla and React versions you are looking at.
Accessibility (reduced-motion)
All nine turn their autoplay loop off under prefers-reduced-motion: reduce while keeping the verdict. The label stands in its raised position without the rise, the check sits in place without the pop, and the shake never runs while the error color and aria-invalid stay exactly as they were. Losing the motion must not mean losing the fact that this box is wrong.
The rule against signaling a verdict by color alone follows from the same idea. Every one of the nine pairs its color with a sentence or an icon, and the table below shows what a screen reader gets.
| Item | On screen | To a screen reader | Trigger |
|---|---|---|---|
| 01 Required label | Label, asterisk, border color | aria-required and aria-invalid |
Judged when focus leaves |
| 02 Format tooltip | Example pieces locking in | Tooltip tied by aria-describedby |
Opens on focus |
| 03 Inline message | One line swapping sentences | aria-live="polite" |
Every keystroke |
| 04 Requirement list | Row color plus a check icon | List tied by aria-describedby |
Every keystroke |
| 05 Strength gauge | Cell count plus a grade word | Off-screen role="status" sentence |
Every keystroke |
| 06 Character count | Digits plus bar color | Remaining count as a sentence | maxlength blocks it |
| 07 Status icon | Spinner, check, X | A sentence per state via aria-live |
400ms after typing stops |
| 08 Submit shake | Shake plus label color | aria-invalid per box |
Submit button |
| 09 First-error jump | 3px focus ring | The moved field's name as a sentence | Submit button |
The markup that lets a program read the verdict is laid out in the MDN page on aria-invalid. Input parts of the same family are collected in the forms category, and the motion that reports a failure in the error category.
FAQ
Should an input validation ui check while typing or on submit?
Mixing the two works best. For boxes with a clear rule, like an email or a password, telling people as they type, the way 03 and 04 do, cuts down on the round trip through a failed submit. Throwing a red message before anyone has finished typing is just noise, though, so drawing the error only after focus has left once — the way 01 does — is the safe default. The all-at-once pass belongs on the submit button, as in 08. Showing the verdict as a shaking animation is the version in Form Validation CSS Animations.
If the patterns are in the file, can the server skip validation?
Client-side checks exist only to save people a wasted detour, and code running in a browser can be edited by anyone. All nine patterns here were written on the assumption that a server runs the same rules a second time. The same goes for maxlength: it trims what a person types or pastes, but a devtools edit or a script that sets the value slips past it, so the length cap in 06 gets counted again on the server.
Should an error message sit above or below the box?
Below is the safer default. Put it above and the input gets pushed down, so the finger has to chase it, and with a mobile keyboard open the message can be shoved off-screen entirely. The hint in 02 is the exception, because an example shown before typing is better placed where the keyboard will not cover it. Either way, reserving the space first matters more than the side you pick.








