폼 유효성 검사 UI 9종 — 입력하는 순간 판정하는 오류 안내
폼 유효성 검사는 사람이 친 값이 규칙에 맞는지 그 자리에서 재서 화면으로 알려 주는 장치입니다. 아홉 개 모두 실제 정규식이 돌아가고, 입력하는 순간 판정하는 쪽과 제출을 눌러야 판정하는 쪽이 섞여 있습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 필수 표시가 붙는 플로트 라벨
- 02 포커스에 뜨는 형식 예시 툴팁
- 03 입력 중 바뀌는 인라인 안내 문구
- 04 하나씩 채워지는 요건 체크리스트
- 05 점수를 재는 비밀번호 강도 게이지
- 06 한도가 가까워지면 경고로 바뀌는 글자 수
- 07 기다렸다가 판정하는 상태 아이콘
- 08 제출을 눌렀을 때 흔들리며 알리는 칸
- 09 첫 오류로 데려가는 포커스 링
순서는 인기순이 아니라 한 사람이 폼을 채우다 막히는 순서입니다. 칸에 들어가기 전에 무엇이 필수인지 보고(01), 어떤 꼴로 써야 하는지 예시를 보고(02), 치는 동안 맞는지 틀리는지 한 줄로 듣습니다(03). 그다음은 규칙이 여러 개인 칸입니다. 요건을 하나씩 지워 나가고(04), 점수로 강도를 재고(05), 길이 한도에 다가갑니다(06). 마지막 셋은 판정이 미뤄지는 경우입니다. 서버 대답을 기다렸다 판정하고(07), 제출을 눌러야 한꺼번에 검사하고(08), 화면 밖에 있는 첫 오류까지 데려갑니다(09). 회원가입 오류를 미리 막는 아홉 개의 규칙은 전부 파일 안에 정규식으로 적혀 있고, 색과 움직임은 그 판정의 결과만 그립니다. 입력창을 여러 개 모아 회원가입 화면으로 만들면 회원가입 폼 디자인 입력창이고, 단계로 쪼개면 회원가입 단계 ui가 이어받습니다.
01필수 표시가 붙는 플로트 라벨
css 유효성 검사에서 무엇이 보이나
라벨이 입력칸 안에 앉아 있다가 글자를 치면 8px 위로 떠오르며 78% 크기로 줄어들고, 필수 칸을 비운 채 포커스가 떠나면 라벨과 별표, 테두리가 함께 오류색으로 바뀝니다. 판정은 값만 보지 않고 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">김빛나</span>
<label class="fl__label" for="fl-input"><span class="fl__text">이름</span><span class="fl__star" aria-hidden="true">*</span></label>
</div>
<p class="fl__msg" id="fl-msg" role="status">
<span class="fl__m1">이름을 적어야 다음으로 넘어가요</span>
<span class="fl__m2">이름이 채워졌어요</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 · 필수 표시가 붙는 플로트 라벨 — 측정 누적면적 11.549% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
한 번도 손대지 않은 칸을 빨갛게 칠하면 폼을 열자마자 온통 빨간 화면이 되기 때문입니다.
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(); });
02포커스에 뜨는 형식 예시 툴팁
화면에서 일어나는 일
전화번호 칸에 들어가면 010-1234-5678 이 적힌 툴팁이 위에서 내려옵니다. 예시는 세 조각으로 쪼개져 있고, 친 값이 조각의 정규식을 통과할 때마다 그 조각만 확정 색으로 굳습니다.

<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">휴대폰 번호</label>
<div class="ph__field">
<input class="ph__input" id="ph-input" type="tel" autocomplete="off" inputmode="numeric" aria-describedby="ph-tip" placeholder="숫자만 눌러 보세요">
<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 · 포커스에 뜨는 형식 예시 툴팁 — 측정 누적면적 3.906% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
입력값에서 숫자만 남긴 뒤 세 자리·네 자리로 다시 이어 붙여 하이픈을 자동으로 넣습니다.
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));
});
03입력 중 바뀌는 인라인 안내 문구
어떤 움직임인가
이메일 칸 아래에는 문구가 나타날 때 아래 내용이 밀리지 않도록 16px짜리 한 줄을 미리 비워 둡니다. 오류 문구와 완료 문구는 같은 자리에 겹쳐 두고 opacity 하나만 갈아 끼웁니다.

<main class="stage">
<div class="il is-demo" id="il">
<label class="il__label" for="il-input">이메일</label>
<div class="il__field">
<input class="il__input" id="il-input" type="email" autocomplete="off" aria-describedby="il-msg" placeholder="주소를 쳐 보세요">
<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">@ 뒤 주소가 아직 모자라요</span>
<span class="il__good">보낼 수 있는 주소예요</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 · 입력 중 바뀌는 인라인 안내 문구 — 측정 누적면적 1.919% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
그 줄에 aria-live="polite" 를 걸어 두면 눈으로 읽는 문장과 낭독기가 읽는 문장이 하나로 맞춰집니다.
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);
04하나씩 채워지는 요건 체크리스트
무엇이 보이나
비밀번호 아래 세 줄이 각자의 정규식을 하나씩 들고 있습니다. 줄 하나가 통과하면 그 줄만 확정 색으로 넘어가고, 동그라미 안의 체크 아이콘이 scale(0) 에서 1.3 을 거쳐 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="비밀번호를 만들어 보세요">
<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">여덟 글자 넘기</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">숫자 하나 섞기</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">기호 하나 넣기</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 · 하나씩 채워지는 요건 체크리스트 — 측정 누적면적 2.318% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
규칙을 배열로 두면 요건을 늘리거나 순서를 바꿀 때 화면 쪽 코드는 건드리지 않아도 됩니다.
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');
}
05점수를 재는 비밀번호 강도 게이지
화면에서 일어나는 일
네 조건을 각각 재서 0부터 4까지의 점수 하나로 줄이면, 화면은 그 숫자 하나만 보고 칸 수와 낱말, 색을 정합니다. 칸은 scaleX 로 왼쪽에서 자라고 게이지는 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="비밀번호를 쳐 보세요">
<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">약함</span><span class="pg__w" data-w="mid">보통</span><span class="pg__w" data-w="high">강함</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">네 조건 중 하나만 지켰어요</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 = ['네 조건 중 하나도 못 지켰어요', '네 조건 중 하나만 지켰어요', '두 조건까지 지켰어요',
'하나만 더 채우면 강해져요', '네 조건을 모두 지켰어요'];
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 · 점수를 재는 비밀번호 강도 게이지 — 측정 누적면적 1.597% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
등급 낱말은 세 개를 겹쳐 두고 하나만 켭니다.
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)));
06한도가 가까워지면 경고로 바뀌는 글자 수
어떤 움직임인가
maxlength 가 한도를 넘는 입력을 브라우저 쪽에서 막고, 자바스크립트는 남은 글자를 세어 경계를 넘었는지만 봅니다. 남은 글자가 12자 이하로 떨어지면 경고, 0이면 정지 단계로 바뀌고 숫자·막대·테두리가 한꺼번에 그 색을 따라갑니다.

<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="한 줄 감상을 남겨 보세요"></textarea>
<span class="cc__ghost" aria-hidden="true">배송이 하루 만에 왔고 포장도 꼼꼼했어요</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자 남음</span><span class="cc__n" data-n="1">26자 남음</span><span class="cc__n" data-n="2">8자 남음</span><span class="cc__n" data-n="3">0자 남음</span>
</span>
<span class="cc__live" id="cc-left">44자 남음</span>
<span class="cc__notes" aria-hidden="true">
<span class="cc__note" data-note="near">곧 한도예요</span><span class="cc__note" data-note="full">더 쓸 수 없어요</span>
</span>
<span class="cc__say" id="cc-say" role="status" aria-live="polite">60자 중 16자를 썼어요</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 + '자 남음';
ccSay.textContent = CC_MAX + '자 중 ' + 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 · 한도가 가까워지면 경고로 바뀌는 글자 수 — 측정 누적면적 3.722% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
진행 막대는 폭이 아니라 scaleX 를 씁니다.
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 + '자 남음';
ccSay.textContent = CC_MAX + '자 중 ' + used + '자를 썼어요';
}
07기다렸다가 판정하는 상태 아이콘
무엇이 보이나
아이디 중복 확인처럼 서버에 물어야 하는 칸은 한 글자마다 요청을 보내면 안 되므로, 타이핑이 멎고 400ms가 지난 뒤에 한 번만 재도록 setTimeout 을 걸고 새 글자가 들어오면 clearTimeout 으로 앞 예약을 버립니다.
<main class="stage">
<div class="dm is-demo" id="dm" data-state="idle">
<label class="dm__label" for="dm-input">아이디</label>
<div class="dm__field">
<input class="dm__input" id="dm-input" type="text" autocomplete="off" aria-describedby="dm-say" placeholder="네 글자 넘게 쳐 보세요">
<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">확인하는 중</span><span class="dm__m" data-m="ok">쓸 수 있는 아이디예요</span><span class="dm__m" data-m="no">규칙에 맞지 않는 아이디예요</span>
</span>
<span class="dm__say" id="dm-say" role="status" aria-live="polite">아이디를 입력해 주세요</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', '아이디를 입력해 주세요'); return; }
dmSet('wait', '확인하는 중이에요');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? '쓸 수 있는 아이디예요' : '규칙에 맞지 않는 아이디예요');
}, 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 · 기다렸다가 판정하는 상태 아이콘 — 측정 누적면적 2.357% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
기다리는 동안 회전 표시가 돌다가, 판정이 나면 체크가 튀어나오거나 엑스가 좌우로 흔들리며 들어옵니다.
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
dmInput.addEventListener('input', function () {
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', '아이디를 입력해 주세요'); return; }
dmSet('wait', '확인하는 중이에요');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? '쓸 수 있는 아이디예요' : '규칙에 맞지 않는 아이디예요');
}, 400);
});
08제출을 눌렀을 때 흔들리며 알리는 칸
화면에서 일어나는 일
치는 동안 검사하지 않고 제출 버튼을 눌러야 검사하는 쪽입니다. 비어 있는 칸만 translateX 로 좌우 6px·4px·2px씩 줄어들며 흔들리고, 같은 줄의 라벨이 오류색으로 켜집니다.

<main class="stage">
<form class="sk is-demo" id="sk" novalidate>
<div class="sk__row" data-row="1">
<label class="sk__label" for="sk-a">이메일</label>
<span class="sk__box">
<input class="sk__input" id="sk-a" type="email" autocomplete="off" required>
<span class="sk__ghost" aria-hidden="true">[email protected]</span>
</span>
</div>
<div class="sk__row" data-row="2">
<label class="sk__label" for="sk-b">연락처</label>
<span class="sk__box">
<input class="sk__input" id="sk-b" type="tel" autocomplete="off" required>
<span class="sk__ghost" aria-hidden="true">010-1234-5678</span>
</span>
</div>
<button class="sk__btn" type="submit">가입 신청</button>
<p class="sk__say" id="sk-say" role="status" aria-live="polite">두 칸을 모두 채워 주세요</p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: cubic-bezier(.36, .07, .19, .97);
$color: $subject-blue;
@include stage($stage-paper);
$ok: #479076;
.sk {
box-sizing: border-box;
width: min(300px, 86vw);
display: flex;
flex-direction: column;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__row {
display: flex;
align-items: center;
gap: $sp-2;
}
.sk__label {
flex: 0 0 auto;
width: 48px;
color: rgba(23, 20, 26, .72);
font-size: 11px; line-height: 14px; font-weight: 700;
transition: color $dur-quick $ease-out;
}
.sk__row.is-bad .sk__label { color: $error; }
.sk__row.is-ok .sk__label { color: $ok; }
.sk__box {
position: relative;
flex: 1 1 auto;
display: block;
}
.sk__ghost {
position: absolute;
left: 14px; top: 0;
color: $subject-ink;
font-size: 13px; line-height: 34px; font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.sk__input {
box-sizing: border-box;
display: block;
width: 100%; height: 34px;
padding: 0 $sp-3;
border: 2px solid rgba(23, 20, 26, .2);
border-radius: $r-control;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 13px; line-height: 30px; font-weight: 700;
transition: border-color $dur-quick $ease-out;
}
.sk__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sk__row.is-bad .sk__input { border-color: $error; background: $error-soft; }
.sk__row.is-ok .sk__input { border-color: $ok; }
// 비어 있는 칸만 좌우로 흔든다 — 한 번만 돌고 멈춘다
.sk__row.is-bad {
animation-name: sk-shake;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
.sk__btn {
height: 34px;
border: 0;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-size: 13px; line-height: 34px; font-weight: 700;
cursor: pointer;
transition: transform $dur-instant $ease-out;
}
.sk__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sk__btn:active { transform: scale(.97); }
.sk__say {
// 흔들리는 칸이 눈으로 말해 주는 것을 낭독기에도 같은 뜻으로 보낸다
position: absolute;
width: 1px; height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~24% 빈 폼 → 26% 버튼 눌림 → 28·30% 두 줄이 차례로 흔들리며 오류로 켜짐
// → 56% 위 칸 채움 · 64% 아래 칸 채움(두 줄 다 확정 색) → 84·88% 흩어서 복귀.
// 흔들림은 translateX 라 레이아웃을 다시 계산하지 않는다.
.sk.is-demo .sk__row {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] { animation-name: sk-row-1; }
.sk.is-demo .sk__row[data-row="2"] { animation-name: sk-row-2; }
.sk.is-demo .sk__label,
.sk.is-demo .sk__input {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__label { animation-name: sk-lb-1; }
.sk.is-demo .sk__row[data-row="2"] .sk__label { animation-name: sk-lb-2; }
.sk.is-demo .sk__row[data-row="1"] .sk__input { animation-name: sk-in-1; }
.sk.is-demo .sk__row[data-row="2"] .sk__input { animation-name: sk-in-2; }
.sk.is-demo .sk__ghost {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__ghost { animation-name: sk-gh-1; }
.sk.is-demo .sk__row[data-row="2"] .sk__ghost { animation-name: sk-gh-2; }
.sk.is-demo .sk__btn {
transition: none;
animation-name: sk-btn;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes sk-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(2px); }
}
@keyframes sk-row-1 {
0% { transform: translateX(0); }
28% { transform: translateX(0); }
31% { transform: translateX(-6px); }
34% { transform: translateX(6px); }
37% { transform: translateX(-4px); }
40% { transform: translateX(2px); }
43% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes sk-row-2 {
0% { transform: translateX(0); }
30% { transform: translateX(0); }
33% { transform: translateX(-6px); }
36% { transform: translateX(6px); }
39% { transform: translateX(-4px); }
42% { transform: translateX(2px); }
45% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes sk-lb-1 {
0% { color: rgba(23, 20, 26, .72); }
28% { color: $error; }
56% { color: $ok; }
84% { color: rgba(23, 20, 26, .72); }
100% { color: rgba(23, 20, 26, .72); }
}
@keyframes sk-lb-2 {
0% { color: rgba(23, 20, 26, .72); }
30% { color: $error; }
64% { color: $ok; }
88% { color: rgba(23, 20, 26, .72); }
100% { color: rgba(23, 20, 26, .72); }
}
@keyframes sk-in-1 {
0% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
28% { border-color: $error; background: $error-soft; }
56% { border-color: $ok; background: $stage-paper; }
84% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
100% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
}
@keyframes sk-in-2 {
0% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
30% { border-color: $error; background: $error-soft; }
64% { border-color: $ok; background: $stage-paper; }
88% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
100% { border-color: rgba(23, 20, 26, .2); background: $stage-paper; }
}
@keyframes sk-gh-1 {
0% { opacity: 0; }
56% { opacity: 1; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sk-gh-2 {
0% { opacity: 0; }
64% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes sk-btn {
0% { transform: scale(1); }
24% { transform: scale(1); }
26% { transform: scale(.97); }
30% { transform: scale(1); }
100% { transform: scale(1); }
}
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.sk { gap: $sp-1; }
.sk__label { width: 40px; }
.sk__input, .sk__btn { height: 32px; }
.sk__ghost { line-height: 32px; }
.sk__btn { line-height: 32px; }
.sk__input { line-height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.sk.is-demo .sk__row,
.sk.is-demo .sk__label,
.sk.is-demo .sk__input,
.sk.is-demo .sk__btn,
.sk.is-demo .sk__ghost,
.sk__row.is-bad { animation: none !important; }
.sk.is-demo .sk__label { color: $error; }
.sk.is-demo .sk__input { border-color: $error; background: $error-soft; }
.sk__label, .sk__input, .sk__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sk {
box-sizing: border-box;
width: min(300px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sk__row {
display: flex;
align-items: center;
gap: 8px;
}
.sk__label {
flex: 0 0 auto;
width: 48px;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
line-height: 14px;
font-weight: 700;
transition: color 220ms ease-out;
}
.sk__row.is-bad .sk__label {
color: #c2361a;
}
.sk__row.is-ok .sk__label {
color: #479076;
}
.sk__box {
position: relative;
flex: 1 1 auto;
display: block;
}
.sk__ghost {
position: absolute;
left: 14px;
top: 0;
color: #17141a;
font-size: 13px;
line-height: 34px;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.sk__input {
box-sizing: border-box;
display: block;
width: 100%;
height: 34px;
padding: 0 12px;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 30px;
font-weight: 700;
transition: border-color 220ms ease-out;
}
.sk__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sk__row.is-bad .sk__input {
border-color: #c2361a;
background: #ffe0d8;
}
.sk__row.is-ok .sk__input {
border-color: #479076;
}
.sk__row.is-bad {
animation-name: sk-shake;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97);
animation-iteration-count: 1;
}
.sk__btn {
height: 34px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 13px;
line-height: 34px;
font-weight: 700;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sk__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sk__btn:active {
transform: scale(0.97);
}
.sk__say {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.sk.is-demo .sk__row {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] {
animation-name: sk-row-1;
}
.sk.is-demo .sk__row[data-row="2"] {
animation-name: sk-row-2;
}
.sk.is-demo .sk__label,
.sk.is-demo .sk__input {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__label {
animation-name: sk-lb-1;
}
.sk.is-demo .sk__row[data-row="2"] .sk__label {
animation-name: sk-lb-2;
}
.sk.is-demo .sk__row[data-row="1"] .sk__input {
animation-name: sk-in-1;
}
.sk.is-demo .sk__row[data-row="2"] .sk__input {
animation-name: sk-in-2;
}
.sk.is-demo .sk__ghost {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sk.is-demo .sk__row[data-row="1"] .sk__ghost {
animation-name: sk-gh-1;
}
.sk.is-demo .sk__row[data-row="2"] .sk__ghost {
animation-name: sk-gh-2;
}
.sk.is-demo .sk__btn {
transition: none;
animation-name: sk-btn;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes sk-shake {
0%, 100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(2px);
}
}
@keyframes sk-row-1 {
0% {
transform: translateX(0);
}
28% {
transform: translateX(0);
}
31% {
transform: translateX(-6px);
}
34% {
transform: translateX(6px);
}
37% {
transform: translateX(-4px);
}
40% {
transform: translateX(2px);
}
43% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes sk-row-2 {
0% {
transform: translateX(0);
}
30% {
transform: translateX(0);
}
33% {
transform: translateX(-6px);
}
36% {
transform: translateX(6px);
}
39% {
transform: translateX(-4px);
}
42% {
transform: translateX(2px);
}
45% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes sk-lb-1 {
0% {
color: rgba(23, 20, 26, 0.72);
}
28% {
color: #c2361a;
}
56% {
color: #479076;
}
84% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes sk-lb-2 {
0% {
color: rgba(23, 20, 26, 0.72);
}
30% {
color: #c2361a;
}
64% {
color: #479076;
}
88% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes sk-in-1 {
0% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
28% {
border-color: #c2361a;
background: #ffe0d8;
}
56% {
border-color: #479076;
background: #ffffff;
}
84% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
100% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
}
@keyframes sk-in-2 {
0% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
30% {
border-color: #c2361a;
background: #ffe0d8;
}
64% {
border-color: #479076;
background: #ffffff;
}
88% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
100% {
border-color: rgba(23, 20, 26, 0.2);
background: #ffffff;
}
}
@keyframes sk-gh-1 {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sk-gh-2 {
0% {
opacity: 0;
}
64% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sk-btn {
0% {
transform: scale(1);
}
24% {
transform: scale(1);
}
26% {
transform: scale(0.97);
}
30% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 360px) {
.sk {
gap: 4px;
}
.sk__label {
width: 40px;
}
.sk__input, .sk__btn {
height: 32px;
}
.sk__ghost {
line-height: 32px;
}
.sk__btn {
line-height: 32px;
}
.sk__input {
line-height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.sk.is-demo .sk__row,
.sk.is-demo .sk__label,
.sk.is-demo .sk__input,
.sk.is-demo .sk__btn,
.sk.is-demo .sk__ghost,
.sk__row.is-bad {
animation: none !important;
}
.sk.is-demo .sk__label {
color: #c2361a;
}
.sk.is-demo .sk__input {
border-color: #c2361a;
background: #ffe0d8;
}
.sk__label, .sk__input, .sk__btn {
transition: none;
}
}// 실제 동작: 제출을 눌러야 검사한다. 비어 있는 칸만 흔들리고 라벨이 켜지며, 다 채우면 같은 라벨이 확정 색으로 넘어간다.
var sk = document.getElementById('sk');
var skSay = document.getElementById('sk-say');
var skRows = [].slice.call(document.querySelectorAll('.sk__row'));
sk.addEventListener('submit', function (e) {
e.preventDefault();
sk.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var bad = 0;
skRows.forEach(function (row) {
var input = row.querySelector('.sk__input');
var empty = input.value.trim().length === 0;
input.setAttribute('aria-invalid', empty ? 'true' : 'false');
row.classList.toggle('is-ok', !empty);
row.classList.remove('is-bad');
if (empty) {
void row.offsetWidth; // 리플로를 강제해야 같은 흔들림이 다시 돈다
row.classList.add('is-bad');
bad += 1;
}
});
skSay.textContent = bad ? bad + '칸이 비어 있어요' : '두 칸을 모두 채웠어요';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SubmitShakeLabel.module.scss";
export type ShakeField = { label: string; type: string; sample: string };
export type SubmitShakeLabelProps = {
fields?: ShakeField[];
submitLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
const DEFAULT_FIELDS: ShakeField[] = [
{ label: "Email", type: "email", sample: "[email protected]" },
{ label: "Phone", type: "tel", sample: "010-1234-5678" },
];
export default function SubmitShakeLabel({
fields = DEFAULT_FIELDS,
submitLabel = "Sign up",
demo = true,
duration = 300,
easing = "cubic-bezier(.36,.07,.19,.97)",
color = "#2f6df6",
}: SubmitShakeLabelProps) {
const [values, setValues] = useState<string[]>(fields.map(() => ""));
const [tries, setTries] = useState(0);
const [live, setLive] = useState(demo);
const empty = values.map((v) => v.trim().length === 0);
const badCount = empty.filter(Boolean).length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function set(i: number, v: string) {
setLive(false);
setValues((prev) => prev.map((old, k) => (k === i ? v : old)));
}
return (
<form
className={`${styles.root} ${live ? styles.isDemo : ""}`}
style={vars}
noValidate
onSubmit={(e: { preventDefault: () => void }) => { e.preventDefault(); setLive(false); setTries((n) => n + 1); }}
>
{fields.map((f, i) => (
// key 에 시도 횟수를 섞어 두면 같은 칸이 다시 마운트되어 흔들림이 매번 새로 돈다
<div
key={`${f.label}-${tries}`}
className={`${styles.row} ${tries && empty[i] ? styles.isBad : ""} ${tries && !empty[i] ? styles.isOk : ""}`}
data-row={i + 1}
>
<label className={styles.label} htmlFor={`sk-${i}`}>{f.label}</label>
<span className={styles.box}>
<input
className={styles.input}
id={`sk-${i}`}
type={f.type}
autoComplete="off"
required
aria-invalid={tries > 0 && empty[i]}
value={values[i]}
onChange={(e: ChangeEvt) => set(i, e.target.value)}
/>
<span className={styles.ghost} aria-hidden="true">{f.sample}</span>
</span>
</div>
))}
<button className={styles.btn} type="submit">{submitLabel}</button>
<p className={styles.say} role="status" aria-live="polite">
{tries === 0 ? "Fill in both fields" : badCount ? `${badCount} field(s) still empty` : "Both fields are filled"}
</p>
</form>
);
}08 · 제출을 눌렀을 때 흔들리며 알리는 칸 — 측정 누적면적 13.172% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
같은 칸을 다시 눌러도 흔들림이 또 돌게 하려면 클래스를 뗀 뒤 리플로를 한 번 강제해야 합니다.
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');
}
});
09첫 오류로 데려가는 포커스 링
어떤 움직임인가
칸이 많아 한 화면에 안 들어가는 폼에서는 "어디가 틀렸는지"를 알려 주는 것만으로 부족합니다. 제출이 막히면 첫 번째 빈 칸을 찾아 scrollIntoView 로 데려가고, 그 칸에 3px 링을 켠 다음 포커스를 넘깁니다.

<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">이름</span><span class="fe__slot" data-filled="1">김빛나</span></div>
<div class="fe__row"><span class="fe__cap">생년월일</span><span class="fe__slot" data-filled="1">1996-04-02</span></div>
<div class="fe__row" id="fe-bad"><span class="fe__cap">이메일</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">주소</span><span class="fe__slot" data-filled="1">성수동 12길</span></div>
</div>
</div>
<button class="fe__btn" type="submit">신청서 제출</button>
<p class="fe__say" id="fe-say" role="status" aria-live="polite">비어 있는 이메일 칸으로 옮겼어요</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 · 첫 오류로 데려가는 포커스 링 — 측정 누적면적 13.372% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
이때 preventScroll: true 를 함께 넘기는 것이 이 항목의 전부입니다.
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 }); // 없으면 부모 페이지까지 끌려 내려간다
});
어디서 깨지는가 — 함정
검증 UI에서 가장 자주 깨지는 곳은 정규식이 아니라 문구가 나타날 자리입니다. 오류 한 줄을 나중에 끼워 넣으면 그 줄 높이만큼 아래가 통째로 밀립니다. 버튼을 누르려던 손가락 밑에서 버튼이 도망가는 것이 이 현상입니다. 그래서 03·05·06·07은 문구 줄에 height: 16px 을 먼저 주고 안쪽 문장들을 position: absolute 로 포개 둡니다. 한 줄이 나타나든 사라지든 바깥 높이는 16px 그대로입니다.
두 번째는 문구를 서서히 바꿀 때 생깁니다. 오류 문장과 완료 문장을 opacity 로 교차시키면 그 사이 프레임에 두 문장이 반투명하게 겹쳐 읽을 수 없는 장면이 남습니다. 24장으로 만드는 미리보기에서는 하필 그 장이 대표 그림으로 뽑히기도 했습니다. 그래서 문구 교체에는 animation-timing-function: steps(1, end) 를 써서 한 프레임에 잘라 냅니다. 색이 서서히 변해도 되는 곳은 테두리와 게이지뿐입니다.
세 번째는 좁은 화면입니다. 이 데모들은 글 안에 480×300 iframe 으로 들어가고 폰에서는 320×200 으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 건 174px뿐인데, 320px 폭에서 재어 보니 아홉 부품의 높이는 68px에서 124px 사이였습니다. 요건 세 줄짜리 04는 118px, 스크롤 상자가 들어간 09는 124px로 가장 컸습니다. 여유가 50px밖에 없으니 좁은 화면에서는 간격을 올리지 않고 내립니다. 04의 요건 줄 간격을 8px에서 4px로 내린 것이 그래서입니다. 입력값이 색이라면 판정 기준이 달라지는데 컬러 피커 직접 만들기가 그 기준을 미리 보여 줍니다. 아홉 벌의 규칙을 화면마다 다르게 적어 두면 관리가 안 되므로 한 폴더에 모아 두었고, 그 압축 파일의 비밀번호는 x97m5gb8 이며 안에는 지금 보고 있는 바닐라 판과 React 판이 같은 값으로 들어 있습니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce 에서 자동 재생 루프를 끄되 판정 결과는 남깁니다. 라벨은 떠오르는 과정 없이 위에 서 있고, 체크는 튀지 않고 그 자리에 있으며, 흔들림은 아예 돌지 않되 오류색과 aria-invalid 는 그대로 남습니다. 움직임이 사라져도 "이 칸이 틀렸다" 는 사실은 사라지지 않아야 하기 때문입니다.
색만으로 판정을 알리지 않는 것도 같은 이유입니다. 아홉 개 전부 색과 함께 문장이나 아이콘을 하나 더 붙였고, 낭독기에는 아래 표대로 전해집니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 필수 라벨 | 라벨·별표·테두리 색 | aria-required · aria-invalid |
포커스가 떠날 때 판정 |
| 02 형식 툴팁 | 굳는 예시 조각 | aria-describedby 로 툴팁 연결 |
포커스에 열림 |
| 03 인라인 문구 | 한 줄 문장 교체 | aria-live="polite" |
한 글자마다 |
| 04 요건 목록 | 줄 색 + 체크 아이콘 | 목록을 aria-describedby 로 연결 |
한 글자마다 |
| 05 강도 게이지 | 칸 수 + 등급 낱말 | 화면 밖 role="status" 문장 |
한 글자마다 |
| 06 글자 수 | 숫자 + 막대 색 | 남은 수를 문장으로 | maxlength 가 막음 |
| 07 상태 아이콘 | 회전·체크·엑스 | 상태마다 aria-live 문장 |
멎고 400ms 뒤 |
| 08 제출 흔들림 | 흔들림 + 라벨 색 | 칸마다 aria-invalid |
제출 버튼 |
| 09 첫 오류 이동 | 3px 포커스 링 | 옮긴 칸 이름을 문장으로 | 제출 버튼 |
폼 검증에서 무엇을 프로그램이 읽을 수 있게 표시해야 하는지는 MDN 의 aria-invalid 문서에 정리되어 있습니다. 같은 계열의 입력 부품은 폼·입력 카테고리에, 오류를 알리는 움직임은 오류 카테고리에 모여 있습니다.
FAQ
입력값 유효성 검사는 치는 동안이 좋나요, 제출할 때가 좋나요?
둘을 섞는 쪽이 낫습니다. 이메일이나 비밀번호처럼 규칙이 분명한 칸은 03·04처럼 치는 동안 알려 주면 제출까지 갔다가 되돌아오는 일이 줄어듭니다. 다만 아직 다 치지도 않았는데 빨간 문구부터 띄우면 방해가 되므로, 01처럼 포커스가 한 번 떠난 뒤부터 오류를 그리는 방식이 무난합니다. 한꺼번에 보는 검사는 08처럼 제출 버튼에 답니다. 판정 결과를 흔들리는 애니메이션으로 보여 주는 판은 CSS 유효성 검사 애니메이션에 있습니다.
정규식을 파일 안에 두면 서버 검증은 안 해도 되나요?
화면 쪽 검사는 사람이 덜 헤매게 하는 장치일 뿐이고, 브라우저에서 돌아가는 코드는 누구나 고칠 수 있습니다. 여기 아홉 개의 정규식은 전부 서버가 같은 규칙을 한 번 더 보는 것을 전제로 만든 것입니다. maxlength 도 같습니다. 사람이 치거나 붙여넣는 값은 잘라 내지만 개발자 도구나 스크립트가 값을 직접 넣으면 그냥 통과하므로, 06의 길이 제한도 서버에서 다시 셉니다.
오류 문구는 칸 위에 두는 게 좋나요, 아래에 두는 게 좋나요?
아래가 무난합니다. 위에 두면 입력칸이 아래로 밀려 손가락이 따라가야 하고, 모바일 키보드가 올라온 상태에서는 문구가 화면 밖으로 밀려나기도 합니다. 다만 02처럼 예시를 미리 보여 주는 힌트는 위가 낫습니다. 값을 치는 동안 키보드가 가리지 않는 자리이기 때문입니다. 어느 쪽이든 자리를 미리 비워 두는 것이 먼저입니다.








