GODRICH

プロフィール UI デザイン 9種 — 画像ファイルなし

プロフィール UI デザインとは、利用者を写真一枚の枠で示す小さな部品の置き方のことです。ここに並べた九つは、名前だけから色と模様を決める円、写真が届かないときの代替の順序、重なり、状態ドット、メンションチップまで、画像ファイルなしで HTML と CSS、数行の JavaScript だけで組んであります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

九つの並び順は難易度ではなく、画面に現れる順番です。最初の三つはアバターが内側から満ちる場面で、名前から色を受け取る円(01)、写真が最後まで届かないときの3段の代替(02)、イニシャルが重なるときの模様(03)が並びます。次の三つはアバターがデザインシステムとチームの中に置かれる形で、形とサイズのセット(04)、参加者を畳んで見せる重なり(05)、オンライン状態のドット(06)が続きます。最後の三つは入力と確認に入ってくる場面で、メンションチップ(07)、ホバーのユーザーカード(08)、写真の位置合わせ(09)が収まります。九つともインク #17141a ・クリーム #fff7e6 と六つのトーン(青・バイオレット・ティール・プラム・レンガ・スレート)を分け合っているので、二つ三つを一枚の画面に混ぜても色は喧嘩しません。プロフィールカード全体の見かけはプロフィールカード デザイン 9種に、ログイン画面周りの部品はログイン・サインアップ カード UI 9種に、アカウントの安全の画面はアカウント保護設定 UI 9種にまとめてあります。

01イニシャル自動配色アバター

名前の文字を数値に畳んで六色から一つを選ぶ円です。畳み方は純粋関数のハッシュなので同じ名前は画面を描き直すたびに必ず同じ色を受け取り、入力欄に打ち込むと円と文字がその場で入れ替わります。写真を設定しない利用者の多い一覧やコメントに使います。

translateYanimation-fill-modeanimation-delay
// 名前を数値に畳んで 0〜5 — 純粋関数なので同じ名前は必ず同じ色を選ぶ
function hashName(text) {
  var h = 0;
  for (var i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0;
  return h % 6;
}

// ハングルなら最初の一文字、ラテン文字なら先頭二語の頭文字
function initialsOf(text) {
  var trimmed = text.trim();
  if (!trimmed) return '';
  if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
  var words = trimmed.split(/\s+/).filter(function (w) { return /[A-Za-z]/.test(w[0] || ''); });
  return words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
}

// JS は色を知らない — data-tone の番号だけ変え、scss が六色を塗る
function applyName() {
  avatar.dataset.tone = String(hashName(input.value));
  avatar.textContent = initialsOf(input.value);
}
.ai__avatar[data-tone="0"] { background: $av-blue; }
.ai__avatar[data-tone="1"] { background: $av-violet; }
.ai__avatar[data-tone="2"] { background: $av-teal; }
.ai__avatar[data-tone="3"] { background: $av-plum; }
.ai__avatar[data-tone="4"] { background: $av-brick; }
.ai__avatar[data-tone="5"] { background: $av-slate; }

023段フォールバック・アバター

写真を待つ枠、写真が結局届かないときのイニシャル、名前すらないときの人型と、三段階が一コマで切り替わります。外部から受け取った写真の URL がよく壊れる画面に使います。

steps(1, end)translateXobject-fit
// 写真はわざと存在しないファイルを指す — error イベントが代替の段を押し上げる
photo.addEventListener('error', function () {
  root.dataset.step = 'initials';
});

// ボタン一つでイニシャルと人型を行き来する(名前を消す・戻す)
btn.addEventListener('click', function () {
  root.dataset.step = root.dataset.step === 'initials' ? 'mark' : 'initials';
});
// 三段を steps(1, end) で一コマに切る — 半透明の状態が重ならない。
// 0% 待機 / 34% イニシャル / 67% 人型、88% の中に待機へ戻る。
// カットだけでは動くコマが足りないので、待ち段階のシマー棒が通り続けてコマを満たす
.fb.is-demo .fb__photo,
.fb.is-demo .fb__stepTxt--photo {
  animation-name: fbCutPhoto;
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}

@keyframes fbCutPhoto {
  0%      { opacity: 1; }
  34%     { opacity: 0; }
  88%     { opacity: 1; }
  100%    { opacity: 1; }
}

03名前パターン・アバター

同じ名前から必ず同じ 5×5 の模様が出るアイデンティコンです。ハッシュのビットを左三列に配り、右へ鏡写しにして左右対称にするので、イニシャルが重なって見分けられないチームやリポジトリの一覧で人を分けます。

grid-template-columnsanimation-delaytransform-origin
// 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;
}
.id__grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
}

// マスごとに時差の呼吸 — 点いたマスが消えないよう opacity では消さない
.id.is-demo .id__cell {
  animation-name: idBreathe;
  animation-duration: $duration;      // 1s — 2s の約数
  animation-delay: calc(var(--i) * 40ms);
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}

04形とサイズのセット

同じアバター一つが円・角丸円・六角を行き来し、四つのサイズ(28・36・48・64px)が一列に並びます。文字の大きさと縁の太さは --size 一つから比率で追従するので、複数の画面で同じアバターを使い回すデザインシステムに向きます。

clip-pathaspect-ratioaria-pressed
// 形はすべて clip-path で — 異なる関数同士は補間できないので、替えは steps のカットで跳ぶ
.as[data-shape="circle"]   .as__av { clip-path: circle(50%); }
.as[data-shape="squircle"] .as__av { clip-path: inset(0 round 34%); }
.as[data-shape="hex"]      .as__av { clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); }
.as__av { aspect-ratio: 1; }

// --size 一つで文字の大きさ・縁の太さが比率で追う(px の直書きはしない)
.as__av {
  width: var(--size);
  font-size: calc(var(--size) * .4);
  border: calc(var(--size) / 14) solid $av-deep;
}

05重なりアバターと +N

四人だけが重なって見え、残りは +N の一つの枠に畳まれます。ポインターが乗るかフォーカスが当たると重なりがほどけて一人ずつ横に開き、+N を押すと畳んだ人数が一覧に開きます。文書やタスクの参加者を一行で見せる場所に使います。

translateXanimation-delay:focus-visible
// 重なりは負の margin — 間隔には 4px 格子のトークンだけ使う
.st__stack > * + * { margin-left: -#{$sp-3}; }

// 開きは重なったぶんだけ戻す translateX — 瞬間移動なしで往復する
@keyframes stSpread {
  0%   { transform: translateX(0); }
  40%  { transform: translateX(calc(var(--i) * #{$sp-3})); }
  78%  { transform: translateX(calc(var(--i) * #{$sp-3})); }
  90%  { transform: translateX(0); }
  100% { transform: translateX(0); }
}

// 畳んだ人数の一覧 — display:flex が UA の hidden に勝つので一行重ねる
.st__row[hidden] { display: none; }

06オンライン状態ドット

アバターの面をマスクで丸くくり抜き、その穴にドットを座らせます。ドットは顔も縁も覆わず抜いた場所に正確に収まり、状態が変わるたびに隣の文字と読み上げる名前が一緒に替わります。今誰が接続しているかを見せるチームの画面に使います。

mask-imagesteps(1, end)aria-label
// このデモの主題 — アバターの面にマスクで穴を開け、ドットをその穴に座らせる。
// ドットは顔も縁も覆わず、抜いた場所に正確に収まる
.pd__av {
  -webkit-mask-image: radial-gradient(circle 11px at 82% 82%, transparent 98%, #000 100%);
  mask-image: radial-gradient(circle 11px at 82% 82%, transparent 98%, #000 100%);
}

// 状態ドット — マスクの穴(82% 82%、直径およそ 21px)に収まる 10px の円
.pd__dot {
  position: absolute;
  left: 82%;
  top: 82%;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: $av-teal;
  transform: translate(-50%, -50%);
}
// 状態の文字はコードに埋め込まず、HTML の中の要素から読む
function currentLabel() {
  var el = root.querySelector('.pd__state span[data-key="' + root.getAttribute('data-state') + '"]');
  return el ? el.textContent : '';
}

// aria-label は文の前後に隙間が開かないよう、最初から完成した文で作る
function applyLabel() {
  wrap.setAttribute('aria-label', nameEl.textContent + ', ' + currentLabel());
}

07メンションチップ入力

アットマークを打つとアバター付きの一覧が開き、上下のキーでたどって選んだ人が入力欄の前にチップとして入ります。チップは削除ボタンで外れ、Escape で一覧が閉じます。コメントやタスクで人を呼んで指名する入力に使います。

aria-activedescendanttranslateYrole="listbox"
<input class="mc__input" type="text" role="combobox" aria-expanded="true"
       aria-controls="mc-list" aria-autocomplete="list" aria-activedescendant="mc-opt-0"
       placeholder="コメントを入力" autocomplete="off">
<ul class="mc__list" id="mc-list" role="listbox" aria-label="メンション先">
  <li class="mc__opt" id="mc-opt-0" role="option" aria-selected="true">
    <span class="mc__av mc__av--blue" aria-hidden="true"></span><span class="mc__name">編集者</span>
  </li>
</ul>
// たどっている行を aria-activedescendant が読み上げる
function setActive(i) {
  var v = visible();
  if (!v.length) return;
  active = (i + v.length) % v.length;
  v.forEach(function (o, k) { o.setAttribute('aria-selected', k === active ? 'true' : 'false'); });
  input.setAttribute('aria-activedescendant', v[active].id);
}

08ホバーのユーザーカード

アバターにポインターが留まってから小さなカードが上がり、離れても少し待ってから閉じます。閉じるまでの猶予があるので、カードへマウスを移す間もカードは消えず、名前だけが出る場所で誰かを確かめられます。

aria-describedbytranslateY:focus-visible
// 開くのは 300ms の遅延 — かすめて通り過ぎるだけではカードが揺れない。
// 閉じるのは 200ms の猶予 — 引き金からカードへマウスが渡る間、カードは生きている
trigger.addEventListener('pointerenter', function () {
  clearTimeout(openT); clearTimeout(closeT);
  openT = setTimeout(function () { card.hidden = false; }, 300);
});
function scheduleClose() {
  clearTimeout(openT); clearTimeout(closeT);
  closeT = setTimeout(function () { card.hidden = true; }, 200);
}
trigger.addEventListener('pointerleave', scheduleClose);
card.addEventListener('pointerenter', function () { clearTimeout(closeT); });
card.addEventListener('pointerleave', scheduleClose);
// カードの出現はカット + 短いポップ — 半透明の幽霊フレームができない
@keyframes ucCard {
  0%        { opacity: 0; transform: translateY(6px); animation-timing-function: steps(1, end); }
  26%       { opacity: 1; transform: translateY(6px); animation-timing-function: $easing; }
  34%, 100% { opacity: 1; transform: translateY(0); }
}

// 閉じは 84〜92% のワイプ — 面積を複数のコマに分けて渡す
@keyframes ucWipe {
  0%, 84%   { clip-path: inset(0 0 0 0); animation-timing-function: ease-in; }
  92%, 100% { clip-path: inset(0 0 100% 0); }
}

09写真の位置合わせ

丸い窓の下の絵をドラッグして位置を動かし、スライダーで倍率を上げる画面です。倍率を下げても窓の外に余白が出ないように、動ける範囲が先に狭まります。プロフィール写真を上げた直後の切り抜きに使います。

clip-pathscaletranslate
// 肝 — 動ける範囲は maxOffset = (写真の大きさ × 倍率 − 窓の大きさ) / 2。
// 倍率を下げると範囲が先に狭まるので、窓の縁に空きは絶対にできない。
// 写真の下地は窓の 1.5 倍(inset -25%)
function clamp(v, zz) {
  var w = win.offsetWidth;                       // 窓の大きさ(描画変換とは無関係)
  var m = Math.max(0, (w * 1.5 * zz - w) / 2);
  return Math.max(-m, Math.min(m, v));
}
function apply() {
  root.style.setProperty('--x', x + 'px');
  root.style.setProperty('--y', y + 'px');
  root.style.setProperty('--z', '' + z);
}
// 変換はこの一か所に集める — ドラッグ・倍率・見本が同じ変数を分け合う
.cu__photo {
  position: absolute;
  inset: -25%;
  transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--z, 1));
  will-change: transform;
}
.cu__window { clip-path: circle(50%); }

どこで壊れるか — 落とし穴

最初に壊れたのは 07 のメンション一覧の溢れでした。絶対配置のリストに top: 100% を掛けたところ、その 100% が高さを予約してあったルート全体の寸法を指してしまい、一覧が予約済みの領域の下へ落ちて、画面 300px に対して内容 332px になりました。絶対配置の高さは親の計算から外されるので、格子に隙間が空いて初めて分かる壊れ方です。基準を行(position: relative の付いた入力の行)へ移し、ルートの padding-bottom を開いた高さぶんをそのまま残すと、一覧は予約の内側に収まって溢れが消えました。

二つ目は 09 の偽の写真でした。グラデーション一枚で描いた写真を 10px ほど動かしても、動きの測定では強度が 13.8 しか出ず、しきい値の 15 を超えませんでした。柔らかなグラデーションは隣り合う画素の差が小さく、目には動いて見えても画素の差としてはほぼ残らないためです。明るい顔の斑と暗い肩の斑を重ねて写真の境界をはっきりさせると、強度は 16.4 まで上がりました。同じ理由で、背景をグラデーションで埋めたデモの文字の対比は、周囲の色を一通り計算してから決める必要があります。計算を先にしないと、暗い色の上の濃い字が静かに消えます。

三つ目はマスクの接頭辞です。06 が使う mask-image は 2026-09-12 の caniuse 実測で全世界の 92.86% が完全対応、4.17% が部分対応で、接頭辞なしの標準属性は Chrome 120・Safari 15.4・Firefox 53 からです。古いバージョンが残っている間は -webkit-mask-image を隣に並べて書く方が安全なので、二行を並べてあります。

最後は状態の入れ替えです。02 と 06 は最初、ゆっくり移し替える形で組んだところ、二つの状態が半透明で重なるフレームが現れたので steps(1, end) のカットに改めました。カットだけでは動いたコマが足りないため、待ち段階のシマー棒とドットの脈動を添えてコマ数を確保しています。

測定値はこの四つの場所をそのまま映しています。07 は一覧全体が動くので累積変化面積 21.97% と九つの中で最も広く、08 はカードが現れる瞬間の明るさの差が大きく強度 125.5 で最も強く、09 は斑を入れても 16.4 と九つで最も低く、この部品がグラデーションの上でどれほど静かかを示します。逆に 06 は点一つの脈動なので累積面積は 0.56% に過ぎませんが、動いたコマは 17 枚を数えて、小さな部品を止まった絵にしないのは面積ではなくコマの数だということを確かめました。03 の呼吸は 25 のマスがそれぞれ時差を受けて、登録されたアニメーションだけで 100 個、24 コマのうち 23 コマに動きが残ります。01 も同じ 23 枚を満たして時差の基準になり、02 と 04 はカットにシマーとポップを添えてそれぞれ 11 枚、14 枚を確保しました。内側の動きにも同じ設計が通っています。07 の選ぶ棒は 0・36・54・86% の四点で 72px を往復し一覧は最後まで開いたまま、08 の埋める棒は 28〜84% で scaleX が伸びて閉じのワイプが 84〜92% に面積を分け、02 のシマー棒は 1s で通り抜けて 2s の周期の約数として段の切替と噛み合っています。05 は一人につき 45ms の時差で開くので、開ききった姿が代表コマに選ばれます。04 の形の替え目は 0・34・67・88% の四点に置かれ、循環の戻りは 90% の中に収まります。この四か所を直した完成版の九つは zip にまとめてあり、解凍のパスワードは dchsanq2 です。

アクセシビリティ

対比は勘ではなく、WCAG 2.1 の相対輝度の式で一組ずつ計算し、通った組み合わせだけ載せました。イニシャルの文字は六つのトーンの上でいつも同じクリーム一色です。

文字色 背景 実測コントラスト 判定
クリーム #fff7e6 青 #2a5fd8 5.29:1 合格
クリーム #fff7e6 バイオレット #5b4bd6 5.76:1 合格
クリーム #fff7e6 ティール #0d6e5c 5.79:1 合格
クリーム #fff7e6 プラム #a1266f 6.52:1 合格
クリーム #fff7e6 レンガ #c2361a 5.13:1 合格
クリーム #fff7e6 スレート #3b4a63 8.40:1 合格
クリーム #fff7e6 インク #17141a 17.11:1 合格
インク #17141a イエロー #ffd23f 12.63:1 合格
インク #17141a オレンジ #ff4d1f 5.50:1 合格
インク #17141a ホワイト #ffffff 18.24:1 合格
クリーム #fff7e6 オレンジ #ff4d1f 3.11:1 不合格 — 使わない
クリーム #fff7e6 イエロー #ffd23f 1.35:1 不合格 — 使わない

動きを減らしてほしいという設定(prefers-reduced-motion: reduce)では、九つとも消えるのはアニメーションだけで、名前・状態・値は画面にそのまま残ります。01 は六つのアバターが全部上がった姿で立ち、02 はイニシャルの段階で止まり、03 は模様が全部引かれた状態で呼吸だけが止まります。04 は最後に選んだ形のまま四つのサイズが並び、05 は重なったまま静止し、06 は状態の色と文字が生きています。07 は一覧が開いたまま選ぶ棒が最初の行で止まり、08 はカードが開いたまま活動の棒が満ちて止まり、09 は写真が中央に止まって窓が空きません。

構造の面で整えたことは次のとおりです。イニシャルに実名ではなく役職名(管理者・編集者・デザイナー)だけを使い、イニシャルの文字は画面の文字列から JavaScript が計算するので、言語が変われば付いていきます。01・03・09 には名前を打つ入力と引きずる入力があり、05 のアバターはすべてボタンなので Tab で一人ずつ選べ、06 は状態ごとに aria-label を「名前、状態」の完成した文へ入れ替えます。07 は role="combobox"aria-expandedaria-activedescendant でたどっている行を読み上げ、08 は aria-describedby でカードを引き金に結んだ上で、遅延と猶予はポインター操作にだけ残しました。マスクの接頭辞と継ぎ目の根拠はMDN の mask-image の文書pointerenter の文書に、対比の基準はW3C の最低輝度対比の解説に従いました。利用者表示の題材をさらに見るにはダッシュボード部品のカテゴリーへ、このサイトがデモをどう描き、どう測っているかは紹介に書いてあります。

FAQ

アバターのサイズは何pxに決めるのがよいですか?

決まった一つの値より、先にセットを決める方がよいです。04 のように 28・36・48・64px の四段を用意し、文字の大きさと縁の太さを calc(var(--size) * .4) の形で一つの変数に束ねると、どのサイズを選んでも比率が保たれます。画面が狭くなるときはサイズを縮めるより、下の行を隠す方が対比を守ります。

写真をまだ上げていない利用者はどう表示すればよいですか?

名前があれば 01 の自動配色イニシャルが既定値です。写真の URL を受け取る画面なら、02 のように待機のシマー、イニシャル、人型の三段を先に決めて imgerror イベントに次の段を付けると、壊れた URL が空の丸として残りません。

状態のドットはアバターにそのまま重ねてよいですか?

そのまま重ねると顔を隠すか、縁の外へ押し出されます。06 のようにアバターの面にマスクで穴を開けてドットをその中に座らせると、ドットは常に定位置にあり、状態が変わるとき隣の文字と aria-label まで一緒に替わるので、色だけで情報が完結しません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。