05ブラウザの自動入力を検知して広がるハイライトグロー
欄が自動入力された状態を検知すると、縁の周りに柔らかいグローがパルスしながら広がる。
使いどころ保存済みパスワードの自動入力確認欄
AUTOFILL_GLOW_PULSE_MSfillAndGlowafg__input

<main class="stage">
<div class="afg is-demo">
<label class="afg__label" for="afg-input">保存済みパスワード</label>
<div class="afg__frame">
<input id="afg-input" class="afg__input" type="password" placeholder="自動入力待ち">
</div>
<button type="button" class="afg__btn" id="afgBtn">自動入力を再現</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.afg {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: $sp-3;
justify-items: center;
}
// 배경 톤: 이미 계산 중인 --glow 엔벌로프를 입력창 테두리 밖으로도 넓혀 카드 톤 전체가 같이 숨쉬게 한다.
.afg::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(110, 200, 255, calc(var(--glow, 0) * .22));
}
.afg__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba($subject-cream, .75);
}
.afg__frame { position: relative; width: 100%; }
.afg__input {
--glow: 0;
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .3);
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: rgba($subject-cream, .06);
letter-spacing: .1em;
box-shadow:
0 0 calc(var(--glow) * 26px) rgba(110, 200, 255, .9),
0 0 calc(var(--glow) * 8px) rgba(110, 200, 255, .7);
&:focus { outline: none; }
}
.afg__btn {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
cursor: pointer;
transition: transform $dur-quick $ease-pop;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.afg { width: min(260px, 88vw); }
}
@media (prefers-reduced-motion: reduce) {
.afg__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);
}
.afg {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: 12px;
justify-items: center;
}
.afg::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(110, 200, 255, calc(var(--glow, 0) * 0.22));
}
.afg__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba(255, 247, 230, 0.75);
}
.afg__frame {
position: relative;
width: 100%;
}
.afg__input {
--glow: 0;
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.3);
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: rgba(255, 247, 230, 0.06);
letter-spacing: 0.1em;
box-shadow: 0 0 calc(var(--glow) * 26px) rgba(110, 200, 255, 0.9), 0 0 calc(var(--glow) * 8px) rgba(110, 200, 255, 0.7);
}
.afg__input:focus {
outline: none;
}
.afg__btn {
border: 0;
border-radius: 999px;
padding: 8px 20px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.afg__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.afg {
width: min(260px, 88vw);
}
}
@media (prefers-reduced-motion: reduce) {
.afg__btn {
transition: none;
}
}(function () {
// 기법: 실제 autofill 감지 API 는 쓰지 않는다("흉내" 버튼이 유일한 트리거).
// 글로우는 트리거 이후 경과 t 에 대한 계산 엔벌로프(sin 상승 * exp 감쇠)를 매 프레임
// --glow CSS 변수로 갱신해 box-shadow 가 그 변수를 읽게 한다.
var AUTOFILL_GLOW_PULSE_MS = 900;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var root = document.querySelector('.afg');
var input = document.getElementById('afg-input');
var btn = document.getElementById('afgBtn');
var pwRand = seedRand(426051);
var pool = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#';
var seedValue = '';
for (var i = 0; i < 11; i += 1) seedValue += pool[Math.floor(pwRand() * pool.length)];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var glowRaf = 0;
var glowStartTs = null;
function glowTick(ts) {
if (glowStartTs === null) glowStartTs = ts;
var t = ts - glowStartTs;
if (t > AUTOFILL_GLOW_PULSE_MS) {
input.style.setProperty('--glow', '0');
root.style.setProperty('--glow', '0');
glowRaf = 0;
glowStartTs = null;
return;
}
var envelope = Math.max(0, Math.sin(Math.PI * t / AUTOFILL_GLOW_PULSE_MS)) * Math.exp(-2.2 * t / AUTOFILL_GLOW_PULSE_MS);
input.style.setProperty('--glow', String(envelope));
root.style.setProperty('--glow', String(envelope));
glowRaf = requestAnimationFrame(glowTick);
}
function resetField() {
input.value = '';
}
function fillAndGlow() {
input.value = seedValue;
if (reduce) {
input.style.setProperty('--glow', '0');
return;
}
if (glowRaf) cancelAnimationFrame(glowRaf);
glowStartTs = null;
glowRaf = requestAnimationFrame(glowTick);
}
btn.addEventListener('click', fillAndGlow);
if (reduce) root.classList.remove('is-demo');
if (root.classList.contains('is-demo')) {
setTimeout(function () {
fillAndGlow();
setInterval(function () {
resetField();
fillAndGlow();
}, 2000);
}, 200);
}
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealAutofillHighlightGlow.module.scss";
export default function PasswordRevealAutofillHighlightGlow() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const btn = btnRef.current;
if (!root || !input || !btn) return;
const AUTOFILL_GLOW_PULSE_MS = 900;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426051);
const pool = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#";
let seedValue = "";
for (let i = 0; i < 11; i += 1) seedValue += pool[Math.floor(pwRand() * pool.length)];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let glowRaf = 0;
let glowStartTs: number | null = null;
function glowTick(ts: number) {
if (glowStartTs === null) glowStartTs = ts;
const t = ts - glowStartTs;
if (t > AUTOFILL_GLOW_PULSE_MS) {
input!.style.setProperty("--glow", "0");
root!.style.setProperty("--glow", "0");
glowRaf = 0;
glowStartTs = null;
return;
}
const envelope = Math.max(0, Math.sin(Math.PI * t / AUTOFILL_GLOW_PULSE_MS)) * Math.exp(-2.2 * t / AUTOFILL_GLOW_PULSE_MS);
input!.style.setProperty("--glow", String(envelope));
root!.style.setProperty("--glow", String(envelope));
glowRaf = requestAnimationFrame(glowTick);
}
function resetField() {
input!.value = "";
}
function fillAndGlow() {
input!.value = seedValue;
if (reduce) {
input!.style.setProperty("--glow", "0");
return;
}
if (glowRaf) cancelAnimationFrame(glowRaf);
glowStartTs = null;
glowRaf = requestAnimationFrame(glowTick);
}
function onClick() { fillAndGlow(); }
btn.addEventListener("click", onClick);
if (reduce) root.classList.remove("is-demo");
let loopTimer: ReturnType<typeof setTimeout> | null = null;
let loopInterval: ReturnType<typeof setInterval> | null = null;
if (root.classList.contains("is-demo")) {
loopTimer = setTimeout(() => {
fillAndGlow();
loopInterval = setInterval(() => {
resetField();
fillAndGlow();
}, 2000);
}, 200);
}
return () => {
btn.removeEventListener("click", onClick);
if (glowRaf) cancelAnimationFrame(glowRaf);
if (loopTimer) clearTimeout(loopTimer);
if (loopInterval) clearInterval(loopInterval);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.afg} is-demo`} ref={rootRef}>
<label className={styles.afg__label} htmlFor="afg-input-react">저장된 비밀번호</label>
<div className={styles.afg__frame}>
<input
id="afg-input-react"
ref={inputRef}
className={styles.afg__input}
type="password"
placeholder="자동입력 대기 중"
/>
</div>
<button type="button" className={styles.afg__btn} ref={btnRef}>자동입력 흉내</button>
</div>
</main>
);
}05 ブラウザの自動入力を検知して広がるハイライトグロー — 計測 累積面積 23.938% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算
このデモが属する記事
パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算「パスワード 目のアイコン」の作り方は9通りある。目のアイコンの下には九つに枝分かれする路地がある。01番の路地は文字と点が入れ替わる交差点で、05番の路地は光が壁を伝って広がる通路で、06番の路地は文字が一歩ずつ歩いて入ってくる階段だ。 この記事のデモをすべて見る


