05反復・順次値減点 — 同じ文字が連続したり数字の値が順番に続く区間の分だけ強度の値が減る方式
同じ文字が連続したり数字の値が順番に続く区間が見つかると、その長さの値の分だけ強度の値が減ります。
使いどころ1234・aaaa のような値をはじくアカウントセキュリティ設定、パターン化した値を検知する登録フォーム
flagPositions(value)PER_CHAR_PENALTY = 8.rs__chip--on

<main class="stage">
<div class="rs is-demo" id="rs">
<label class="rs__field">
<input class="rs__input" id="rsInput" type="text" autocomplete="off" spellcheck="false" value="">
</label>
<div class="rs__chips" id="rsChips"></div>
<div class="rs__track"><div class="rs__fill" id="rsFill"></div></div>
<div class="rs__row">
<span class="rs__score">値 <b id="rsScore">0</b>/100</span>
<span class="rs__warn" id="rsWarn">反復・連続 <b id="rsPenalty">-0</b></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 같은 글자 3연속(반복) 또는 문자코드 증감 1씩 3연속(연속)을 찾아
// 그 글자 수만큼 고정 감점을 기본 점수(길이×8)에서 뺀다.
@include stage($stage-ink);
.rs {
width: 100%;
max-width: 320px;
display: grid;
gap: $sp-3;
}
.rs__field { display: block; }
.rs__input {
width: 100%;
box-sizing: border-box;
padding: $sp-3 $sp-4;
border-radius: $r-control;
border: 0;
background: rgba($stage-paper, .12);
color: $subject-cream;
font: 500 16px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: .04em;
-webkit-text-security: disc;
&:focus { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.rs__chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
min-height: $sp-4;
}
.rs__chip {
width: $sp-3;
height: $sp-3;
border-radius: $r-xs;
background: rgba($stage-paper, .28);
transform: scale(1);
transition: background-color $dur-base $ease-out, transform $dur-base $ease-out;
}
.rs__chip--on {
background: $error;
transform: scale(1.15);
}
.rs__track {
position: relative;
height: $sp-3;
border-radius: $r-sm;
background: rgba($stage-paper, .14);
overflow: hidden;
}
.rs__fill {
position: absolute;
inset: 0;
border-radius: $r-sm;
background: $subject-blue-light;
transform: scaleX(0);
transform-origin: left center;
transition: transform $dur-base $ease-out;
}
.rs--hit .rs__fill { background: $tick-down; }
.rs__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
font: 600 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-paper;
}
.rs__score b { font-variant-numeric: tabular-nums; }
.rs__warn {
color: $tick-down;
opacity: 0;
transform: scale(.7);
transform-origin: right center;
transition: opacity $dur-base $ease-out, transform $dur-base $ease-out;
}
@media (max-width: 340px) {
.rs { max-width: 260px; }
.rs__input { padding: $sp-2 $sp-3; font-size: 14px; }
.rs__row { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.rs__fill, .rs__warn, .rs__chip { 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);
}
.rs {
width: 100%;
max-width: 320px;
display: grid;
gap: 12px;
}
.rs__field {
display: block;
}
.rs__input {
width: 100%;
box-sizing: border-box;
padding: 12px 16px;
border-radius: 12px;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff7e6;
font: 500 16px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: 0.04em;
-webkit-text-security: disc;
}
.rs__input:focus {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.rs__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 16px;
}
.rs__chip {
width: 12px;
height: 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.28);
transform: scale(1);
transition: background-color 300ms ease-out, transform 300ms ease-out;
}
.rs__chip--on {
background: #c2361a;
transform: scale(1.15);
}
.rs__track {
position: relative;
height: 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.rs__fill {
position: absolute;
inset: 0;
border-radius: 8px;
background: #5b8cf8;
transform: scaleX(0);
transform-origin: left center;
transition: transform 300ms ease-out;
}
.rs--hit .rs__fill {
background: #ff7a5e;
}
.rs__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font: 600 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #ffffff;
}
.rs__score b {
font-variant-numeric: tabular-nums;
}
.rs__warn {
color: #ff7a5e;
opacity: 0;
transform: scale(0.7);
transform-origin: right center;
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
@media (max-width: 340px) {
.rs {
max-width: 260px;
}
.rs__input {
padding: 8px 12px;
font-size: 14px;
}
.rs__row {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs__fill, .rs__warn, .rs__chip {
transition: none;
}
}(function () {
var root = document.getElementById('rs');
var input = document.getElementById('rsInput');
var chipsBox = document.getElementById('rsChips');
var fill = document.getElementById('rsFill');
var scoreEl = document.getElementById('rsScore');
var warn = document.getElementById('rsWarn');
var penaltyEl = document.getElementById('rsPenalty');
var PER_CHAR_PENALTY = 8;
// 계산 겹: 같은 글자 3연속(반복) 또는 문자코드가 1씩 오르내리는 3연속(연속)을
// 찾아 그 위치들을 표시하고, 표시된 文字数 × 감점값을 기본 점수에서 뺀다.
function flagPositions(value) {
var n = value.length;
var flag = new Array(n).fill(false);
var i = 0;
while (i < n) {
var j = i;
while (j + 1 < n && value.charCodeAt(j + 1) === value.charCodeAt(j)) j++;
if (j - i + 1 >= 3) for (var k = i; k <= j; k++) flag[k] = true;
i = j + 1;
}
i = 0;
while (i < n - 1) {
var dir = value.charCodeAt(i + 1) - value.charCodeAt(i);
if (dir === 1 || dir === -1) {
var j2 = i + 1;
while (j2 + 1 < n && value.charCodeAt(j2 + 1) - value.charCodeAt(j2) === dir) j2++;
if (j2 - i + 1 >= 3) for (var k2 = i; k2 <= j2; k2++) flag[k2] = true;
i = j2;
} else {
i++;
}
}
return flag;
}
function scoreOf(value) {
var base = Math.min(100, value.length * 8);
var flag = flagPositions(value);
var hitCount = flag.filter(Boolean).length;
var penalty = hitCount * PER_CHAR_PENALTY;
var score = Math.max(0, base - penalty);
return { score: score, flag: flag, penalty: penalty };
}
function paint(value) {
var r = scoreOf(value);
fill.style.transform = 'scaleX(' + (r.score / 100) + ')';
root.classList.toggle('rs--hit', r.penalty > 0);
scoreEl.textContent = String(r.score);
penaltyEl.textContent = '-' + r.penalty;
warn.style.opacity = r.penalty > 0 ? '1' : '0';
warn.style.transform = r.penalty > 0 ? 'scale(1)' : 'scale(.7)';
chipsBox.innerHTML = '';
for (var i = 0; i < value.length; i++) {
var chip = document.createElement('span');
chip.className = 'rs__chip' + (r.flag[i] ? ' rs__chip--on' : '');
chipsBox.appendChild(chip);
}
}
input.addEventListener('input', function () {
root.classList.remove('is-demo');
stopAutoplay();
paint(input.value);
});
paint(input.value);
// 자동 루프: 시드 고정 문자열 배열 — 反復・連続 구간이 있는 값과 없는 값을 번갈아 입력한다.
var SEQ = ['', 'q7Kd', 'aaaKd9', '12345Kd', 'abcdKf3', 'q7mR2z', 'aaa123', ''];
var si = 0;
var autoTimer = null;
function autoTick() { input.value = SEQ[si]; paint(SEQ[si]); si = (si + 1) % SEQ.length; }
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(autoTick, 380); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./RepeatSequencePenalty.module.scss";
type Box<T> = { current: T };
const PER_CHAR_PENALTY = 8;
export default function RepeatSequencePenalty() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const input = root.querySelector(`.${styles.input}`) as HTMLInputElement | null;
const chipsBox = root.querySelector(`.${styles.chips}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const scoreEl = root.querySelector(`.${styles.scoreNum}`) as HTMLElement | null;
const warn = root.querySelector(`.${styles.warn}`) as HTMLElement | null;
const penaltyEl = root.querySelector(`.${styles.penaltyNum}`) as HTMLElement | null;
if (!input || !chipsBox || !fill || !scoreEl || !warn || !penaltyEl) return;
// 계산 겹: 같은 글자 3연속(반복) 또는 문자코드 증감 1씩 3연속(연속)을 찾아
// 그 위치들을 표시하고, 표시된 글자 수 × 감점값을 기본 점수에서 뺀다.
function flagPositions(value: string) {
const n = value.length;
const flag = new Array(n).fill(false);
let i = 0;
while (i < n) {
let j = i;
while (j + 1 < n && value.charCodeAt(j + 1) === value.charCodeAt(j)) j++;
if (j - i + 1 >= 3) for (let k = i; k <= j; k++) flag[k] = true;
i = j + 1;
}
i = 0;
while (i < n - 1) {
const dir = value.charCodeAt(i + 1) - value.charCodeAt(i);
if (dir === 1 || dir === -1) {
let j2 = i + 1;
while (j2 + 1 < n && value.charCodeAt(j2 + 1) - value.charCodeAt(j2) === dir) j2++;
if (j2 - i + 1 >= 3) for (let k2 = i; k2 <= j2; k2++) flag[k2] = true;
i = j2;
} else {
i++;
}
}
return flag as boolean[];
}
function scoreOf(value: string) {
const base = Math.min(100, value.length * 8);
const flag = flagPositions(value);
const hitCount = flag.filter(Boolean).length;
const penalty = hitCount * PER_CHAR_PENALTY;
const score = Math.max(0, base - penalty);
return { score, flag, penalty };
}
function paint(value: string) {
const r = scoreOf(value);
fill!.style.transform = "scaleX(" + r.score / 100 + ")";
root!.classList.toggle(styles["rs--hit"], r.penalty > 0);
scoreEl!.textContent = String(r.score);
penaltyEl!.textContent = "-" + r.penalty;
warn!.style.opacity = r.penalty > 0 ? "1" : "0";
warn!.style.transform = r.penalty > 0 ? "scale(1)" : "scale(.7)";
chipsBox!.innerHTML = "";
for (let i = 0; i < value.length; i++) {
const chip = document.createElement("span");
chip.className = styles.chip + (r.flag[i] ? " " + styles["chip--on"] : "");
chipsBox!.appendChild(chip);
}
}
function onInput() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
paint(input!.value);
}
input.addEventListener("input", onInput);
paint(input.value);
// 자동 루프: 시드 고정 문자열 배열 — 반복·연속 구간이 있는 값과 없는 값을 번갈아 입력한다.
const SEQ = ["", "q7Kd", "aaaKd9", "12345Kd", "abcdKf3", "q7mR2z", "aaa123", ""];
let si = 0;
let autoTimer: number | null = null;
function autoTick() { input!.value = SEQ[si]; paint(SEQ[si]); si = (si + 1) % SEQ.length; }
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 380); autoTick(); }
return () => {
stopAutoplay();
input.removeEventListener("input", onInput);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.rs} ${styles["is-demo"]}`}>
<label className={styles.field}>
<input className={styles.input} type="text" autoComplete="off" spellCheck={false} defaultValue="" />
</label>
<div className={styles.chips} />
<div className={styles.track}><div className={styles.fill} /></div>
<div className={styles.row}>
<span className={styles.score}>값 <b className={styles.scoreNum}>0</b>/100</span>
<span className={styles.warn}>반복·연속 <b className={styles.penaltyNum}>-0</b></span>
</div>
</div>
);
}05 反復・順次値減点 — 同じ文字が連続したり数字の値が順番に続く区間の分だけ強度の値が減る方式 — 計測 累積面積 3.086% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワード強度チェック UI 9選 — 強度の値が計算される仕組み
このデモが属する記事
パスワード強度チェック UI 9選 — 強度の値が計算される仕組み9つのうち1つは文字種の値がいくつ混ざっているかで強度の値が決まり、1つはよくある値のリストと同じ状態になると強度の値が減り、もう1つは確認入力の値と元の値が食い違い始める位置の値をリアルタイムで見つけます。パスワード強度チェック、9つの作 この記事のデモをすべて見る


