An explorable explanation of turning usernames into unique visual identities
{avatar && (
)}
{avatar && (
)}
{GALLERY_NAMES.slice(0, 16).map((n) => {
const av = generateAvatar(n);
return av ? (
) : null;
})}
scroll to explore how it works ↓
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Section 1 — Hashing: From Name to Number
// ═══════════════════════════════════════════════════════════════════════════════
const HEX_COLORS = [
"#4a6fa5", "#5b82b6", "#6c95c7", "#7da8d8", // 0-3: blues
"#4aa58a", "#5bb69b", "#6cc7ac", "#7dd8bd", // 4-7: teals
"#a5894a", "#b69a5b", "#c7ab6c", "#d8bc7d", // 8-b: golds
"#a54a6f", "#b65b80", "#c76c91", "#d87da2", // c-f: roses
];
function HashDisplay({ hash, label }) {
return (
{label && (
{label}
)}
{hash.split("").map((char, i) => (
{char}
))}
);
}
function HashSection() {
const [nameA, setNameA] = useState("alice");
const [nameB, setNameB] = useState("alice1");
const avatarA = useMemo(() => generateAvatar(nameA), [nameA]);
const avatarB = useMemo(() => generateAvatar(nameB), [nameB]);
const diffCount = useMemo(() => {
if (!avatarA || !avatarB) return 0;
return avatarA.hash.split("").filter((c, i) => c !== avatarB.hash[i]).length;
}, [avatarA, avatarB]);
return (
Every avatar starts with a simple requirement: given a username, produce something
visual that's always the same for that name, but different from every other
name. We need a deterministic fingerprint.
That's what a hash function gives us. Feed in a name, get back an enormous number —
64 hexadecimal characters worth. Always the same number for the same name.
{avatarA && }
But here's the magical part. Change even a single character and the{" "}
entire output transforms. This property — where tiny input changes cause
dramatic output changes — is called the avalanche effect.
{avatarA && }
{avatarB && }
{diffCount} of 64 characters changed — {Math.round((diffCount / 64) * 100)}%
different
It doesn't matter how similar the inputs are. The outputs are{" "}
always wildly different. This is what makes each avatar genuinely
unique — even{" "}alice and alice1 get completely
different visual fingerprints.
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Section 2 — Colors & the Golden Angle
// ═══════════════════════════════════════════════════════════════════════════════
function ColorWheel({ baseHue, points, size = 220, interactive, onHueChange }) {
const svgRef = useRef(null);
const r = size / 2;
const dotDist = r * 0.72;
const handleWheel = useCallback(
(e) => {
if (!interactive || !onHueChange) return;
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
let angle = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
if (angle < 0) angle += 360;
onHueChange(Math.round(angle) % 360);
},
[interactive, onHueChange]
);
// Build conic gradient stops for the wheel ring
const ringSegments = [];
for (let i = 0; i <= 360; i += 5) {
ringSegments.push(
);
}
return (
);
}
function AngleComparisonWheels({ baseHue }) {
const angles = [90, 120, 137];
const n = 12;
return (
);
}
function SunflowerPattern({ angle, count, size = 260 }) {
const cx = size / 2;
const cy = size / 2;
const maxR = size / 2 - 12;
const scale = maxR / Math.sqrt(count);
return (
);
}
function ColorSection() {
const [angle, setAngle] = useState(137);
const [numPoints, setNumPoints] = useState(8);
const [baseHue, setBaseHue] = useState(42);
const [sunflowerAngle, setSunflowerAngle] = useState(137);
const [sunflowerCount, setSunflowerCount] = useState(120);
const wheelPoints = useMemo(() => {
return Array.from({ length: numPoints }, (_, i) => ({
hue: (baseHue + i * angle) % 360,
}));
}, [baseHue, angle, numPoints]);
return (
Our hash gives us a huge number. Now we need to extract{" "}
colors from it. The trick is to work in{" "}
HSL — hue, saturation, lightness — where hue is simply
an angle on a color wheel.
We take our number modulo 360 to get a base hue. But we need two{" "}
colors — a background and a foreground. How do we pick the second one?
We could pick an arbitrary offset. But there's an elegant
answer from number theory: the golden angle, approximately
137.5° (we use 137° as an integer approximation).
Click or drag on the wheel to change the starting hue
{angle === 137 && (
The golden angle — maximum spread for any number of colors
)}
{wheelPoints.map((p, i) => (
))}
Try it — drag the angle slider away from 137° and watch the colors cluster.
At 90°, four positions repeat. At 120°, three. But at 137°, each new color
lands in the largest remaining gap. No matter how many colors you add, they
stay evenly distributed.
The same angle with 12 points — which distributes best?
This isn't just math for math's sake. Nature figured it out first.
Sunflower seeds, pinecone spirals, leaf arrangements — they all use the golden
angle to pack efficiently. Each new seed rotates by ~137.5° from the last,
filling space as uniformly as possible.
A sunflower spiral — each dot rotated by the chosen angle from the last
{[90, 120, 137, 150].map((a) => (
))}
For avatars, the result is simple but powerful: whatever base hue our hash gives us,
offsetting by 137° always produces a natural complement. Two colors that contrast{" "}
without clashing.
Color pairs for different usernames
{["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi"].map((name) => {
const av = generateAvatar(name);
if (!av) return null;
return (
{name}
);
})}
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Section 3 — Grid, Bits & Symmetry
// ═══════════════════════════════════════════════════════════════════════════════
function GridPreview({ cells, bgColor, fgColor, size = 180 }) {
return (
);
}
function BitToggle({ index, active, isMirror, onClick }) {
return (
);
}
function SymmetryDemo() {
const [seed, setSeed] = useState(42);
const randomGrid = useMemo(() => {
// 25 random cells from seed
let s = seed;
const next = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s; };
return Array.from({ length: 25 }, () => next() % 3 === 0);
}, [seed]);
const symmetricGrid = useMemo(() => {
let s = seed;
const next = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s; };
const grid = Array(25).fill(false);
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 3; col++) {
if (next() % 3 === 0) {
grid[row * 5 + col] = true;
if (col < 2) grid[row * 5 + (4 - col)] = true;
}
}
}
return grid;
}, [seed]);
const renderGrid = (cells) => (
);
return (
Random
{renderGrid(randomGrid)}
vs
Symmetric
{renderGrid(symmetricGrid)}
);
}
function GridSection() {
const [bits, setBits] = useState(
() => Array.from({ length: 15 }, (_, i) => Boolean((0b101_011_110_010_100 >> i) & 1))
);
const toggleBit = useCallback((index) => {
setBits((prev) => prev.map((b, i) => (i === index ? !b : b)));
}, []);
const rawBitsNum = useMemo(
() => bits.reduce((acc, b, i) => acc | (b ? 1 << i : 0), 0),
[bits]
);
const cells = useMemo(() => cellsFromBits(rawBitsNum), [rawBitsNum]);
// Build 5×5 display grid showing which cells are "original" vs "mirror"
const displayGrid = useMemo(() => {
const grid = Array.from({ length: 25 }, (_, i) => {
const col = i % 5;
const row = Math.floor(i / 5);
const isCenter = col === 2;
const isLeft = col < 2;
const isRight = col > 2;
const origCol = isRight ? 4 - col : col;
const bitIndex = row * 3 + origCol;
const isOn = bits[bitIndex];
return { col, row, isOn, isMirror: isRight && !isCenter, bitIndex };
});
return grid;
}, [bits]);
return (
Colors give us mood. Now we need shape — a pattern that
feels distinctive and almost alive.
We generate a 5×5 grid where each cell is either on or off. But here's the
key insight: the grid is symmetric. The left side mirrors
the right.
Why does symmetry matter? Our brains are deeply wired to see faces in symmetric
arrangements — a phenomenon called pareidolia. It's why you see
faces in electrical outlets and car fronts. Symmetric patterns immediately feel
more intentional, more identity-like.
To build our grid, we extract 15 bits from the hash. These
control the left half and center column of our 5×5 grid — that's 3 columns ×
5 rows = 15 cells. The right 2 columns are automatically mirrored from the left.
Toggle the bits below to see how each one controls the pattern. The faded cells
on the right are mirrors — they follow their counterpart on the left
automatically.
The binary representation of these 15 bits — {rawBitsNum.toString(2).padStart(15, "0")}{" "}
— is the pattern's DNA. Two different hashes will almost certainly produce
different bits, and therefore different shapes.
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Section 4 — Putting It All Together
// ═══════════════════════════════════════════════════════════════════════════════
function PipelineStep({ number, label, children, active }) {
return (
= 4}>
{avatar.rawBits.toString(2).padStart(15, "0")}
= 5}>
{avatar.cells.length} cells active
{/* Result */}
)}
{avatar && (
{[1, 2, 3, 4, 5].map((s) => (
)}
And here's the beauty of it: because every step is deterministic, the same
username will always produce the same avatar. No storage needed, no
random seed to save — just a pure function from name to face.
A gallery of generated avatars — every one unique, every one reproducible
{GALLERY_NAMES.map((name) => {
const av = generateAvatar(name);
return av ? (