フォーム バリデーション UI 9選 — 打ち込んだ瞬間に判定するエラー表示
フォーム バリデーションは、打ち込まれた値が規則に合うかをその場で測り、結果を画面で知らせる仕組みです。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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 必須マーク付きフローティングラベル
- 02 フォーカスで開く書式例ツールチップ
- 03 入力中に切り替わるインライン文言
- 04 一つずつ埋まる要件チェックリスト
- 05 スコアを測るパスワード強度ゲージ
- 06 上限が近づくと警告に変わる文字数カウンタ
- 07 待ってから判定するステータスアイコン
- 08 送信時に揺れて知らせる欄
- 09 最初のエラーへ連れて行くフォーカスリング
並び順は人気順ではなく、一人がフォームを埋めながらつまずく順です。まず何が必須かを入力前に知り(01)、どんな書式で書くのかを例で知り(02)、打っている最中に合っているかを一行で聞きます(03)。次の3つは規則が複数ある欄です。要件を一つずつ消し(04)、点数で強さを測り(05)、長さの上限に近づきます(06)。最後の3つは判定が遅れて届く場合です。サーバーの返事を待ってから判定し(07)、送信を押してまとめて検査し(08)、画面の外にある最初のエラーまで連れて行きます(09)。入力 フォーム バリデーションの9つの規則はすべてファイルの中に正規表現として書いてあり、色と動きはその判定の結果だけを描きます。入力欄を集めて会員登録画面にすれば会員登録 フォーム デザイン 入力欄で、段階に分ければフォーム ステップ uiが引き継ぎます。この九つの判定は入力が止まった瞬間ではなく、変換の確定を挟んだ後に走るよう遅延を置きました。日本語はIMEの確定前後で値が二度変わるため、確定前の仮名に反応すると誤ったエラーを見せることになります。02番の書式例示はその隙間を埋める役割で、半角数字と全角数字のどちらで入力しても同じ桁数として数える正規化も先に決めておきました。
01必須マーク付きフローティングラベル
「フォーム バリデーションとは」何が見えるか
ラベルは入力欄の中にあり、文字を打つと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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
一度も触れていない欄を赤く塗ると、フォームを開いた瞬間に画面が赤だらけになるからです。
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 と書かれたツールチップが上から降りてきます。例は3つの断片に切ってあり、打った値がその断片の正規表現にマッチするたびに、その断片だけが確定色に固まります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
入力値は数字だけ残してから3桁・4桁に組み直すので、ハイフンは自動で入ります。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
その行に 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一つずつ埋まる要件チェックリスト
何が見えるか
パスワード欄の下の3行が、それぞれ固有の正規表現を持っています。1行が条件を満たすとその行だけが確定色に変わり、丸の中のチェックが 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">8文字より長く</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">数字を1つ入れる</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">記号を1つ入れる</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
規則を配列にまとめておくと、要件を増やしても順番を変えても画面側のコードには手を入れずに済みます。
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スコアを測るパスワード強度ゲージ
画面で起きること
4つの条件を別々に測って0から4までの一つの点数にまとめると、画面はその数字だけを見てマスの数と等級の語と色を決めます。マスは scaleX で左から伸び、トラックは grid-template-columns: repeat(4, 1fr) で4つを等しく割ってあります。

<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">4つの条件のうち1つだけ満たしています</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 = ['4つの条件をまだ1つも満たしていません', '4つの条件のうち1つだけ満たしています', '4つの条件のうち2つ満たしています',
'あと1つでパスワードが強くなります', '4つの条件をすべて満たしています'];
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
等級の語は3つを重ねて置き、一つだけを点けます。
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">配送は1日で届き、梱包も丁寧でした</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
進行バーは幅ではなく 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 + ' left';
ccSay.textContent = used + ' of ' + CC_MAX + ' characters used';
}
07待ってから判定するステータスアイコン
何が見えるか
IDの重複確認のようにサーバーへ問い合わせる欄では1文字ごとに聞くわけにはいかないので、入力が止まってから400ms後に一度だけ測るよう setTimeout を仕掛け、新しい文字が入ったら clearTimeout で前の予約を捨てます。
<main class="stage">
<div class="dm is-demo" id="dm" data-state="idle">
<label class="dm__label" for="dm-input">ID</label>
<div class="dm__field">
<input class="dm__input" id="dm-input" type="text" autocomplete="off" aria-describedby="dm-say" placeholder="4文字より多く入力してください">
<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">使えるIDです</span><span class="dm__m" data-m="no">規則に合わないIDです</span>
</span>
<span class="dm__say" id="dm-say" role="status" aria-live="polite">IDを入力してください</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', 'IDを入力してください'); return; }
dmSet('wait', '確認しています');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? '使えるIDです' : '規則に合わないIDです');
}, 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
待っている間はスピナーが回り、判定が出るとチェックが弾んで現れるか、バツが左右に揺れながら入ってきます。
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
dmInput.addEventListener('input', function () {
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', 'Enter a username'); return; }
dmSet('wait', 'Checking');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? 'This username is available' : 'This username does not fit the rule');
}, 400);
});
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
同じ欄をもう一度送信しても揺れが再び走るようにするには、クラスを外してからリフローを一度強制する必要があります。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
このとき 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) を使い、1フレームで切ります。ゆっくり色が変わってよいのは枠線とゲージだけです。
三つ目は狭い画面です。このデモは記事の中に480×300のiframeで入り、スマートフォンでは320×200に縮みます。ステージの余白を引くと縦に使えるのは174pxだけですが、320px幅で測ったところ9つの部品の高さは68pxから124pxの間でした。要件3行の04が118px、スクロール箱の入った09が124pxで最も大きい値です。余裕が50pxしかないので、狭い画面では間隔を上げずに下げます。04の要件行の間隔を8pxから4pxに下げたのはそのためです。入力値が色なら判定基準が変わり、カラーピッカー UIがその基準を先に見せます。9通りの規則を画面ごとに違う書き方で置くと管理できないので、一つのフォルダにまとめてあり、その圧縮ファイルのパスワードは x97m5gb8 で、中には今見ているバニラ版とReact版が同じ値で入っています。
アクセシビリティ
9つとも prefers-reduced-motion: reduce では自動再生のループを止めますが、判定結果は残します。ラベルは浮き上がる動きなしに最初から上にあり、チェックは弾まずその場に現れ、揺れはまったく走らない一方で、エラー色と aria-invalid はそのまま残ります。動きが消えても「この欄が違う」という事実まで消えてはいけないからです。
色だけで判定を伝えないのも同じ理由です。9つとも色と一緒に文かアイコンをもう一つ添えてあり、スクリーンリーダーへは下の表のとおり届きます。
| 項目 | 目で見て | スクリーンリーダーへ | きっかけ |
|---|---|---|---|
| 01 必須ラベル | ラベル・アスタリスク・枠線の色 | aria-required と aria-invalid |
フォーカスが外れたとき |
| 02 書式ツールチップ | 固まっていく例の断片 | aria-describedby でツールチップを連結 |
フォーカスで開く |
| 03 インライン文言 | 一行の文の入れ替え | aria-live="polite" |
1文字ごと |
| 04 要件リスト | 行の色とチェックアイコン | aria-describedby でリストを連結 |
1文字ごと |
| 05 強度ゲージ | マスの数と等級の語 | 画面外の role="status" の文 |
1文字ごと |
| 06 文字数 | 数字とバーの色 | 残りの数を文で | maxlength が止める |
| 07 ステータスアイコン | スピナー・チェック・バツ | 状態ごとの aria-live の文 |
入力が止まって400ms後 |
| 08 送信時の揺れ | 揺れとラベルの色 | 欄ごとの aria-invalid |
送信ボタン |
| 09 最初のエラーへ移動 | 3pxのフォーカスリング | 移った欄の名前を文で | 送信ボタン |
判定をプログラムから読めるようにするために何を書いておくべきかは、MDNのaria-invalidの解説にまとまっています。同じ系統の入力部品はフォーム・入力のカテゴリに、エラーを知らせる動きはエラーのカテゴリに集めてあります。
FAQ
入力中に検査するのと送信時に検査するのは、どちらがよいですか
両方を混ぜるのが無難です。メールやパスワードのように規則がはっきりした欄では、03や04のように打っている最中に知らせると、送信まで行って戻される回数が減ります。ただし打ち終わってもいないのに赤い文言を出すと邪魔になるので、01のようにフォーカスが一度外れてからエラーを描く方式が安全です。まとめて見る検査は08のように送信ボタンに付けます。判定結果を揺れるアニメーションで見せる版はCSS バリデーション アニメーションにあります。
正規表現をファイルに置けば、サーバー側の検証は省けますか
画面側の検査は人が迷わないための仕掛けにすぎず、ブラウザで動くコードは誰でも書き換えられます。ここにある9つの正規表現は、すべてサーバーが同じ規則をもう一度見ることを前提に作ってあります。maxlength も同じです。人が打ったり貼り付けたりした値は切り詰めますが、開発者ツールやスクリプトが値を直接入れると素通りするので、06の長さ制限もサーバーで数え直します。
「フォーム エラー メッセージ」は欄の上と下のどちらに置くのがよいですか
下が無難です。上に置くと入力欄が下へ押されて指が追いかけることになり、モバイルのキーボードが出ている状態では文言が画面の外へ押し出されることもあります。ただし02のように例を先に見せるヒントは上のほうが向いています。値を打っている間にキーボードが隠さない場所だからです。どちらにしても、場所をあらかじめ空けておくことが先決です。








