Reactで、名前から頭文字と色を決めたアイコンを表示する方法
プロフィール画像がないユーザーには、名前の頭文字を色付きの丸に載せたアイコンを出すと、一覧の中で人を見分けやすくなります。頭文字は Intl.Segmenter で見た目の1文字を取り出し、色は名前から計算した数で色相を決めれば、外部のライブラリは要りません。同じ名前なら、いつ表示しても同じ色になります。この方法は、ユーザー一覧などで、画像がない人に頭文字のアバターを出したいときに使えます。
ここでは、name を渡すと頭文字と色を決めて丸いアイコンを描く Avatar というコンポーネントを作りました。日本語の名前は最初の1文字、ラテン文字の名前は最初と最後の語の頭文字を大文字で2文字にします。記事に出てくる名前は、すべて架空のものです。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で Chromium に表示して確かめました。Firefox と Safari、実機のスマートフォン、スクリーンリーダーでの実際の読み上げは試していません。画像がある人には画像を出し、ない人だけ頭文字にする切り替えも扱っていません。

頭文字は、見た目の1文字で取り出す
頭文字と色を決める関数は initials.ts にまとめました。
// 名前から、アイコンに出す頭文字と背景の色を決める関数
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
// 見た目の1文字(絵文字や結合文字も1つとして数える)を取り出す
function firstGrapheme(s: string): string {
for (const { segment } of segmenter.segment(s)) return segment;
return '';
}
// 見た目の文字数('👩💻'.length は 5 だが、ここでは 1 と数える)
export function graphemeCount(s: string): number {
return [...segmenter.segment(s)].length;
}
// 全角・半角をそろえ、前後の空白を取り、空白で区切った語の配列にする
function toWords(name: string): string[] {
return name.normalize('NFKC').trim().split(/\s+/).filter((w) => w !== '');
}
// ラテン文字の名前は最初と最後の語の頭文字を大文字で2文字、それ以外は最初の1文字
export function getInitials(name: string): string {
const words = toWords(name);
if (words.length === 0) return '?';
const first = firstGrapheme(words[0]);
if (!/^\p{Script=Latin}/u.test(first)) return first;
const last = words.length > 1 ? firstGrapheme(words[words.length - 1]) : '';
return (first + last).toUpperCase();
}
// 文字列から 32bit の数を作る。1文字ずつ、排他的論理和と掛け算を繰り返す
// 同じ文字列なら、いつ・どこで実行しても同じ数になる
export function hashString(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
// 名前から色相(0〜359)を決め、明るさをそろえた OKLCH の色にする
export function avatarColor(name: string): string {
const hue = hashString(toWords(name).join(' ')) % 360;
return `oklch(0.52 0.12 ${hue})`;
}
先頭の1文字は、name[0] では正しく取れないことがあります。JavaScript の文字列は UTF-16 のコード単位の並びで、MDN の String.length の説明にあるとおり、絵文字などでは、見た目の文字数と合わないことがあります。Node.js で3つの取り方を比べると、次のようになりました。
"👩💻 開発チーム" n[0]: "\ud83d" / [...n][0]: "👩" (U+1F469) / Segmenter: "👩💻"
"🇯🇵 Team" n[0]: "\ud83c" / [...n][0]: "🇯" (U+1F1EF) / Segmenter: "🇯🇵"
"émile" n[0]: "e" / [...n][0]: "e" (U+65) / Segmenter: "é"
n[0] は、絵文字を表す2つのコード単位の前半だけを返します。スプレッド構文の [...n][0] は1つのコードポイントを返すので、「パソコンに向かう人」の絵文字は「女性」だけに、日本の国旗は「J」を表す地域指示記号(U+1F1EF)だけになりました。3つ目の émile は、e の後ろにアクセント記号 U+0301 を続けて書いた文字列です。これも é ではなく e だけが取れます。
Intl.Segmenter に granularity: 'grapheme' を渡すと、文字列を書記素(見た目の1文字)ごとに区切れます。3つとも、見た目どおりの1文字が取れました。
名前は、区切る前に normalize('NFKC') を通しています。NFKC は、互換等価な文字を同じ形にそろえる正規化です(MDN の String.prototype.normalize() の説明)。これで、半角カナの「ヤマダ タロウ」は「ヤ」に、全角の英字で書いた「taro yamada」は「TY」になりました。全角の空白(U+3000)も NFKC で半角の空白(U+0020)に変わるので、「山田 太郎」も「山田 太郎」と同じ扱いです。Node.js で変換前後の文字を調べると、次のようになりました。
" " U+3000 -> " " U+0020
"ダ" U+FF80 U+FF9E -> "ダ" U+30C0
"t" U+FF54 -> "t" U+0074
主な名前の頭文字と色を抜き出すと、次のとおりです。
"山田 太郎" -> "山" oklch(0.52 0.12 108)
"山田 太郎" -> "山" oklch(0.52 0.12 108)
" 山田 太郎 " -> "山" oklch(0.52 0.12 108)
"ヤマダ タロウ" -> "ヤ" oklch(0.52 0.12 19)
"taro yamada" -> "TY" oklch(0.52 0.12 116)
"taro yamada" -> "TY" oklch(0.52 0.12 116)
"Mary Jane Smith" -> "MS" oklch(0.52 0.12 237)
"chris" -> "C" oklch(0.52 0.12 100)
"" -> "?" oklch(0.52 0.12 61)
ラテン文字かどうかは、正規表現の Unicode 文字クラスエスケープ
\p{Script=Latin} で見ています。ラテン文字の名前は、最初と最後の語から1文字ずつ取ります。真ん中の語(「Mary Jane Smith」の Jane)は使いません。空の文字列や空白だけの名前は「?」にしました。
名前から、毎回同じ色を決める
色は、名前を hashString() で 32bit の数にし、360 で割った余りを色相にしています。Math.random() と違い、同じ名前からは必ず同じ数が出ます。Node.js でも Chromium でも、「山田 太郎」は色相 108 でした。Math.imul() は、C 言語と同じように 32 ビットで掛け算をする関数です。MDN の例では Math.imul(0xffffffff, 5) が -5 になるように、結果は負の数になることもあります。そこで最後に >>> 0 を通し、0 以上の整数にそろえています。
ハッシュに渡すのは、正規化して空白を1つにそろえた文字列です。そのため、全角の空白を使った「山田 太郎」も、前後に空白がある名前も、同じ色になります。一方で、「山田 太郎」と「山田 次郎」のように1文字違うだけの名前は、色相 108 と 75 に分かれました。
色相だけを変えるなら、hsl() より oklch() がよい
アイコンが並んだときに一部の色だけが目立たないよう、色相だけを変え、明るさと鮮やかさはそろえることにしました。ただ、hsl() で明るさの値をそろえても、白い文字の読みやすさはそろいません。MDN の oklch() の説明には、oklch() の L は目で見た明るさ(知覚明度)を表し、hsl() の L とは違うと書かれています。
そこで、色相 0〜359 の色を Chromium の canvas に塗って sRGB の値を読み、白い文字とのコントラスト比を求めました。計算には WCAG の相対輝度とコントラスト比の式を使っています。

hsl(h 65% 45%): 最小 2.01(色相 60)、最大 9.87(色相 240)、4.5 未満の色相 186個(21〜206)
oklch(0.55 0.12 h): 最小 4.38(色相 192)、最大 5.21(色相 357)、4.5 未満の色相 43個(173〜216)
oklch(0.52 0.12 h): 最小 4.93(色相 190)、最大 5.92(色相 0)、4.5 未満の色相 0個
hsl(色相 65% 45%) では、橙から水色にかけての 186 の色相で、コントラスト比が 4.5 を下回りました。黄色(色相 60)では 2.01 しかありません。oklch() では、明るさ 0.55 でも 0.52 でも、どの色相も 4.4〜5.9 の幅に収まります。それでも明るさ 0.55 では、青緑のあたり(色相 173〜216)が 4.5 に届かなかったので、0.52 に下げました。これで、360 の色相すべてで 4.93 以上になります。
4.5 は、WCAG 2.2 の達成基準 1.4.3 が、ふつうの大きさの文字に求めるコントラスト比です。大きな文字(18 ポイント以上、または 14 ポイント以上の太字)なら 3:1 で足ります。同じページには、1pt = 1.333px なので 14pt と 18pt はおよそ 18.5px と 24px に当たる、と書かれています。既定の大きさ 48px の Avatar では、1文字の頭文字が 24px、2文字が 19.2px の太字なので、大きな文字に当たります。ただ、size を小さくするとこの条件から外れるので、4.5 を目安にしました。
色の数は 360 しかなく、近い色相は見分けにくくなります。上の画像でも、「山田 太郎」(色相 108)と「taro yamada」(色相 116)はほとんど同じ色に見えます。色は見分ける手がかりの1つと考え、名前の文字もアイコンの横に出しておくのが確実です。
頭文字と色を丸く描く Avatar コンポーネント
Avatar.tsx は、名前から頭文字と色を求めて、丸い span を描くだけです。
import { avatarColor, getInitials, graphemeCount } from './initials';
type Props = {
name: string;
size?: number;
};
export function Avatar({ name, size = 48 }: Props) {
const initials = getInitials(name);
return (
<span
className="avatar"
role="img"
aria-label={name.trim() || '名前なし'}
style={{
width: size,
height: size,
// 2文字のときは少し小さくして、円の中に収める
fontSize: size * (graphemeCount(initials) > 1 ? 0.4 : 0.5),
backgroundColor: avatarColor(name),
}}
>
{initials}
</span>
);
}
style に数値を渡すと、単位のないプロパティを除いて React が px を付けます(React の共通コンポーネントのリファレンスにある style の説明)。2文字かどうかは graphemeCount() で数えています。initials.length > 1 で判定すると、絵文字の頭文字も2文字と見なされ、文字が小さくなります。検証環境で .length を調べると、「👩💻」は 5、「🇯🇵」は 4 でした。
"TY" .length: 2 / graphemeCount: 2
"山" .length: 1 / graphemeCount: 1
"👩💻" .length: 5 / graphemeCount: 1
"🇯🇵" .length: 4 / graphemeCount: 1
丸くして文字を真ん中に置くのは、CSS で行います。
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 50%;
color: #fff;
font-weight: bold;
line-height: 1;
user-select: none;
}
Playwright で測ると、一覧の 10 個のアイコンはどれも 48×48、入力欄の下のものは 72×72 で、2文字の「MS」も円の幅の中に収まっていました。
読み上げには、名前をそのまま渡す
頭文字の「山」だけでは、誰のアイコンか分かりません。そこで role="img" を付け、aria-label に名前を入れています。MDN の img ロールの説明には、ほとんどのスクリーンリーダーは role="img" を付けた要素の中にはアクセスせず、全体の説明を aria-label などで付けるようにと書かれています。
Playwright で getByRole('img', { name: '佐藤 花子' }) を探すと、「佐」のアイコンが見つかりました。名前が空のときは、ラベルを「名前なし」にしています。
画面を組み立てる
入力欄と、名前の一覧を並べた App.tsx です。main.tsx では、これを StrictMode で包んで表示しています。
import { useState } from 'react';
import { Avatar } from './Avatar';
// 架空の名前
const names = [
'山田 太郎',
'山田 太郎',
'佐藤 花子',
'ヤマダ タロウ',
'taro yamada',
'taro yamada',
'Mary Jane Smith',
'chris',
'👩💻 開発チーム',
'',
];
export function App() {
const [name, setName] = useState('鈴木 一郎');
return (
<main>
<label htmlFor="name">名前</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<p className="preview">
<Avatar name={name} size={72} />
</p>
<ul className="list">
{names.map((n, i) => (
<li key={i}>
<Avatar name={n} />
<span className="name">{n === '' ? '(空の文字列)' : n}</span>
</li>
))}
</ul>
</main>
);
}
入力欄の名前を書き換えると、アイコンの頭文字と色もそれに合わせて変わります。Playwright で入力欄に名前を入れたところ、次のようになりました。
入力 "鈴木 一郎" -> "鈴" oklch(0.52 0.12 261)
入力 "鈴木 二郎" -> "鈴" oklch(0.52 0.12 1)
入力 "ken tanaka" -> "KT" oklch(0.52 0.12 177)
入力 "" -> "?" oklch(0.52 0.12 61)
頭文字が同じ「鈴木 一郎」と「鈴木 二郎」でも、色は青(色相 261)と赤みのある色(色相 1)に分かれます。ブラウザのコンソールには、React DevTools を案内するメッセージのほかに、エラーや警告は出ませんでした。





