03名前パターン・アバター
同じ名前から必ず同じ5×5の模様が出るようハッシュのビットをマスに配り、左半分を右へ鏡写しにして対称に立てます。
使いどころイニシャルが重なって見分けられないチーム・リポジトリ一覧
grid-template-columnsanimation-delaytransform-origin
<main class="stage">
<section class="id is-demo" aria-label="名前パターン・アバター">
<div class="id__field">
<label class="id__label" for="id-name">名前</label>
<input class="id__input" id="id-name" type="text" value="管理者" autocomplete="off" spellcheck="false" placeholder="名前を入力">
</div>
<!-- 25칸은 JS 가 만든다: 해시 비트를 15칸(열 0·1·2)에 배분하고 열 0↔4 · 1↔3 을 거울처럼 맞춘다 -->
<div class="id__grid" data-tone="1" aria-hidden="true"></div>
<ul class="id__list">
<li class="id__item"><div class="id__mini" data-tone="5" aria-hidden="true"></div><span class="id__name">デザイナー</span></li>
<li class="id__item"><div class="id__mini" data-tone="4" aria-hidden="true"></div><span class="id__name">プランナー</span></li>
<li class="id__item"><div class="id__mini" data-tone="0" aria-hidden="true"></div><span class="id__name">運営者</span></li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // 칸 숨쉬기 한 바퀴 (2s 의 약수)
$easing: $ease-out;
$color: $stage-ink; // 오렌지 무대 글자 5.50:1 — 크림은 3.11:1 로 금지
@include stage($stage-orange);
.id {
display: grid;
justify-items: center;
gap: $sp-3;
width: min(360px, 92vw);
}
.id__field {
display: flex;
align-items: center;
gap: $sp-2;
}
.id__label {
color: $color;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.id__input {
width: 150px;
padding: $sp-2 $sp-3;
border: 2px solid $color;
border-radius: $r-sm;
background: transparent;
color: $color;
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
}
.id__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 큰 무늬 — 5×5. 칸 폭이 정확히 균등해진다
.id__grid {
display: grid;
grid-template-columns: repeat(5, 1fr); // 칩
width: 64px;
height: 64px;
}
// 칸은 켜진 것만 색이 들어간다. 꺼진 칸은 무대 위 희미한 격자로 자리만 지킨다
.id__cell {
transform-origin: center; // 칩 — 제자리에서 숨쉰다
border-radius: $r-xs;
background: rgba(23, 20, 26, .12);
}
.id__grid[data-tone="0"] .id__cell.is-on,
.id__mini[data-tone="0"] .id__cell.is-on { background: $av-blue; }
.id__grid[data-tone="1"] .id__cell.is-on,
.id__mini[data-tone="1"] .id__cell.is-on { background: $av-violet; }
.id__grid[data-tone="2"] .id__cell.is-on,
.id__mini[data-tone="2"] .id__cell.is-on { background: $av-teal; }
.id__grid[data-tone="3"] .id__cell.is-on,
.id__mini[data-tone="3"] .id__cell.is-on { background: $av-plum; }
.id__grid[data-tone="4"] .id__cell.is-on,
.id__mini[data-tone="4"] .id__cell.is-on { background: $av-brick; }
.id__grid[data-tone="5"] .id__cell.is-on,
.id__mini[data-tone="5"] .id__cell.is-on { background: $av-slate; }
.id__list {
display: flex;
gap: $sp-4;
margin: 0;
padding: 0;
list-style: none;
}
.id__item { display: grid; justify-items: center; gap: $sp-1; }
.id__mini {
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 40px;
height: 40px;
}
.id__name {
color: $color;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 칸마다 시차를 두고 조용히 숨쉰다. 켜진 칸은 사라지지 않는다 — opacity 로 끄지 않는다(유령 프레임 금지).
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 40ms);
animation-fill-mode: backwards; // 지연 구간에도 0% 자세(scale .72)가 찍힌다
}
@keyframes idBreathe {
0% { transform: scale(.72); }
50% { transform: scale(1); }
100% { transform: scale(.72); }
}
// 미리보기 루프 동안에는 자동완성 흔적 글자가 겹쳐 보이지 않게 한다
.id.is-demo .id__input::placeholder { color: transparent; }
// 좁은 화면 — 표시와 여백만 줄인다. 값을 올리지 않는다.
@media (max-width: 340px), (max-height: 220px) {
.id { gap: $sp-2; }
.id__input { width: 120px; font-size: 13px; }
.id__grid { width: 56px; height: 56px; }
.id__mini { width: 32px; height: 32px; }
.id__list { gap: $sp-3; }
.id__name { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .id.is-demo .id__cell (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 무늬는 그대로 남긴다
.id__cell { animation: none !important; }
}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);
}
.id {
display: grid;
justify-items: center;
gap: 12px;
width: min(360px, 92vw);
}
.id__field {
display: flex;
align-items: center;
gap: 8px;
}
.id__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.id__input {
width: 150px;
padding: 8px 12px;
border: 2px solid #17141a;
border-radius: 8px;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
}
.id__input:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.id__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 64px;
height: 64px;
}
.id__cell {
transform-origin: center;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.id__grid[data-tone="0"] .id__cell.is-on,
.id__mini[data-tone="0"] .id__cell.is-on {
background: #7c90be;
}
.id__grid[data-tone="1"] .id__cell.is-on,
.id__mini[data-tone="1"] .id__cell.is-on {
background: #9791c9;
}
.id__grid[data-tone="2"] .id__cell.is-on,
.id__mini[data-tone="2"] .id__cell.is-on {
background: #479082;
}
.id__grid[data-tone="3"] .id__cell.is-on,
.id__mini[data-tone="3"] .id__cell.is-on {
background: #ab5488;
}
.id__grid[data-tone="4"] .id__cell.is-on,
.id__mini[data-tone="4"] .id__cell.is-on {
background: #c2361a;
}
.id__grid[data-tone="5"] .id__cell.is-on,
.id__mini[data-tone="5"] .id__cell.is-on {
background: #3b4a63;
}
.id__list {
display: flex;
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.id__item {
display: grid;
justify-items: center;
gap: 4px;
}
.id__mini {
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 40px;
height: 40px;
}
.id__name {
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: 1s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 40ms);
animation-fill-mode: backwards;
}
@keyframes idBreathe {
0% {
transform: scale(0.72);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.72);
}
}
.id.is-demo .id__input::placeholder {
color: transparent;
}
@media (max-width: 340px), (max-height: 220px) {
.id {
gap: 8px;
}
.id__input {
width: 120px;
font-size: 13px;
}
.id__grid {
width: 56px;
height: 56px;
}
.id__mini {
width: 32px;
height: 32px;
}
.id__list {
gap: 12px;
}
.id__name {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.id__cell {
animation: none !important;
}
}(function () {
var root = document.querySelector('.id');
var input = root.querySelector('.id__input');
var grid = root.querySelector('.id__grid');
// FNV-1a 32비트 — 같은 이름은 늘 같은 정수를 돌려준다
function hash32(text) {
var h = 2166136261;
for (var i = 0; i < text.length; i++) {
h = (h ^ text.charCodeAt(i)) >>> 0;
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
// 비트 15개를 열 0·1·2 에 놓고 열 0↔4 · 1↔3 을 거울처럼 맞춰 25칸을 채운다
function cellsOf(text) {
var h = hash32(text);
var cells = [];
for (var row = 0; row < 5; row++) {
var left = [(h >>> (row * 3)) & 1, (h >>> (row * 3 + 1)) & 1, (h >>> (row * 3 + 2)) & 1];
cells.push(left[0], left[1], left[2], left[1], left[0]);
}
return cells;
}
function buildCells(el) {
for (var i = 0; i < 25; i++) {
var cell = document.createElement('span');
cell.className = 'id__cell';
el.appendChild(cell);
}
}
// 켜진 칸만 클래스가 붙고, 색도 해시로 고른 data-tone 번호가 정한다(scss 가 칠한다)
function render(el, name) {
var cells = cellsOf(name);
el.dataset.tone = String(hash32(name) % 6);
var kids = el.children;
for (var i = 0; i < 25; i++) {
kids[i].classList.toggle('is-on', cells[i] === 1);
kids[i].style.setProperty('--i', String(i));
}
}
buildCells(grid);
render(grid, input.value);
// 작은 무늬 셋 — 名前 표시에서 각각 계산한다
var items = root.querySelectorAll('.id__item');
Array.prototype.forEach.call(items, function (item) {
var mini = item.querySelector('.id__mini');
buildCells(mini);
render(mini, item.querySelector('.id__name').textContent);
});
// 입력이 바뀌면 그 자리에서 다시 그린다
input.addEventListener('input', function () { render(grid, input.value); });
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarIdenticonPattern.module.scss";
export type AvatarIdenticonPatternProps = {
label?: string;
nameLabel?: string;
placeholder?: string;
name?: string;
smallNames?: string[];
/** 칸 숨쉬기 한 바퀴(ms) — 2초의 약수 */
duration?: number;
easing?: string;
/** 오렌지 무대 글자색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CellVars = CSSProperties & { "--i"?: string };
// ambient 타입에 React 이벤트 제네릭이 없어 로컬 타입을 쓴다
type InputEvt = { target: { value: string } };
// FNV-1a 32비트 — 같은 이름은 늘 같은 정수를 돌려준다
function hash32(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h = (h ^ text.charCodeAt(i)) >>> 0;
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
// 비트 15개를 열 0·1·2 에 놓고 열 0↔4 · 1↔3 을 거울처럼 맞춰 25칸을 채운다
function cellsOf(text: string): number[] {
const h = hash32(text);
const cells: number[] = [];
for (let row = 0; row < 5; row++) {
const left = [(h >>> (row * 3)) & 1, (h >>> (row * 3 + 1)) & 1, (h >>> (row * 3 + 2)) & 1];
cells.push(left[0], left[1], left[2], left[1], left[0]);
}
return cells;
}
function IdenticonGrid({ className, name }: { className: string; name: string }): ReactNode {
// 켜진 칸만 색이 들어간다 — 색도 해시로 고른 data-tone 번호가 정한다(scss 가 칠한다)
return (
<div className={className} data-tone={String(hash32(name) % 6)} aria-hidden="true">
{cellsOf(name).map((on, i) => (
<span
key={i}
className={on ? `${styles.cell} ${styles.cellOn}` : styles.cell}
style={{ "--i": String(i) } as CellVars}
/>
))}
</div>
);
}
export default function AvatarIdenticonPattern({
label = "이름 무늬 아바타",
nameLabel = "이름",
placeholder = "이름을 입력하세요",
name = "관리자",
smallNames = ["디자이너", "기획자", "운영자"],
duration = 1000,
easing = "ease-out",
color = "#17141a",
}: AvatarIdenticonPatternProps): ReactNode {
const [value, setValue] = useState<string>(name);
const [loop, setLoop] = useState<boolean>(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.id} ${styles.isDemo}` : styles.id}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.field}>
<label className={styles.label} htmlFor="id-name">{nameLabel}</label>
<input
className={styles.input}
id="id-name"
type="text"
value={value}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
onChange={(e: InputEvt) => setValue(e.target.value)}
/>
</div>
{/* 입력이 바뀌면 그 자리에서 다시 그린다 */}
<IdenticonGrid className={styles.grid} name={value} />
<ul className={styles.list}>
{smallNames.map((smallName) => (
<li key={smallName} className={styles.item}>
<IdenticonGrid className={styles.mini} name={smallName} />
<span className={styles.smallName}>{smallName}</span>
</li>
))}
</ul>
</section>
);
}03 名前パターン・アバター — 計測 累積面積 3.54% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · プロフィール UI デザイン 9種 — 画像ファイルなし


