dio.la

Dani Guardiola’s blog

May 2, 2023May 2nd, 2023 · 3 minute read · tweet

Colorful avatars

An interactive walkthrough of an algorithm to pick an avatar's color based on the user's initials.

This article's main image

I've written about tracking the loading state of an avatar image before. However, it's not uncommon to have users without a profile picture.

In that case, the usual approach is to show the user's initials over a colored background.

Three colorful avatars displaying initials

Picking the right color

Avatars with images are recognizable and memorable. Just like you might recognize someone close to you by the sound they make when they walk, avatar images become a shortcut to quickly identify a user.

When there is no image to display, achieving the same result is a bit harder. A person's initials alone are not very useful when doing a quick visual scan. That's where the color comes in, and there are a few important rules to follow:

  1. Deterministic outcome: it's not enough to just pick a random color, it needs to be consistent. If it was different every time, it'd be useless and confusing!
  2. Uniform probability distribution: one color should not show up more often than the others. Note that we're not going for perfection here, but it should be close enough.
  3. Disctinct permutations: different arrangements of the same letters (like "DG" and "GD") should result in different colors. If not, it'd be hard to tell them apart since they share the same letters and colors!

With these in mind, we can now build the algorithm.

Do these rules remind you of something?

If you've ever worked with hash functions, you'll notice that they have a lot in common. In fact, when I first implemented this, I called it a "color hash"!

Thanks to Vladimir Klepov (who reviewed this article) for pointing this out.

The magic number

There are many possible combinations of initials.

We have 702 potential combinations with one or two initials (261+262=70226^1 + 26^2 = 702), and that's just for the English alphabet (A-Z). It increases greatly when you account for other alphabets! Our mission is to turn each of these into a small number so that we can map it to a color.

For that, we can use a bit of simple modular arithmetic, but we need to turn our initials into a number first. I call this number the "magic number" because I am fancy like that.

ts
function function getInitialsMagicNumber(initials: string): numbergetInitialsMagicNumber(initials: stringinitials: string) { const const numbers: number[]numbers = initials: stringinitials .String.toLowerCase(): string

Converts all the alphabetic characters in a string to lowercase.

toLowerCase
()
.String.substring(start: number, end?: number): string

Returns the substring at the specified location within a String object.

@paramstart The zero-based index number indicating the beginning of the substring.@paramend Zero-based index number indicating the end of the substring. The substring includes the characters up to, but not including, the character indicated by end. If end is omitted, the characters from start through the end of the original string are returned.
substring
(0, 2)
.String.split(separator: string | RegExp, limit?: number): string[] (+1 overload)

Split a string into substrings using the specified separator and return them as an array.

@paramseparator A string that identifies character or characters to use in separating the string. If omitted, a single-element array containing the entire string is returned.@paramlimit A value used to limit the number of elements returned in the array.
split
("")
.Array<string>.map<number>(callbackfn: (value: string, index: number, array: string[]) => number, thisArg?: any): number[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
((char: stringchar) => char: stringchar.String.charCodeAt(index: number): number

Returns the Unicode value of the character at the specified location.

@paramindex The zero-based index of the desired character. If there is no character at the specified index, NaN is returned.
charCodeAt
(0));
return const numbers: number[]numbers.Array<number>.reduce(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: number[]) => number): number (+2 overloads)

Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.

@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((acc: numberacc, n: numbern) => acc: numberacc + n: numbern);
}
Open in playground

To achieve this, we:

  1. "Normalize" the initials string by converting it to lowercase and taking only the first two characters.
  2. Split and turn both characters into numbers with String.prototype.charCodeAt.
  3. Finally, add them together to get the magic number.

Here's a little playground to try it out:

203

Mapping to a color

Let's say we have these five colors:

ts
const const COLORS: readonly ["purple", "orange", "green", "yellow", "blue"]COLORS = ["purple", "orange", "green", "yellow", "blue"] as type const = readonly ["purple", "orange", "green", "yellow", "blue"]const;Open in playground

We can now map the magic number to one of these colors by using the remainder (%) operator (A.K.A. modulo).

ts
export function function getAvatarColorFromInitials(initials: string): "purple" | "orange" | "green" | "yellow" | "blue"getAvatarColorFromInitials(initials: stringinitials: string) { const const magicNumber: numbermagicNumber = function getInitialsMagicNumber(initials: string): numbergetInitialsMagicNumber(initials: stringinitials); const const colorIndex: numbercolorIndex = const magicNumber: numbermagicNumber % const COLORS: readonly ["purple", "orange", "green", "yellow", "blue"]COLORS.length: 5length; return const COLORS: readonly ["purple", "orange", "green", "yellow", "blue"]COLORS[const colorIndex: numbercolorIndex];}Open in playground

Try it:

DG

With this, we've achieved our first two goals: deterministic outcome and (more or less) uniform probability distribution. We're missing the third one, though.

Disctinct permutations

Here's a demo that shows the color for both permutations of the same initials (the numbers below are the magic numbers):

DG
203
GD
203

As you can see, with the current algorithm, the colors are the same for both permutations. This is because the magic number is the same for both.

We can fix that with a little trick: we can add something I called "spice" (because, again, I am fancy like that) to the magic number. This spice will be a number that is either 0 or 1, depending on the order of the characters (based on their UTF-16 code unit).

For example, if we have "DG" and "GD", only one of them will be affected by the spice, so they will have different magic numbers and, therefore, different colors.

ts
function function getInitialsMagicNumber(initials: string): numbergetInitialsMagicNumber(initials: stringinitials: string) { const const numbers: number[]numbers = initials: stringinitials .String.toLowerCase(): string

Converts all the alphabetic characters in a string to lowercase.

toLowerCase
()
.String.substring(start: number, end?: number): string

Returns the substring at the specified location within a String object.

@paramstart The zero-based index number indicating the beginning of the substring.@paramend Zero-based index number indicating the end of the substring. The substring includes the characters up to, but not including, the character indicated by end. If end is omitted, the characters from start through the end of the original string are returned.
substring
(0, 2)
.String.split(separator: string | RegExp, limit?: number): string[] (+1 overload)

Split a string into substrings using the specified separator and return them as an array.

@paramseparator A string that identifies character or characters to use in separating the string. If omitted, a single-element array containing the entire string is returned.@paramlimit A value used to limit the number of elements returned in the array.
split
("")
.Array<string>.map<number>(callbackfn: (value: string, index: number, array: string[]) => number, thisArg?: any): number[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
((char: stringchar) => char: stringchar.String.charCodeAt(index: number): number

Returns the Unicode value of the character at the specified location.

@paramindex The zero-based index of the desired character. If there is no character at the specified index, NaN is returned.
charCodeAt
(0));
const const spice: 0 | 1spice = const numbers: number[]numbers[0] < const numbers: number[]numbers[1] ? 0 : 1; return const numbers: number[]numbers.Array<number>.reduce(callbackfn: (previousValue: number, currentValue: number, currentIndex: number, array: number[]) => number): number (+2 overloads)

Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.

@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((acc: numberacc, n: numbern) => acc: numberacc + n: numbern) + const spice: 0 | 1spice;
}
Open in playground

Now it works as intended:

DG
203
GD
204

And we are done!

Bonus: initials from name and one final demo

Here's another neat utility to extract initials (up to two characters) from a name:

ts
export function function getInitialsFromName(name: string): string | undefinedgetInitialsFromName(name: stringname: string) { const const matchResult: RegExpStringIterator<RegExpExecArray>matchResult = name: stringname?.String.matchAll(regexp: RegExp): RegExpStringIterator<RegExpExecArray>

Matches a string with a regular expression, and returns an iterable of matches containing the results of that search.

@paramregexp A regular expression
matchAll
(/(\S)\S*/g);
const const matches: string[]matches = [...const matchResult: RegExpStringIterator<RegExpExecArray>matchResult].Array<RegExpExecArray>.map<string>(callbackfn: (value: RegExpExecArray, index: number, array: RegExpExecArray[]) => string, thisArg?: any): string[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
((r: RegExpExecArrayr) => r: RegExpExecArrayr[1]);
return const matches: string[]matches ? `${const matches: string[]matches[0]}${const matches: string[]matches[1] ?? ""}` : var undefinedundefined;}
Open in playground

Finally, here's a demo of the Avatar component I built for Guide that displays all permutations of initials using a-z and a few non-English characters:

Keep up with my stuff. Zero spam.