04하나씩 채워지는 요건 체크리스트
비밀번호 아래 세 줄의 요건이 각자의 정규식을 통과하는 순간마다 원 안의 체크가 튀어나타며 줄 전체가 확정 색으로 넘어갑니다.
쓰임비밀번호 만들기 규칙을 먼저 보여줘야 하는 회원가입
scaleopacityRegExp

<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 포함 · 폼 유효성 검사 UI 9종 — 입력하는 순간 판정하는 오류 안내
이 데모가 속한 글
폼 유효성 검사 UI 9종 — 입력하는 순간 판정하는 오류 안내폼 유효성 검사는 사람이 친 값이 규칙에 맞는지 그 자리에서 재서 화면으로 알려 주는 장치입니다. 아홉 개 모두 실제 정규식이 돌아가고, 입력하는 순간 판정하는 쪽과 제출을 눌러야 판정하는 쪽이 섞여 있습니다. 이 글의 데모 전부 보기


