CSS 入力フォーム フォーカスアニメーション9選 一行でコピペOK
css 入力フォームのアニメーションは、クリックする前・クリックした瞬間・値を入力し終えたあとまで、ブラウザがすでに持っている:focus:focus-within:placeholder-shown:valid:invalidという状態をそのまま動きに変換する作業です。
01 input-search-focus-floating-label
02 input-search-focus-underline-grow
03 input-search-focus-has-focus-glow
04 input-search-focus-search-expand
05 input-search-focus-placeholder-slide
06 input-search-focus-error-shake
07 input-search-focus-select-custom-open
08 input-search-focus-char-count
09 input-search-focus-password-eye-toggle
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 フローティングラベル
- 02 フォーカスで下線が伸びる
- 03 :has(:focus)でグロー
- 04 検索ボックス拡張
- 05 プレースホルダースライド
- 06 エラーで揺れる
- 07 カスタムセレクトが開く
- 08 文字数カウント
- 09 パスワード目アイコン切替
9つとも本物の<input>を使い、「入力フォーム css おしゃれ」のような派手さの順ではなく入力欄が必要とする判定が増える順に並べました。01・02・04・05は:focus``:focus-within``:placeholder-shownだけで完結し、03はそこに:has()を重ねて離れた要素の状態を検知し、06はブラウザ自身のHTML5検証(:valid/:invalid)が判定を肩代わりし、07〜09はクリックやキー入力のたびに状態が切り替わる場面なので、リストを開く・文字数を数える・目アイコンを切り替えるというCSSの状態だけでは無理な3か所にだけ最小限のJavaScriptが入ります。ここの9つは利用者が入力欄に触れたときだけ反応しますが、触れなくても回り続ける@keyframes側の9つはCSS キーフレーム アニメーション 9選に別途まとめています。08の文字数カウントや09の目アイコンのように入力欄へ添える印そのものを線として描いて動かしたい場合は、CSS アイコン アニメーション 9選のSVGの組み方がこの9つとそのまま噛み合います。
01フローティングラベル
ラベルが浮かぶ条件
inputの後ろに兄弟として置いたlabelが、:focusになるか値が入って:not(:placeholder-shown)になった瞬間に左上へ浮かび上がって縮小します。プレースホルダーとラベルを一つの要素にまとめるので、狭いフォームでラベル専用の行を一本節約できます。
浮上にはcubic-bezier(.3, 1.6, .4, 1)のスプリングオーバーシュートが付いていて、目標位置を通り過ぎてから戻って落ち着きます。同じ瞬間、入力枠の縁から外へ広がるradialグローが一度だけ流れます。デモの自動再生も.focus()と.blur()を行き来する3行のJSがあるだけで、実際のページでは値を打つかタブキーでフォーカスするだけで同じラベルが浮かびます。

<main class="stage">
<div class="fl is-demo">
<input id="fl-nick" class="fl__input" type="text" placeholder=" " autocomplete="nickname">
<span class="fl__bloom" aria-hidden="true"></span>
<label for="fl-nick" class="fl__label">ニックネーム</label>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 4개 ──────────────────────────────────────────
$duration: $dur-quick; // 라벨이 떠오르는 시간
$easing: $ease-out;
$spring: cubic-bezier(.3, 1.6, .4, 1); // 스프링 오버슛 — 라벨이 목표 스케일을 살짝 지나쳤다 돌아온다
$color: $subject-cream; // 포커스 시 라벨·테두리 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.fl {
position: relative;
width: min(240px, 82vw);
}
.fl__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .35);
border-radius: $r-control;
padding: $sp-5 $sp-4 $sp-2;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: transparent;
position: relative;
transition: border-color $duration $easing;
&:focus { outline: none; border-color: $color; }
}
// 테두리 radial 글로우 확산 — 포커스 순간 input 중심에서 바깥으로 번지는 빛 한 겹
.fl__bloom {
position: absolute;
inset: -6px;
border-radius: calc(#{$r-control} + 6px);
background: radial-gradient(circle, rgba($color, .32) 0%, rgba($color, 0) 70%);
opacity: 0;
pointer-events: none;
}
.fl__input:focus ~ .fl__bloom { animation: bloom $dur-base $ease-out; }
.fl__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center; // 규칙8: 인풋 왼쪽 붙은 라벨
color: rgba($subject-cream, .6);
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
pointer-events: none;
transition: transform $dur-base $spring, color $duration $easing;
}
// 실사용: 포커스거나 값이 있으면(:not(:placeholder-shown)) 라벨이 떠오른다
.fl__input:focus ~ .fl__label,
.fl__input:not(:placeholder-shown) ~ .fl__label {
transform: translateY(-24px) scale(.78);
color: $color;
}
// 스프링 오버슛의 실체 — 스케일이 .78 을 지나 .72 까지 줄었다가(.625 초) 제자리로 돌아온다
@keyframes bloom {
0% { opacity: 0; transform: scale(.6); }
35% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.25); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 자리만 바뀐다 ──────
@media (prefers-reduced-motion: reduce) {
.fl__label { transition: none; }
.fl__input:focus ~ .fl__bloom { animation: 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;
width: min(240px, 82vw);
}
.fl__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.35);
border-radius: 12px;
padding: 20px 16px 8px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: transparent;
position: relative;
transition: border-color 220ms ease-out;
}
.fl__input:focus {
outline: none;
border-color: #fff7e6;
}
.fl__bloom {
position: absolute;
inset: -6px;
border-radius: calc(12px + 6px);
background: radial-gradient(circle, rgba(255, 247, 230, 0.32) 0%, rgba(255, 247, 230, 0) 70%);
opacity: 0;
pointer-events: none;
}
.fl__input:focus ~ .fl__bloom {
animation: bloom 300ms ease-out;
}
.fl__label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
color: rgba(255, 247, 230, 0.6);
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.3, 1.6, 0.4, 1), color 220ms ease-out;
}
.fl__input:focus ~ .fl__label,
.fl__input:not(:placeholder-shown) ~ .fl__label {
transform: translateY(-24px) scale(0.78);
color: #fff7e6;
}
@keyframes bloom {
0% {
opacity: 0;
transform: scale(0.6);
}
35% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.25);
}
}
@media (prefers-reduced-motion: reduce) {
.fl__label {
transition: none;
}
.fl__input:focus ~ .fl__bloom {
animation: none;
}
}// .is-demo 자동 재생: 진짜 focus 를 오가며 :focus 규칙을 그대로 탄다 (3줄)
var el = document.querySelector('.is-demo .fl__input');
var t = function () { document.activeElement === el ? el.blur() : el.focus({ preventScroll: true }); }; // preventScroll — iframe 안 포커스가 홈 그리드의 부모 스크롤을 끌어당기지 않게 (사용자 2026-09-10 실측)
t(); setInterval(t, 400);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./FloatingLabel.module.scss";
export type FloatingLabelProps = {
id?: string;
label?: string;
type?: string;
/** 라벨이 떠오르는 시간(ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FloatingLabel({
id = "fl-nick",
label = "Nickname",
type = "text",
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: FloatingLabelProps) {
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fl} style={vars}>
<input
id={id}
className={styles.input}
type={type}
placeholder=" "
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<span className={styles.bloom} aria-hidden="true" />
<label htmlFor={id} className={styles.label}>{label}</label>
</div>
);
}01 · フローティングラベル — 計測 累積面積 7.982% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
デモの自動再生の仕組み
.fl__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
transition: transform $duration $easing, color $duration $easing;
}
.fl__input:focus ~ .fl__label,
.fl__input:not(:placeholder-shown) ~ .fl__label {
transform: translateY(-24px) scale(.78);
color: $color;
}
02フォーカスで下線が伸びる
下線の役目を持つspan一本
枠線のないinputの下に置いたspan一本が下線の役目を持ち、親の.ugが:focus-withinになるとtransform-origin: centerからscaleX(0)がscaleX(1)まで育ちます。
下線はscaleX(1)を通り越して一度伸びきってから巻き戻るスプリングオーバーシュート(cubic-bezier(.2, 1.5, .35, 1))で伸び、フォーカスの瞬間に下線の下へ楕円形のradialグローが一度だけ広がります。見た目のラベル文はinputの上に別のテキストとして置いてあります。グリッドの中では.focus()と.blur()を往復する3行のJSがループさせているだけで、実際のフォームでは中のinputをクリックするかタブで移動するだけで下線が伸びます。

<main class="stage">
<div class="ug is-demo">
<span class="ug__label">ウェブサイト</span>
<input class="ug__input" type="url" placeholder="https://example.com" autocomplete="url">
<span class="ug__underline" aria-hidden="true"></span>
<span class="ug__bloom" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 밑줄이 자라는 시간
$easing: cubic-bezier(.2, 1.5, .35, 1); // 스프링 오버슛 — scaleX 가 1 을 지나 1.08 까지 뻗었다가 돌아온다
$color: $subject-ink; // 밑줄 색
@include stage($stage-yellow);
.ug {
position: relative;
width: min(240px, 82vw);
padding-bottom: $sp-3;
}
.ug__label {
display: block;
margin-bottom: $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba($stage-ink, .7);
}
.ug__input {
box-sizing: border-box;
width: 100%;
border: 0;
border-bottom: 2px solid rgba($stage-ink, .3);
padding: $sp-2 $sp-1;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: transparent;
&:focus { outline: none; }
}
.ug__underline {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: center;
transition: transform $duration $easing;
}
// 실사용: 입력이나 그 자식 어떤 곳에 포커스가 가도(:focus-within) 밑줄이 자란다
.ug:focus-within .ug__underline { transform: scaleX(1); }
// 포커스 순간 밑줄 중심에서 아래로 번지는 radial 글로우 확산 한 겹
.ug__bloom {
position: absolute;
left: 0; right: 0; bottom: -10px;
height: 30px;
background: radial-gradient(ellipse 60% 100% at 50% 0%, rgba($color, .25) 0%, rgba($color, 0) 72%);
opacity: 0;
pointer-events: none;
}
.ug:focus-within .ug__bloom { animation: ug-bloom $dur-base $ease-out; }
@keyframes ug-bloom {
0% { opacity: 0; transform: scaleX(.4); }
40% { opacity: 1; transform: scaleX(1); }
100% { opacity: 0; transform: scaleX(1.15); }
}
@media (prefers-reduced-motion: reduce) {
.ug__underline { transition: none; }
.ug:focus-within .ug__bloom { animation: 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);
}
.ug {
position: relative;
width: min(240px, 82vw);
padding-bottom: 12px;
}
.ug__label {
display: block;
margin-bottom: 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
}
.ug__input {
box-sizing: border-box;
width: 100%;
border: 0;
border-bottom: 2px solid rgba(23, 20, 26, 0.3);
padding: 8px 4px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: transparent;
}
.ug__input:focus {
outline: none;
}
.ug__underline {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: #17141a;
transform: scaleX(0);
transform-origin: center;
transition: transform 300ms cubic-bezier(0.2, 1.5, 0.35, 1);
}
.ug:focus-within .ug__underline {
transform: scaleX(1);
}
.ug__bloom {
position: absolute;
left: 0;
right: 0;
bottom: -10px;
height: 30px;
background: radial-gradient(ellipse 60% 100% at 50% 0%, rgba(23, 20, 26, 0.25) 0%, rgba(23, 20, 26, 0) 72%);
opacity: 0;
pointer-events: none;
}
.ug:focus-within .ug__bloom {
animation: ug-bloom 300ms ease-out;
}
@keyframes ug-bloom {
0% {
opacity: 0;
transform: scaleX(0.4);
}
40% {
opacity: 1;
transform: scaleX(1);
}
100% {
opacity: 0;
transform: scaleX(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.ug__underline {
transition: none;
}
.ug:focus-within .ug__bloom {
animation: none;
}
}// .is-demo 자동 재생: 진짜 focus 를 오가며 :focus-within 을 그대로 탄다 (3줄)
var el = document.querySelector('.is-demo .ug__input');
var t = function () { document.activeElement === el ? el.blur() : el.focus({ preventScroll: true }); }; // preventScroll — iframe 안 포커스가 홈 그리드의 부모 스크롤을 끌어당기지 않게 (사용자 2026-09-10 실측)
t(); setInterval(t, 450);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./UnderlineGrow.module.scss";
export type UnderlineGrowProps = {
label?: string;
placeholder?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function UnderlineGrow({
label = "Website",
placeholder = "https://example.com",
duration = 300,
easing = "cubic-bezier(.2,1.5,.35,1)",
color = "#17141a",
}: UnderlineGrowProps) {
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ug} style={vars}>
<span className={styles.label}>{label}</span>
<input
className={styles.input}
type="url"
placeholder={placeholder}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<span className={styles.underline} aria-hidden="true" />
<span className={styles.bloom} aria-hidden="true" />
</div>
);
}02 · フォーカスで下線が伸びる — 計測 累積面積 1.592% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
グリッドのループと実際のフォーム
.ug__underline {
height: 2px;
background: $color;
transform: scaleX(0);
transform-origin: center;
transition: transform $duration $easing;
}
.ug:focus-within .ug__underline { transform: scaleX(1); }
03:has(:focus)でグロー
グローリングはどこにあるか
グローリングはinputの兄弟ではなく.fgの外側に別に置かれたspanで、親が:has(.fg__input:focus)で子の状態を検知してglow-pulseという@keyframesをopacityとscaleで無限に繰り返します。
突入時にはまずglow-inがscale(.8)から1.08を経て.92へ巻き込むスプリング進入を一度だけ演じ、その着地位置から呼吸のループが引き継ぎます。フォーカスが外れると呼吸も止まります。デモのループも.focus()と.blur()を切り替える3行のJSで再生していて、:has()対応ブラウザなら実際のページでもタブ移動だけでリングが呼吸を始めます。

<main class="stage">
<div class="fg is-demo">
<input class="fg__input" type="text" placeholder="メモを残す">
<span class="fg__glow" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 글로우가 한 번 숨쉬는 시간
$easing: $ease-out;
$color: $subject-cream; // 글로우 링 색
@include stage($stage-orange);
.fg { position: relative; width: min(240px, 82vw); }
.fg__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .4);
border-radius: $r-control;
padding: $sp-4 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: transparent;
position: relative;
z-index: 1;
&:focus { outline: none; }
}
.fg__glow {
position: absolute;
inset: -$sp-2;
border-radius: $r-card;
border: 2px solid $color;
opacity: 0;
transform: scale(.92);
pointer-events: none;
}
// :has() 로 부모(.fg)가 자식 입력의 실제 포커스 여부를 안다 — 형제가 아니어도 스타일링된다
.fg:has(.fg__input:focus) .fg__glow {
animation: glow-in $dur-base cubic-bezier(.3, 1.6, .4, 1) both,
glow-pulse $duration $easing $dur-base infinite;
}
// 스프링 오버슛 등장 — 링이 1.08 까지 뻗었다가 1 로 감아 들어간 뒤 숨쉬기로 넘어간다
@keyframes glow-in {
0% { opacity: 0; transform: scale(.8); }
60% { opacity: .95; transform: scale(1.08); }
100% { opacity: .35; transform: scale(.92); }
}
@keyframes glow-pulse {
0%, 100% { opacity: .35; transform: scale(.92); }
50% { opacity: .9; transform: scale(1.04); }
}
@media (prefers-reduced-motion: reduce) {
.fg:has(.fg__input:focus) .fg__glow { animation: none; opacity: .7; transform: scale(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);
}
.fg {
position: relative;
width: min(240px, 82vw);
}
.fg__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.4);
border-radius: 12px;
padding: 16px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: transparent;
position: relative;
z-index: 1;
}
.fg__input:focus {
outline: none;
}
.fg__glow {
position: absolute;
inset: -8px;
border-radius: 16px;
border: 2px solid #fff7e6;
opacity: 0;
transform: scale(0.92);
pointer-events: none;
}
.fg:has(.fg__input:focus) .fg__glow {
animation: glow-in 300ms cubic-bezier(0.3, 1.6, 0.4, 1) both, glow-pulse 2s ease-out 300ms infinite;
}
@keyframes glow-in {
0% {
opacity: 0;
transform: scale(0.8);
}
60% {
opacity: 0.95;
transform: scale(1.08);
}
100% {
opacity: 0.35;
transform: scale(0.92);
}
}
@keyframes glow-pulse {
0%, 100% {
opacity: 0.35;
transform: scale(0.92);
}
50% {
opacity: 0.9;
transform: scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.fg:has(.fg__input:focus) .fg__glow {
animation: none;
opacity: 0.7;
transform: scale(1);
}
}// .is-demo 자동 재생: 진짜 focus 를 오가며 :has(:focus) 를 그대로 탄다 (3줄)
var el = document.querySelector('.is-demo .fg__input');
var t = function () { document.activeElement === el ? el.blur() : el.focus({ preventScroll: true }); }; // preventScroll — iframe 안 포커스가 홈 그리드의 부모 스크롤을 끌어당기지 않게 (사용자 2026-09-10 실측)
t(); setInterval(t, 1600);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./FocusGlow.module.scss";
export type FocusGlowProps = {
placeholder?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FocusGlow({
placeholder = "Leave a note",
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: FocusGlowProps) {
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fg} style={vars}>
<input
className={styles.input}
type="text"
placeholder={placeholder}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<span className={styles.glow} aria-hidden="true" />
</div>
);
}03 · :has(:focus)でグロー — 計測 累積面積 6.35% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
対応ブラウザでの動き
.fg__glow {
border: 2px solid $color;
opacity: 0;
transform: scale(.92);
}
.fg:has(.fg__input:focus) .fg__glow {
animation: glow-pulse $duration $easing infinite;
}
@keyframes glow-pulse {
0%, 100% { opacity: .35; transform: scale(.92); }
50% { opacity: .9; transform: scale(1.04); }
}
04検索ボックス拡張
「css 検索ボックス」が広がる幅
ピル型のlabel.seは普段148pxに折りたたまれていて、.se:focus-withinになった瞬間に240pxまで広がります。
幅は240pxを少し行き過ぎてから整うスプリングカーブ(cubic-bezier(.3, 1.4, .4, 1))で広がり、広がる瞬間にピルの内側でradialグローが一度だけ咲きます。左のアイコンは円と線一本だけで描いたインラインSVGなので、アイコンフォントを読み込まなくても欠けません。自動再生も.focus()と.blur()を交互に呼ぶ3行のJSだけで動いていて、実際のヘッダーではクリックかタブでの移動がそのままきっかけになります。

<main class="stage">
<label class="se is-demo" for="se-input">
<svg class="se__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><line x1="20" y1="20" x2="15.7" y2="15.7"/></svg>
<input id="se-input" class="se__input" type="search" placeholder="検索キーワードを入力" aria-label="検索">
<span class="se__bloom" aria-hidden="true"></span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 폭이 넓어지는 시간
$easing: cubic-bezier(.3, 1.4, .4, 1); // 스프링 오버슛 — 폭이 목표 240px 을 지나 살짝 뻗었다가 돌아온다
$color: $subject-ink; // 포커스 시 테두리·아이콘 색
@include stage($stage-paper);
.se {
display: inline-flex;
align-items: center;
gap: $sp-2;
border-radius: $r-pill;
border: 2px solid rgba($stage-ink, .25);
padding: $sp-3 $sp-4;
cursor: text;
background: $subject-cream;
width: 148px;
position: relative;
transition: width $duration $easing, border-color $duration $easing;
}
// 실사용: 입력에 포커스가 가면(:focus-within) 폭이 넓어진다
.se:focus-within { width: 240px; border-color: $color; }
// 포커스 순간 필 중심에서 번지는 radial 글로우 확산 한 겹
.se__bloom {
position: absolute;
inset: -8px;
border-radius: $r-pill;
background: radial-gradient(ellipse at 50% 50%, rgba($color, .18) 0%, rgba($color, 0) 70%);
opacity: 0;
pointer-events: none;
}
.se:focus-within .se__bloom { animation: se-bloom $dur-base $ease-out; }
@keyframes se-bloom {
0% { opacity: 0; transform: scale(.7); }
40% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.2); }
}
.se__icon {
width: 18px; height: 18px;
flex: none;
fill: none;
stroke: $color;
stroke-width: 2;
stroke-linecap: round;
}
.se__input {
border: 0;
outline: 0;
background: transparent;
width: 100%;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
@media (prefers-reduced-motion: reduce) {
.se { transition: none; }
.se:focus-within .se__bloom { animation: 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);
}
.se {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
border: 2px solid rgba(23, 20, 26, 0.25);
padding: 12px 16px;
cursor: text;
background: #fff7e6;
width: 148px;
position: relative;
transition: width 300ms cubic-bezier(0.3, 1.4, 0.4, 1), border-color 300ms cubic-bezier(0.3, 1.4, 0.4, 1);
}
.se:focus-within {
width: 240px;
border-color: #17141a;
}
.se__bloom {
position: absolute;
inset: -8px;
border-radius: 999px;
background: radial-gradient(ellipse at 50% 50%, rgba(23, 20, 26, 0.18) 0%, rgba(23, 20, 26, 0) 70%);
opacity: 0;
pointer-events: none;
}
.se:focus-within .se__bloom {
animation: se-bloom 300ms ease-out;
}
@keyframes se-bloom {
0% {
opacity: 0;
transform: scale(0.7);
}
40% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.2);
}
}
.se__icon {
width: 18px;
height: 18px;
flex: none;
fill: none;
stroke: #17141a;
stroke-width: 2;
stroke-linecap: round;
}
.se__input {
border: 0;
outline: 0;
background: transparent;
width: 100%;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.se {
transition: none;
}
.se:focus-within .se__bloom {
animation: none;
}
}// .is-demo 자동 재생: 진짜 focus 를 오가며 :focus-within 을 그대로 탄다 (3줄)
var el = document.querySelector('.is-demo .se__input');
var t = function () { document.activeElement === el ? el.blur() : el.focus({ preventScroll: true }); }; // preventScroll — iframe 안 포커스가 홈 그리드의 부모 스크롤을 끌어당기지 않게 (사용자 2026-09-10 실측)
t(); setInterval(t, 450);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./SearchExpand.module.scss";
export type SearchExpandProps = {
placeholder?: string;
ariaLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SearchExpand({
placeholder = "Type to search",
ariaLabel = "Search",
duration = 300,
easing = "cubic-bezier(.3,1.4,.4,1)",
color = "#17141a",
}: SearchExpandProps) {
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.se} style={vars}>
<svg className={styles.icon} viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<line x1="20" y1="20" x2="15.7" y2="15.7" />
</svg>
<input
className={styles.input}
type="search"
placeholder={placeholder}
aria-label={ariaLabel}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<span className={styles.bloom} aria-hidden="true" />
</label>
);
}04 · 検索ボックス拡張 — 計測 累積面積 7.744% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
自動再生のきっかけ
.se {
width: 148px;
transition: width $duration $easing, border-color $duration $easing;
}
.se:focus-within { width: 240px; border-color: $color; }
05プレースホルダースライド
placeholderを空欄1文字にする
実際のplaceholder属性は空欄1文字だけにして、重ねたspanが案内文の表示を代わりに担います。inputが:focusになるか値が入って:not(:placeholder-shown)になると、このspanが右へtranslate(10px, -50%)しながらopacity 0で消えます。
消えゆくspanは13pxへscale(.94)を添えて、cubic-bezier(.3, 1.6, .4, 1)のスプリングオーバーシュートで止まり際に行き過ぎてから収まり、去る瞬間にフィールド内をradialグローが一度だけ流れます。こちらも自動再生は.focus()と.blur()を往復する3行のJSに過ぎず、実際のフォームでは値を打ち始めた瞬間に同じspanが消えます。

<main class="stage">
<div class="ps is-demo">
<input id="ps-email" class="ps__input" type="email" placeholder=" " autocomplete="email">
<span class="ps__ghost" aria-hidden="true">メールアドレスを入力</span>
<span class="ps__bloom" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 4개 ──────────────────────────────────────────
$duration: $dur-quick; // 가짜 플레이스홀더가 밀려나는 시간
$easing: cubic-bezier(.3, 1.6, .4, 1); // 스프링 오버슛 — 밀려나는 거리가 목표 10px 을 지나 13px 까지 뻗었다가 돌아온다
$color: $subject-blue; // 포커스 시 테두리 색
@include stage($stage-ink);
.ps { position: relative; width: min(240px, 82vw); }
.ps__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .35);
border-radius: $r-control;
padding: $sp-4 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: transparent;
&:focus { outline: none; border-color: $color; }
}
// 포커스 순간 입력칸 중심에서 번지는 radial 글로우 확산 한 겹
.ps__bloom {
position: absolute;
inset: -6px;
border-radius: calc(#{$r-control} + 6px);
background: radial-gradient(circle, rgba($color, .3) 0%, rgba($color, 0) 70%);
opacity: 0;
pointer-events: none;
}
.ps__input:focus ~ .ps__bloom { animation: ps-bloom $dur-base $ease-out; }
@keyframes ps-bloom {
0% { opacity: 0; transform: scale(.65); }
40% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.22); }
}
// 진짜 placeholder 는 빈 칸(" ") 이고, 보이는 문구는 이 span 이 대신 맡는다
.ps__ghost {
position: absolute;
left: $sp-4; top: 50%;
transform: translate(0, -50%);
color: rgba($subject-cream, .55);
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
pointer-events: none;
transition: transform $dur-base $easing, opacity $duration $ease-out;
}
// 실사용: 포커스거나 값이 있으면(:not(:placeholder-shown)) 옆으로 밀리며 사라진다
.ps__input:focus ~ .ps__ghost,
.ps__input:not(:placeholder-shown) ~ .ps__ghost {
transform: translate(13px, -50%) scale(.94);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.ps__ghost { transition: none; }
.ps__input:focus ~ .ps__bloom { animation: 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);
}
.ps {
position: relative;
width: min(240px, 82vw);
}
.ps__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.35);
border-radius: 12px;
padding: 16px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: transparent;
}
.ps__input:focus {
outline: none;
border-color: #2f6df6;
}
.ps__bloom {
position: absolute;
inset: -6px;
border-radius: calc(12px + 6px);
background: radial-gradient(circle, rgba(47, 109, 246, 0.3) 0%, rgba(47, 109, 246, 0) 70%);
opacity: 0;
pointer-events: none;
}
.ps__input:focus ~ .ps__bloom {
animation: ps-bloom 300ms ease-out;
}
@keyframes ps-bloom {
0% {
opacity: 0;
transform: scale(0.65);
}
40% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.22);
}
}
.ps__ghost {
position: absolute;
left: 16px;
top: 50%;
transform: translate(0, -50%);
color: rgba(255, 247, 230, 0.55);
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.3, 1.6, 0.4, 1), opacity 220ms ease-out;
}
.ps__input:focus ~ .ps__ghost,
.ps__input:not(:placeholder-shown) ~ .ps__ghost {
transform: translate(13px, -50%) scale(0.94);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.ps__ghost {
transition: none;
}
.ps__input:focus ~ .ps__bloom {
animation: none;
}
}// .is-demo 자동 재생: 진짜 focus 를 오가며 :focus·:placeholder-shown 을 그대로 탄다 (3줄)
var el = document.querySelector('.is-demo .ps__input');
var t = function () { document.activeElement === el ? el.blur() : el.focus({ preventScroll: true }); }; // preventScroll — iframe 안 포커스가 홈 그리드의 부모 스크롤을 끌어당기지 않게 (사용자 2026-09-10 실측)
t(); setInterval(t, 400);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./PlaceholderSlide.module.scss";
export type PlaceholderSlideProps = {
ghost?: string;
type?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PlaceholderSlide({
ghost = "Enter your email address",
type = "email",
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: PlaceholderSlideProps) {
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.ps} style={vars}>
<input
className={styles.input}
type={type}
placeholder=" "
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<span className={styles.ghost} aria-hidden="true">{ghost}</span>
<span className={styles.bloom} aria-hidden="true" />
</div>
);
}05 · プレースホルダースライド — 計測 累積面積 7.516% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
自動再生と実際の入力
.ps__ghost {
transform: translate(0, -50%);
transition: transform $duration $easing, opacity $duration $easing;
}
.ps__input:focus ~ .ps__ghost,
.ps__input:not(:placeholder-shown) ~ .ps__ghost {
transform: translate(10px, -50%);
opacity: 0;
}
06エラーで揺れる
ブラウザの:invalid判定だけで揺らす
type="email" requiredのinputに値を入れたあと(:not(:placeholder-shown))、形式が誤っていればブラウザ自身の:invalid判定だけで枠が揺れて×アイコンが現れます。
揺れはサイクルごとに振幅が減る減衰振動(1 → .75 → .55 → .34)として静まり、エラーが現れる瞬間には枠に赤いradialグローが一度だけ走ります。JavaScriptは一行も使っておらず、形式が合っていれば同じ場所に:validのチェックアイコンが出ます。

<main class="stage">
<div class="ie is-demo">
<label class="ie__label" for="ie-email">メールアドレス</label>
<div class="ie__frame">
<span class="ie__bloom" aria-hidden="true"></span>
<input id="ie-email" class="ie__input" type="email" required placeholder=" " autocomplete="email">
<svg class="ie__icon ie__icon--ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 L10 17.5 L19 7" /></svg>
<svg class="ie__icon ie__icon--bad" viewBox="0 0 24 24" aria-hidden="true"><line x1="7" y1="7" x2="17" y2="17"/><line x1="17" y1="7" x2="7" y2="17"/></svg>
</div>
<p class="ie__msg" role="alert">メール形式が正しくありません</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 흔들리는 시간
$easing: cubic-bezier(.36, .07, .19, .97); // 흔들림 전용 이징(요동)
$amount: $sp-1 * 1.25; // 흔들리는 폭(5px)
@include stage($stage-yellow);
.ie { width: min(240px, 82vw); }
.ie__label { display: block; margin-bottom: $sp-2; font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; color: rgba($stage-ink, .75); }
.ie__frame { position: relative; }
.ie__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($stage-ink, .25);
border-radius: $r-control;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
transition: border-color $duration $easing;
&:focus { outline: none; }
}
.ie__icon {
position: absolute;
right: $sp-3; top: 50%;
width: 18px; height: 18px;
transform: translateY(-50%) scale(0);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
pointer-events: none;
}
.ie__icon--ok { fill: none; stroke: $stage-ink; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.ie__icon--bad { fill: none; stroke: $error; stroke-width: 2.4; stroke-linecap: round; }
// 실사용: 값을 입력한 뒤(:not(:placeholder-shown)) HTML5 유효성에 따라 :invalid/:valid 가 갈린다 — JS 없이 브라우저가 판정한다
.ie__input:invalid:not(:placeholder-shown) {
border-color: $error;
animation: shake $duration $easing;
}
// 오류 판정 순간 테두리에서 번지는 radial 글로우 확산 한 겹 — 흔들림과 같이 시작해 같이 멎는다
.ie__bloom {
position: absolute;
inset: -6px;
border-radius: calc(#{$r-control} + 6px);
background: radial-gradient(circle, rgba($error, .28) 0%, rgba($error, 0) 70%);
opacity: 0;
pointer-events: none;
}
.ie:has(.ie__input:invalid:not(:placeholder-shown)) .ie__bloom { animation: ie-bloom $duration $ease-out; }
@keyframes ie-bloom {
0% { opacity: 0; transform: scale(.8); }
30% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.12); }
}
.ie__input:invalid:not(:placeholder-shown) ~ .ie__icon--bad { transform: translateY(-50%) scale(1); opacity: 1; }
.ie__input:valid:not(:placeholder-shown) ~ .ie__icon--ok { transform: translateY(-50%) scale(1); opacity: 1; }
.ie__msg {
margin: $sp-2 0 0;
display: inline-block;
padding: $sp-1 $sp-2; // 옐로 면 위 $error 는 3.79:1 — 페이퍼 면을 깔고 그 위에 5.47:1
border-radius: $r-xs;
background: $stage-paper;
color: $error;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(-4px);
transition: opacity $duration $easing, transform $duration $easing;
}
// .ie__msg 는 .ie__frame 의 형제라 :has() 로 부모(.ie)를 거쳐 연결한다
.ie:has(.ie__input:invalid:not(:placeholder-shown)) .ie__msg { opacity: 1; transform: translateY(0); }
@keyframes shake {
// 감쇠 진동 x(t) = A·e^(-λt)·sin(ωt) — 매 반주기 진폭이 λ 스케일로 줄어든다 (A: 1 → .62 → .34 → .13)
10%, 90% { transform: translateX($amount * -.62); }
20%, 80% { transform: translateX($amount * 1); }
30%, 70% { transform: translateX($amount * -.75); }
40%, 60% { transform: translateX($amount * .55); }
50% { transform: translateX($amount * -.34); }
}
@media (prefers-reduced-motion: reduce) {
.ie__input:invalid:not(:placeholder-shown) { animation: none; }
.ie:has(.ie__input:invalid:not(:placeholder-shown)) .ie__bloom { animation: none; }
.ie__icon, .ie__msg { 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);
}
.ie {
width: min(240px, 82vw);
}
.ie__label {
display: block;
margin-bottom: 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.75);
}
.ie__frame {
position: relative;
}
.ie__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(23, 20, 26, 0.25);
border-radius: 12px;
padding: 12px 40px 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
transition: border-color 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.ie__input:focus {
outline: none;
}
.ie__icon {
position: absolute;
right: 12px;
top: 50%;
width: 18px;
height: 18px;
transform: translateY(-50%) scale(0);
opacity: 0;
transition: transform 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97), opacity 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
pointer-events: none;
}
.ie__icon--ok {
fill: none;
stroke: #17141a;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ie__icon--bad {
fill: none;
stroke: #c2361a;
stroke-width: 2.4;
stroke-linecap: round;
}
.ie__input:invalid:not(:placeholder-shown) {
border-color: #c2361a;
animation: shake 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.ie__bloom {
position: absolute;
inset: -6px;
border-radius: calc(12px + 6px);
background: radial-gradient(circle, rgba(194, 54, 26, 0.28) 0%, rgba(194, 54, 26, 0) 70%);
opacity: 0;
pointer-events: none;
}
.ie:has(.ie__input:invalid:not(:placeholder-shown)) .ie__bloom {
animation: ie-bloom 600ms ease-out;
}
@keyframes ie-bloom {
0% {
opacity: 0;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.12);
}
}
.ie__input:invalid:not(:placeholder-shown) ~ .ie__icon--bad {
transform: translateY(-50%) scale(1);
opacity: 1;
}
.ie__input:valid:not(:placeholder-shown) ~ .ie__icon--ok {
transform: translateY(-50%) scale(1);
opacity: 1;
}
.ie__msg {
margin: 8px 0 0;
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
background: #ffffff;
color: #c2361a;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
font-family: inherit;
opacity: 0;
transform: translateY(-4px);
transition: opacity 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97), transform 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.ie:has(.ie__input:invalid:not(:placeholder-shown)) .ie__msg {
opacity: 1;
transform: translateY(0);
}
@keyframes shake {
10%, 90% {
transform: translateX(-3.1px);
}
20%, 80% {
transform: translateX(5px);
}
30%, 70% {
transform: translateX(-3.75px);
}
40%, 60% {
transform: translateX(2.75px);
}
50% {
transform: translateX(-1.7px);
}
}
@media (prefers-reduced-motion: reduce) {
.ie__input:invalid:not(:placeholder-shown) {
animation: none;
}
.ie:has(.ie__input:invalid:not(:placeholder-shown)) .ie__bloom {
animation: none;
}
.ie__icon, .ie__msg {
transition: none;
}
}// .is-demo 자동 재생: 진짜 값을 오가며 :invalid 를 그대로 탄다 (5줄)
var el = document.querySelector('.is-demo .ie__input');
var bad = true;
var t = function () { el.value = bad ? 'not-an-email' : ''; el.dispatchEvent(new Event('input', { bubbles: true })); bad = !bad; };
t(); setInterval(t, 1500);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./InvalidShake.module.scss";
export type InvalidShakeProps = {
id?: string;
label?: string;
message?: string;
duration?: number;
easing?: string;
amount?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--amount"?: string };
export default function InvalidShake({
id = "ie-email",
label = "Email",
message = "That doesn't look like a valid email",
duration = 600,
easing = "cubic-bezier(.36,.07,.19,.97)",
amount = 5,
}: InvalidShakeProps) {
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--amount": `${amount}px` };
return (
<div className={styles.ie} style={vars}>
<label htmlFor={id} className={styles.label}>{label}</label>
<div className={styles.frame}>
<span className={styles.bloom} aria-hidden="true" />
<input
id={id}
className={styles.input}
type="email"
required
placeholder=" "
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<svg className={`${styles.icon} ${styles.ok}`} viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 L10 17.5 L19 7" /></svg>
<svg className={`${styles.icon} ${styles.bad}`} viewBox="0 0 24 24" aria-hidden="true"><line x1="7" y1="7" x2="17" y2="17" /><line x1="17" y1="7" x2="7" y2="17" /></svg>
</div>
<p className={styles.msg} role="alert">{message}</p>
</div>
);
}06 · エラーで揺れる — 計測 累積面積 2.305% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
形式が合えばチェックが出る
.ie__input:invalid:not(:placeholder-shown) {
border-color: $error;
animation: shake $duration $easing;
}
.ie__input:invalid:not(:placeholder-shown) ~ .ie__icon--bad { transform: translateY(-50%) scale(1); opacity: 1; }
.ie__input:valid:not(:placeholder-shown) ~ .ie__icon--ok { transform: translateY(-50%) scale(1); opacity: 1; }
@keyframes shake {
10%, 90% { transform: translateX($amount * -.5); }
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX($amount * -1); }
40%, 60% { transform: translateX($amount * .7); }
50% { transform: translateX($amount * -.7); }
}
07カスタムセレクトが開く
ネイティブselectを使わない理由
ネイティブのselectは選択肢リストの見た目を変えられないため、この項目だけbuttonとul[role=listbox]を自分で組みました。クリックでis-openクラスを切り替える5行のJSを挟み、開くときはリストがscaleYで伸びてシェブロンが180度回転します。
リストはscaleY(1)を通り越して1.06まで伸びてから巻き戻るスプリングで開き、ボタン下端から下へ広がるradialグローが開いたことを一度だけ確かめます。

<main class="stage">
<div class="cs is-demo">
<button type="button" class="cs__btn" aria-haspopup="listbox" aria-expanded="false" id="cs-btn">
<span class="cs__value">テーマを選択</span>
<svg class="cs__chevron" viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<ul class="cs__list" role="listbox" aria-labelledby="cs-btn">
<li role="option" class="cs__opt">ライト</li>
<li role="option" class="cs__opt">ダーク</li>
<li role="option" class="cs__opt">システム</li>
</ul>
<span class="cs__bloom" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 열리고 닫히는 시간
$easing: cubic-bezier(.3, 1.5, .4, 1); // 스프링 오버슛 — 목록 scaleY 가 1 을 지나 1.06 까지 뻗었다가 감아 들어간다
$color: $subject-ink; // 셰브론 색 — 주황 무대 위 전경은 잉크(크림은 3.11:1 미달)
@include stage($stage-orange);
.cs { position: relative; width: min(200px, 78vw); }
.cs__btn {
display: flex; align-items: center; justify-content: space-between;
width: 100%;
border: 2px solid rgba($subject-ink, .4);
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 600;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: $subject-ink; // 주황 면 위 크림 글자는 3.11:1 — 잉크로 5.50:1
background: transparent;
cursor: pointer;
}
.cs__chevron {
width: 16px; height: 16px;
fill: none; stroke: $color; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
transform: rotate(0deg);
transition: transform $duration $easing;
}
.cs.is-open .cs__chevron { transform: rotate(180deg); }
.cs__list {
position: absolute;
left: 0; right: 0; top: calc(100% + $sp-2);
margin: 0; padding: $sp-2;
list-style: none;
border-radius: $r-control;
background: $subject-cream;
box-shadow: $shadow-raised;
transform: scaleY(0);
transform-origin: top; // 규칙8: 버튼 아래변에서 펼침
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.cs.is-open .cs__list { transform: scaleY(1); opacity: 1; pointer-events: auto; }
// 목록이 열리는 순간 버튼 중심에서 아래로 번지는 radial 글로우 확산 한 겹
.cs__bloom {
position: absolute;
left: 0; right: 0; top: calc(100% + $sp-2);
height: 48px;
background: radial-gradient(ellipse 70% 100% at 50% 0%, rgba($subject-cream, .35) 0%, rgba($subject-cream, 0) 70%);
opacity: 0;
pointer-events: none;
}
.cs.is-open .cs__bloom { animation: cs-bloom $dur-base $ease-out; }
@keyframes cs-bloom {
0% { opacity: 0; transform: scaleY(.5); }
40% { opacity: 1; transform: scaleY(1); }
100% { opacity: 0; transform: scaleY(1.1); }
}
.cs__opt {
padding: $sp-2 $sp-3;
border-radius: $r-sm;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1;
font-family: inherit;
color: $subject-orange-deep; // 크림 면 위 주황 글자는 3.11:1 — deep 으로 5.04:1
&:hover { background: rgba($stage-orange, .08); }
}
@media (prefers-reduced-motion: reduce) {
.cs__chevron, .cs__list { transition: none; }
.cs.is-open .cs__bloom { animation: 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);
}
.cs {
position: relative;
width: min(200px, 78vw);
}
.cs__btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
border: 2px solid rgba(23, 20, 26, 0.4);
border-radius: 12px;
padding: 12px 16px;
font-weight: 600;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: transparent;
cursor: pointer;
}
.cs__chevron {
width: 16px;
height: 16px;
fill: none;
stroke: #17141a;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transform: rotate(0deg);
transition: transform 300ms cubic-bezier(0.3, 1.5, 0.4, 1);
}
.cs.is-open .cs__chevron {
transform: rotate(180deg);
}
.cs__list {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
margin: 0;
padding: 8px;
list-style: none;
border-radius: 12px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scaleY(0);
transform-origin: top;
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.3, 1.5, 0.4, 1), opacity 300ms cubic-bezier(0.3, 1.5, 0.4, 1);
}
.cs.is-open .cs__list {
transform: scaleY(1);
opacity: 1;
pointer-events: auto;
}
.cs__bloom {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
height: 48px;
background: radial-gradient(ellipse 70% 100% at 50% 0%, rgba(255, 247, 230, 0.35) 0%, rgba(255, 247, 230, 0) 70%);
opacity: 0;
pointer-events: none;
}
.cs.is-open .cs__bloom {
animation: cs-bloom 300ms ease-out;
}
@keyframes cs-bloom {
0% {
opacity: 0;
transform: scaleY(0.5);
}
40% {
opacity: 1;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(1.1);
}
}
.cs__opt {
padding: 8px 12px;
border-radius: 8px;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1;
font-family: inherit;
color: #c93000;
}
.cs__opt:hover {
background: rgba(255, 77, 31, 0.08);
}
@media (prefers-reduced-motion: reduce) {
.cs__chevron, .cs__list {
transition: none;
}
.cs.is-open .cs__bloom {
animation: none;
}
}// 실사용: 버튼 클릭으로 연다·닫는다. 데모(.is-demo)는 같은 클래스를 주기적으로 토글 (5줄)
var cs = document.querySelector('.cs');
var btn = cs.querySelector('.cs__btn');
var toggle = function () { cs.classList.toggle('is-open'); btn.setAttribute('aria-expanded', cs.classList.contains('is-open')); };
btn.addEventListener('click', toggle);
if (cs.classList.contains('is-demo')) setInterval(toggle, 500);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CustomSelectOpen.module.scss";
export type CustomSelectOpenProps = {
id?: string;
label?: string;
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CustomSelectOpen({
id = "cs-btn",
label = "Choose theme",
options = ["Light", "Dark", "System"],
duration = 220,
easing = "cubic-bezier(.3,1.5,.4,1)",
color = "#17141a",
}: CustomSelectOpenProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={open ? `${styles.cs} ${styles.open}` : styles.cs} style={vars}>
<button
type="button"
id={id}
className={styles.btn}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span>{label}</span>
<svg className={styles.chevron} viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9" /></svg>
</button>
<ul className={styles.list} role="listbox" aria-labelledby={id}>
{options.map((opt) => (
<li key={opt} role="option" className={styles.opt}>{opt}</li>
))}
</ul>
<span className={styles.bloom} aria-hidden="true" />
</div>
);
}07 · カスタムセレクトが開く — 計測 累積面積 18.322% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
5行のJSが開くリスト
.cs__chevron {
transition: transform $duration $easing;
}
.cs.is-open .cs__chevron { transform: rotate(180deg); }
.cs__list {
transform: scaleY(0);
transform-origin: top;
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.cs.is-open .cs__list { transform: scaleY(1); opacity: 1; }
08文字数カウント
CSSでは数えられないもの
残り文字数はCSSだけでは数えられないので、textareaのinputイベントのたびに数字を書き換え、is-bumpクラスを付け外しするリフローでbumpの@keyframesを再生します。
弾みは反対方向へ二度跳ね返って止まる減衰スプリング反動(1.35 → .853 → 1.062 → 1、減衰係数.42)で、数字が行き過ぎと戻りを経て落ち着きます。aria-live="polite"を添えて、変更をスクリーンリーダーにも伝えます。

<main class="stage">
<div class="cc is-demo">
<label class="cc__label" for="cc-bio">自己紹介</label>
<textarea id="cc-bio" class="cc__input" maxlength="40" placeholder="自己紹介を40文字以内で" rows="2"></textarea>
<span class="cc__count" aria-live="polite"><b class="cc__num">0</b>/40</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-instant; // 숫자가 튕기는 시간
$easing: $ease-pop;
$color: $subject-blue; // 숫자·포커스 색
// ── 스프링 감쇠 반동 계수 ────────────────────────────────
// 진폭이 매 반동마다 γ=.42 배로 줄어든다 — 1 기준 진폭 .35 → -.147 → +.062
$b1: 1.35; // 첫 최대 진폭 (진폭 +.35)
$b2: 1 - .35 * .42; // 1회 반동(반대 방향, 진폭 -.147) → .853
$b3: 1 + .35 * .42 * .42; // 2회 반동(제자리 방향, 진폭 +.062) → 1.062
@include stage($stage-paper);
.cc { width: min(240px, 82vw); }
.cc__label { display: block; margin-bottom: $sp-2; font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; color: rgba($stage-ink, .75); }
.cc__input {
box-sizing: border-box;
width: 100%;
resize: none;
border: 2px solid rgba($stage-ink, .2);
border-radius: $r-control;
padding: $sp-3 $sp-3;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1.4;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
&:focus { outline: none; border-color: $color; }
}
.cc__count {
display: block;
margin-top: $sp-2;
text-align: right;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: rgba($stage-ink, .72); // 흐린 글자 알파 하한 .72 — 흰 면 위 7.27:1
}
.cc__num { color: $color; display: inline-block; transform: scale(1); }
.cc__count.is-bump .cc__num { animation: bump $duration $easing; }
@keyframes bump {
// 스프링 오버슛 — 튕긴 뒤 반대 방향 반동 2번을 거쳐 멎는다 (감쇠 진폭 1.35 → .853 → 1.062 → 1)
0% { transform: scale(1); }
35% { transform: scale(#{$b1}); }
60% { transform: scale(1); }
78% { transform: scale(#{$b2}); }
90% { transform: scale(#{$b3}); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.cc__count.is-bump .cc__num { animation: 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);
}
.cc {
width: min(240px, 82vw);
}
.cc__label {
display: block;
margin-bottom: 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.75);
}
.cc__input {
box-sizing: border-box;
width: 100%;
resize: none;
border: 2px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
padding: 12px 12px;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 14px);
line-height: 1.4;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.cc__input:focus {
outline: none;
border-color: #2f6df6;
}
.cc__count {
display: block;
margin-top: 8px;
text-align: right;
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.cc__num {
color: #2f6df6;
display: inline-block;
transform: scale(1);
}
.cc__count.is-bump .cc__num {
animation: bump 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes bump {
0% {
transform: scale(1);
}
35% {
transform: scale(1.35);
}
60% {
transform: scale(1);
}
78% {
transform: scale(0.853);
}
90% {
transform: scale(1.06174);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.cc__count.is-bump .cc__num {
animation: none;
}
}// 실사용: 입력할 때마다 남은 글자 수를 세고 튕긴다. 데모(.is-demo)는 값을 흉내내 반복 (9줄)
var ta = document.querySelector('.is-demo .cc__input');
var num = document.querySelector('.is-demo .cc__num');
var texts = ['', 'こんにちは', 'こんにちは、エンジニアです'];
var i = 0;
var t = function () {
ta.value = texts[i % texts.length]; i += 1;
num.textContent = String(ta.value.length);
num.parentElement.classList.remove('is-bump'); void num.offsetWidth; num.parentElement.classList.add('is-bump');
};
t(); setInterval(t, 450);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./CharCount.module.scss";
export type CharCountProps = {
id?: string;
label?: string;
placeholder?: string;
maxLength?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CharCount({
id = "cc-bio",
label = "Bio",
placeholder = "Write a short bio (40 characters max)",
maxLength = 40,
duration = 140,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: CharCountProps) {
const [value, setValue] = useState("");
const [bump, setBump] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
setBump(false);
requestAnimationFrame(() => setBump(true));
};
return (
<div className={styles.cc} style={vars}>
<label htmlFor={id} className={styles.label}>{label}</label>
<textarea
id={id}
className={styles.input}
maxLength={maxLength}
placeholder={placeholder}
rows={2}
value={value}
onChange={onChange}
/>
<span className={styles.count} aria-live="polite">
<b className={bump ? `${styles.num} ${styles.bump}` : styles.num} onAnimationEnd={() => setBump(false)}>
{value.length}
</b>
/{maxLength}
</span>
</div>
);
}08 · 文字数カウント — 計測 累積面積 1.504% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
スクリーンリーダーにも伝える
.cc__num { display: inline-block; transform: scale(1); }
.cc__count.is-bump .cc__num { animation: bump $duration $easing; }
@keyframes bump {
0% { transform: scale(1); }
50% { transform: scale(1.35); }
100% { transform: scale(1); }
}
09パスワード目アイコン切替
typeが実際に入れ替わる
目アイコンのボタンを押すとinputのtypeが実際にpasswordとtextの間を行き来します。重ねた目のSVGと斜線入りの目のSVGはis-shownクラスに応じてopacityとscaleでクロスフェードします。
切り替えは1.12まで伸びて戻るスプリングオーバーシュートで行われ、ボタン中心からradialグローが一度だけ広がって目が入れ替わったことを確かめます。

<main class="stage">
<div class="pw is-demo">
<label class="pw__label" for="pw-input">パスワード</label>
<div class="pw__frame">
<input id="pw-input" class="pw__input" type="password" placeholder="パスワードを入力" autocomplete="current-password">
<button type="button" class="pw__eye" aria-label="パスワードを表示" aria-pressed="false">
<svg class="pw__icon pw__icon--eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="2.6"/></svg>
<svg class="pw__icon pw__icon--off" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-6.5 10-6.5c1.7 0 3.2.4 4.5 1.1M22 12s-1.4 2.5-4 4.4M6.5 6.5 3 3m1.5 1.5L21 21"/></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 아이콘이 바뀌는 시간
$easing: cubic-bezier(.3, 1.6, .4, 1); // 스프링 오버슛 — 아이콘 스케일이 1 을 지나 1.12 까지 뻗었다가 돌아온다
$color: $subject-cream; // 아이콘·포커스 색
@include stage($stage-ink);
.pw { width: min(240px, 82vw); }
.pw__label { display: block; margin-bottom: $sp-2; font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; color: rgba($subject-cream, .75); }
.pw__frame { position: relative; }
.pw__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .35);
border-radius: $r-control;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: transparent;
letter-spacing: .06em;
&:focus { outline: none; border-color: $color; }
}
.pw__eye {
position: absolute;
right: $sp-1; top: 50%;
transform: translateY(-50%);
width: 30px; height: 30px;
border: 0;
border-radius: 50%;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
}
// 토글 누르는 순간 버튼 중심에서 번지는 radial 글로우 확산 한 겹
.pw__eye::after {
content: "";
position: absolute;
inset: -4px;
border-radius: 50%;
background: radial-gradient(circle, rgba($color, .4) 0%, rgba($color, 0) 70%);
opacity: 0;
pointer-events: none;
}
.pw__eye.is-shown::after { animation: pw-bloom $dur-quick $ease-out; }
@keyframes pw-bloom {
0% { opacity: 0; transform: scale(.5); }
40% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.3); }
}
.pw__icon {
position: absolute;
width: 19px; height: 19px;
fill: none;
stroke: $color;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transform: scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
.pw__icon--off { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--eye { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--off { transform: scale(1); opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.pw__icon { transition: none; }
.pw__eye.is-shown::after { animation: 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);
}
.pw {
width: min(240px, 82vw);
}
.pw__label {
display: block;
margin-bottom: 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.75);
}
.pw__frame {
position: relative;
}
.pw__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.35);
border-radius: 12px;
padding: 12px 40px 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: transparent;
letter-spacing: 0.06em;
}
.pw__input:focus {
outline: none;
border-color: #fff7e6;
}
.pw__eye {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
}
.pw__eye::after {
content: "";
position: absolute;
inset: -4px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 247, 230, 0.4) 0%, rgba(255, 247, 230, 0) 70%);
opacity: 0;
pointer-events: none;
}
.pw__eye.is-shown::after {
animation: pw-bloom 220ms ease-out;
}
@keyframes pw-bloom {
0% {
opacity: 0;
transform: scale(0.5);
}
40% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.3);
}
}
.pw__icon {
position: absolute;
width: 19px;
height: 19px;
fill: none;
stroke: #fff7e6;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transform: scale(1);
opacity: 1;
transition: transform 220ms cubic-bezier(0.3, 1.6, 0.4, 1), opacity 220ms cubic-bezier(0.3, 1.6, 0.4, 1);
}
.pw__icon--off {
transform: scale(0.5);
opacity: 0;
}
.pw__eye.is-shown .pw__icon--eye {
transform: scale(0.5);
opacity: 0;
}
.pw__eye.is-shown .pw__icon--off {
transform: scale(1);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.pw__icon {
transition: none;
}
.pw__eye.is-shown::after {
animation: none;
}
}// 실사용: 클릭으로 input type 을 바꾼다. 데모(.is-demo)는 주기적으로 토글 (7줄)
var btn = document.querySelector('.is-demo .pw__eye');
var input = document.querySelector('.is-demo .pw__input');
var t = function () {
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
btn.setAttribute('aria-pressed', String(show));
btn.classList.toggle('is-shown', show);
};
t(); setInterval(t, 450);import { useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import styles from "./PasswordEyeToggle.module.scss";
export type PasswordEyeToggleProps = {
id?: string;
label?: string;
placeholder?: string;
showLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PasswordEyeToggle({
id = "pw-input",
label = "Password",
placeholder = "Enter your password",
showLabel = "Show password",
duration = 220,
easing = "cubic-bezier(.3,1.6,.4,1)",
color = "#fff7e6",
}: PasswordEyeToggleProps) {
const [value, setValue] = useState("");
const [shown, setShown] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.pw} style={vars}>
<label htmlFor={id} className={styles.label}>{label}</label>
<div className={styles.frame}>
<input
id={id}
className={styles.input}
type={shown ? "text" : "password"}
placeholder={placeholder}
autoComplete="current-password"
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
/>
<button
type="button"
className={shown ? `${styles.eye} ${styles.shown}` : styles.eye}
aria-label={showLabel}
aria-pressed={shown}
onClick={() => setShown((v) => !v)}
>
<svg className={`${styles.icon} ${styles.eyeIcon}`} viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z" /><circle cx="12" cy="12" r="2.6" /></svg>
<svg className={`${styles.icon} ${styles.offIcon}`} viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-6.5 10-6.5c1.7 0 3.2.4 4.5 1.1M22 12s-1.4 2.5-4 4.4M6.5 6.5 3 3m1.5 1.5L21 21" /></svg>
</button>
</div>
</div>
);
}09 · パスワード目アイコン切替 — 計測 累積面積 0.572% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
2つの目SVGのクロスフェード
.pw__icon {
transform: scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
.pw__icon--off { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--eye { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--off { transform: scale(1); opacity: 1; }
どこで崩れるか — 落とし穴
貼り付けた後に最初に触るのはたいていフォーカス色で、入力欄に載せる明るい配色はビタミンカラー デザイン LPキットのシトラスカラーのセクション9選から選んでそのまま入れられます。色より先に引っかかるのはセレクタの方です。
:has()未対応でも残るもの
03のグローと06のエラーメッセージは、形としては兄弟でも子でもない要素まで親の:has()一つで反応させています。Chrome・Edge 105以降、Safari 15.4以降、Firefox 121以降より前のブラウザではこのセレクタ自体が無視され、グローもエラーメッセージも表示されませんが、○×アイコンは~による通常の兄弟セレクタで動くため:has()の対応状況に関係なく切り替わり続け、input自体は値を受け取り続けて:invalidの判定も生きているため機能が完全に止まるわけではありません。04の検索ボックス拡張は数少ないwidthそのものを動かす項目で、隣に別の要素を置いていると広がった瞬間にその要素が実際に押しのけられるレイアウト変化が起きます。上限を240pxに決めたのもこのためです。06でrequiredを外すと空欄でも:invalidと判定され、ページを開いた瞬間からまだ何も打っていない枠が赤く見えてしまうので、:not(:placeholder-shown)を必ず一緒に付ける必要があります。このzipのロックを外す文字列は4qrx8ve2で、9つのファイルが入ったindex.htmlには自動再生の仕込みではなく人が押す・打つコードだけが残っています。
アクセシビリティ
ラベルと状態を読み上げさせる
01はlabel for="fl-nick"とinput id="fl-nick" autocomplete="nickname"を結び付けていて、ラベルが浮いて小さくなってもスクリーンリーダーは同じ関連付けをたどります。04はinputにaria-label="検索"を添え、label自体がinputを包むのでクリック領域がラベルごと広がります。05は見た目の案内文を担うspanにaria-hidden="true"を付けて隠し、スクリーンリーダーにはinput自身のplaceholderとaria-labelだけを読ませます。06はrequiredを付けたうえでpにrole="alert"を持たせ、エラー文が出た瞬間をすぐに読み上げさせます。07はaria-haspopup="listbox"とaria-expandedをクリックごとに書き換え、ulにはrole="listbox"、各liにはrole="option"を付けてネイティブのselectに近い読み上げを保ちます。08はaria-live="polite"で残り文字数の変化を急がずに伝え、09はaria-pressedを切り替えながらautocomplete="current-password"を保ちます。どの項目も:focusでアウトラインを消しているので、次のように:focus-visibleで復元しておくとキーボード操作の手がかりを消さずに済みます。
<label for="fl-nick">ニックネーム</label>
<input id="fl-nick" class="fl__input" autocomplete="nickname">
.fl__input:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
動きを減らしたときの挙動
prefers-reduced-motion: reduceでは01・02・04・05・07・08・09がtransition: noneで最終状態へ即座に切り替わり、03はanimation: noneのままopacity: .7・scale(1)で固定されます。06だけは:invalidの判定自体は生きたまま揺れるanimationだけを止めます。
次に読む
「css フォーカス 枠」や検索ボックス以外のCSSアニメーションはCSSアニメーションのカテゴリーにまとめてあり、サイトの成り立ちは紹介ページで読めます。
FAQ
9つとも本当にCSSだけで動きますか?
01〜06はCSSの状態セレクタだけで動き、JavaScriptは使っていません。07カスタムセレクトが開く・08文字数カウント・09パスワード目アイコン切替の3つだけ5〜9行ほどのバニラJSを持ち、リストの開閉・文字数の集計・type属性の書き換えというCSS単独では不可能な仕事を担います。入力欄の外で同じようにホバーと選択状態だけで組んだ例としては、料金表 CSSアニメーション9選があります。
「css 入力フォーム デザイン」でlabelを付けずplaceholderだけで済ませてもいいですか?
済ませない方がいいです。placeholderは値を入力した瞬間に消え、スクリーンリーダーの扱いもlabelほど一定しません。01はlabel for="fl-nick"をinputと結び付け、05はplaceholderを空欄にしたまま見た目の案内文だけを別のspanに任せています。
React 版でも :focus-within や :has() はそのまま動きますか?
動きます。CSS Modulesにコンパイルされても:focus``:focus-within``:placeholder-shown``:valid``:invalid``:has()のようなブラウザの状態セレクタはそのまま生きています。useStateが管理するのは07の開閉・08の入力値・09の表示切替のようにCSSだけでは分からない状態に限られ、フォーカスと検証の判定自体はブラウザに任されたままです。









