Avatar
Initials on a filled graphite surface with a soft top light and fine grain, or one of three seeded monochrome faces: glow (soft pools of light in smoke), contour (fine topographic lines) and eclipse (a crescent of rim light).
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactElement } from 'react';
import { Spring } from '../shared/spring';
import type { SpringConfig } from '../shared/spring';
import { resolveAccent, isFocusVisible, type Accent } from '../shared/accent';
import { clamp, hashString, seeded, snapPx, useAnnouncer, useLoop } from '../basics-kit/kit';
import '../shared/ember.css';
import './avatar.css';
export type AvatarStatus = 'none' | 'live' | 'idle';
/**
* `initials`: letters on a filled graphite surface.
* `glow`: two or three soft pools of light drifting in smoke.
* `contour`: fine topographic lines over a seeded terrain, brightest at the summit.
* `eclipse`: a dark sphere with a thin crescent of light from a seeded direction.
* Every generated face is seeded, so the same seed always draws the same face.
*/
export type AvatarFace = 'initials' | 'glow' | 'contour' | 'eclipse';
export interface AvatarProps {
/** The person's name. Gives the initials, the seed and the accessible name. */
name?: string;
/** One or two letters. Defaults to the first letters of the first and last word of `name`. */
initials?: string;
/** An image URL. Covers the circle; the face is the fallback while it fails. */
src?: string;
face?: AvatarFace;
/** Seed for a generated face. Defaults to `name`, then `initials`. */
seed?: string;
/** Accessible name. Defaults to `name`, then the initials. */
label?: string;
/** Diameter in px. Tuned for 24 to 64. */
size?: number;
/** `live`: an accent dot with a slow pulse. `idle`: a still grey dot. */
status?: AvatarStatus;
accent?: Accent;
className?: string;
style?: CSSProperties;
}
export interface AvatarItem {
name?: string;
initials?: string;
src?: string;
face?: AvatarFace;
seed?: string;
label?: string;
status?: AvatarStatus;
}
export interface AvatarStackProps {
items: AvatarItem[];
/** Avatars shown before the rest collapse into a "+n" chip. */
max?: number;
size?: number;
/** How much each avatar covers the next at rest, 0..0.6 of its size. */
overlap?: number;
/** Hold the stack fanned open. */
fanned?: boolean;
/** Accessible name for the group. */
label?: string;
accent?: Accent;
className?: string;
}
/* ------------------------------------------------------------------------------------------------
* Geometry shared by the face, the status dot and the stack cut-outs.
* --------------------------------------------------------------------------------------------- */
/** Width of the gap cut round a status dot or between stacked avatars. */
const cutFor = (size: number) => (size < 32 ? 1.5 : size < 52 ? 2 : 2.5);
const dotFor = (size: number) => Math.round(clamp(size * 0.24, 6, 14));
export function initialsOf(name?: string, initials?: string): string {
if (initials) return initials.trim().slice(0, 2).toUpperCase();
const words = (name ?? '').trim().split(/\s+/).filter(Boolean);
if (!words.length) return '';
const first = words[0][0] ?? '';
const last = words.length > 1 ? (words[words.length - 1][0] ?? '') : '';
return (first + last).toUpperCase();
}
/* ------------------------------------------------------------------------------------------------
* Generated faces. All drawn in a 100-unit box, clipped to the circle by an SVG clipPath (and by
* the face's own overflow), in greys, bone and ink.
* --------------------------------------------------------------------------------------------- */
const BONE = '#e6e1d8';
const ASH = '#77736d';
const SILVER = '#c9ccd1';
const INKS = ['#0f0f0f', '#121212', '#141312', '#111213'];
const pick = <T,>(arr: readonly T[], r: number) => arr[Math.floor(r * arr.length) % arr.length];
/** A seeded irregular polygon, big enough that blurred it reads as a fold of light, not a blob. */
function shard(rnd: () => number, cx: number, cy: number, radius: number): string {
const n = 5 + Math.floor(rnd() * 2);
const start = rnd() * Math.PI * 2;
const pts: string[] = [];
for (let i = 0; i < n; i++) {
const a = start + (i / n) * Math.PI * 2 + (rnd() - 0.5) * 0.7;
const r = radius * (0.55 + rnd() * 0.6);
pts.push(`${(cx + Math.cos(a) * r).toFixed(1)} ${(cy + Math.sin(a) * r * (0.6 + rnd() * 0.5)).toFixed(1)}`);
}
return `M${pts.join('L')}Z`;
}
function GlowFace({ seed, id }: { seed: string; id: string }) {
const g = useMemo(() => {
const rnd = seeded(hashString(`glow:${seed}`));
// Big shapes centred off the circle, so only their soft edges cross it: folds of light, not blobs.
// The key light always comes from the upper half.
const ka = -Math.PI / 2 + (rnd() - 0.5) * 2.4;
const kd = 36 + rnd() * 12;
const fa = ka + Math.PI + (rnd() - 0.5) * 1.4;
const fd = 40 + rnd() * 12;
const sa = ka + (rnd() > 0.5 ? 1 : -1) * (1.2 + rnd() * 0.8);
return {
ink: pick(INKS, rnd()),
tone: rnd() > 0.5 ? BONE : SILVER,
key: shard(rnd, 50 + Math.cos(ka) * kd, 50 + Math.sin(ka) * kd, 40 + rnd() * 10),
keyO: 0.56 + rnd() * 0.2,
fill: shard(rnd, 50 + Math.cos(fa) * fd, 50 + Math.sin(fa) * fd, 40 + rnd() * 10),
fillO: 0.2 + rnd() * 0.16,
fold: shard(rnd, 50 + Math.cos(sa) * 22, 50 + Math.sin(sa) * 22, 22 + rnd() * 8),
};
}, [seed]);
return (
<svg className="em-avatar__art" viewBox="0 0 100 100" aria-hidden="true">
<defs>
<clipPath id={`${id}c`}>
<circle cx="50" cy="50" r="50" />
</clipPath>
<filter id={`${id}b`} x="-60" y="-60" width="220" height="220" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feGaussianBlur stdDeviation="8" />
</filter>
<filter id={`${id}s`} x="-60" y="-60" width="220" height="220" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feGaussianBlur stdDeviation="6" />
</filter>
</defs>
<g clipPath={`url(#${id}c)`}>
<rect width="100" height="100" fill={g.ink} />
<path d={g.fill} fill={ASH} opacity={g.fillO} filter={`url(#${id}b)`} />
<path d={g.key} fill={g.tone} opacity={g.keyO} filter={`url(#${id}b)`} />
<path d={g.fold} fill={g.ink} opacity={1} filter={`url(#${id}s)`} />
</g>
</svg>
);
}
/* Contour: a terrain of three seeded hills, traced with marching squares. */
const GRID = 44;
function contourPaths(seed: string, levels: number) {
const rnd = seeded(hashString(`contour:${seed}`));
const hills = Array.from({ length: 3 }, (_, i) => ({
x: 18 + rnd() * 64,
y: 18 + rnd() * 64,
s: 16 + rnd() * 20,
a: i === 0 ? 1 : (rnd() > 0.35 ? 1 : -1) * (0.35 + rnd() * 0.45),
}));
const tilt = { x: (rnd() - 0.5) * 0.006, y: (rnd() - 0.5) * 0.006 };
// A gentle warp keeps the lines from reading as perfect ellipses.
const warp = { a: 4 + rnd() * 5, p: rnd() * 6.28, q: rnd() * 6.28, f: 14 + rnd() * 8 };
const lo = -6;
const step = (112) / GRID;
const f = new Float32Array((GRID + 1) * (GRID + 1));
let min = Infinity;
let max = -Infinity;
for (let j = 0; j <= GRID; j++) {
for (let i = 0; i <= GRID; i++) {
const gx = lo + i * step;
const gy = lo + j * step;
const x = gx + warp.a * Math.sin(gy / warp.f + warp.p);
const y = gy + warp.a * Math.cos(gx / warp.f + warp.q);
let v = (x - 50) * tilt.x + (y - 50) * tilt.y;
for (const h of hills) v += h.a * Math.exp(-((x - h.x) ** 2 + (y - h.y) ** 2) / (2 * h.s * h.s));
f[j * (GRID + 1) + i] = v;
if (v < min) min = v;
if (v > max) max = v;
}
}
const at = (i: number, j: number) => (f[j * (GRID + 1) + i] - min) / (max - min || 1);
const out: { d: string; t: number }[] = [];
for (let k = 1; k <= levels; k++) {
const t = k / (levels + 1);
let d = '';
for (let j = 0; j < GRID; j++) {
for (let i = 0; i < GRID; i++) {
const a = at(i, j);
const b = at(i + 1, j);
const c = at(i + 1, j + 1);
const e = at(i, j + 1);
const idx = (a > t ? 8 : 0) | (b > t ? 4 : 0) | (c > t ? 2 : 0) | (e > t ? 1 : 0);
if (idx === 0 || idx === 15) continue;
const x0 = lo + i * step;
const y0 = lo + j * step;
const lerp = (p: number, q: number) => (t - p) / (q - p);
const top = () => [x0 + lerp(a, b) * step, y0];
const right = () => [x0 + step, y0 + lerp(b, c) * step];
const bottom = () => [x0 + lerp(e, c) * step, y0 + step];
const left = () => [x0, y0 + lerp(a, e) * step];
const seg = (p: number[], q: number[]) => {
d += `M${p[0].toFixed(2)} ${p[1].toFixed(2)}L${q[0].toFixed(2)} ${q[1].toFixed(2)}`;
};
switch (idx) {
case 1: case 14: seg(left(), bottom()); break;
case 2: case 13: seg(bottom(), right()); break;
case 3: case 12: seg(left(), right()); break;
case 4: case 11: seg(top(), right()); break;
case 6: case 9: seg(top(), bottom()); break;
case 7: case 8: seg(left(), top()); break;
case 5: seg(left(), top()); seg(bottom(), right()); break;
case 10: seg(top(), right()); seg(left(), bottom()); break;
}
}
}
if (d) out.push({ d, t });
}
return { paths: out, peak: hills[0] };
}
function ContourFace({ seed, size, id }: { seed: string; size: number; id: string }) {
// Fewer, wider-spaced lines when small, so 24px still reads as terrain and not as noise.
const levels = size <= 28 ? 3 : size <= 36 ? 4 : size <= 48 ? 5 : 7;
const c = useMemo(() => contourPaths(seed, levels), [seed, levels]);
const width = size <= 28 ? 0.85 : 0.75;
return (
<svg className="em-avatar__art" viewBox="0 0 100 100" aria-hidden="true">
<defs>
<clipPath id={`${id}c`}>
<circle cx="50" cy="50" r="50" />
</clipPath>
<radialGradient id={`${id}l`} gradientUnits="userSpaceOnUse" cx={c.peak.x} cy={c.peak.y} r="72">
<stop offset="0" stopColor="#2c2b29" />
<stop offset="0.6" stopColor="#161615" />
<stop offset="1" stopColor="#0f0f0f" />
</radialGradient>
{/* The lines catch light near the summit and fall off toward the far side. */}
<radialGradient id={`${id}f`} gradientUnits="userSpaceOnUse" cx={c.peak.x} cy={c.peak.y} r="80">
<stop offset="0" stopColor="#fff" stopOpacity="1" />
<stop offset="1" stopColor="#fff" stopOpacity="0.35" />
</radialGradient>
<mask id={`${id}m`} maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<rect width="100" height="100" fill={`url(#${id}f)`} />
</mask>
</defs>
<g clipPath={`url(#${id}c)`}>
<rect width="100" height="100" fill={`url(#${id}l)`} />
<g mask={`url(#${id}m)`}>
{c.paths.map((p, i) => (
<path
key={i}
d={p.d}
fill="none"
stroke={BONE}
strokeOpacity={0.2 + p.t * 0.5}
strokeWidth={width}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
))}
</g>
</g>
</svg>
);
}
/* Eclipse: a dark body in front of a light, leaving a crescent on the lit side. */
function EclipseFace({ seed, id }: { seed: string; id: string }) {
const e = useMemo(() => {
const rnd = seeded(hashString(`eclipse:${seed}`));
// Light from above, give or take 80°, so the crescent never sits at the bottom.
const a = -Math.PI / 2 + (rnd() - 0.5) * 2.8;
const d = 13 + rnd() * 10;
return {
a,
dx: -Math.cos(a) * d,
dy: -Math.sin(a) * d,
lx: 50 + Math.cos(a) * 42,
ly: 50 + Math.sin(a) * 42,
tone: pick([BONE, SILVER, '#d8cfc2'], rnd()),
ink: pick(INKS, rnd()),
};
}, [seed]);
return (
<svg className="em-avatar__art" viewBox="0 0 100 100" aria-hidden="true">
<defs>
<clipPath id={`${id}c`}>
<circle cx="50" cy="50" r="50" />
</clipPath>
<radialGradient id={`${id}h`} gradientUnits="userSpaceOnUse" cx={e.lx} cy={e.ly} r="70">
<stop offset="0" stopColor={e.tone} stopOpacity="1" />
<stop offset="0.3" stopColor={e.tone} stopOpacity="0.5" />
<stop offset="1" stopColor={e.tone} stopOpacity="0" />
</radialGradient>
<radialGradient id={`${id}d`} gradientUnits="userSpaceOnUse" cx={50 + e.dx * 0.4} cy={50 + e.dy * 0.4} r="56">
<stop offset="0" stopColor="#0c0c0c" />
<stop offset="0.75" stopColor={e.ink} />
<stop offset="1" stopColor="#222120" />
</radialGradient>
<filter id={`${id}b`} x="-50" y="-50" width="200" height="200" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feGaussianBlur stdDeviation="2.4" />
</filter>
</defs>
<g clipPath={`url(#${id}c)`}>
<rect width="100" height="100" fill="#121212" />
<rect width="100" height="100" fill={`url(#${id}h)`} />
<circle cx={50 + e.dx} cy={50 + e.dy} r="50" fill={`url(#${id}d)`} filter={`url(#${id}b)`} />
</g>
</svg>
);
}
/* Initials: a filled graphite surface, seeded among a few close greys so a stack is not one tone. */
const SURFACES = [
['#2a2a2a', '#171717'],
['#2b2926', '#181614'],
['#272a2d', '#151719'],
['#242424', '#141414'],
];
function Face({
initials,
src,
face,
seed,
size,
}: {
initials: string;
src?: string;
face: AvatarFace;
seed: string;
size: number;
}) {
const [broken, setBroken] = useState(false);
useEffect(() => setBroken(false), [src]);
const id = `emav${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
if (src && !broken) return <img className="em-avatar__img" src={src} alt="" draggable={false} onError={() => setBroken(true)} />;
if (face === 'glow') return <GlowFace seed={seed} id={id} />;
if (face === 'contour') return <ContourFace seed={seed} size={size} id={id} />;
if (face === 'eclipse') return <EclipseFace seed={seed} id={id} />;
const [top, bottom] = SURFACES[hashString(seed) % SURFACES.length];
// Small avatars need relatively larger, looser letters; large ones tighten up.
const t = clamp((64 - size) / 40, 0, 1);
return (
<span
className="em-avatar__initials"
style={{
['--em-av-top' as string]: top,
['--em-av-bottom' as string]: bottom,
fontSize: Math.max(9, size * (0.36 + 0.06 * t)),
letterSpacing: `${(-0.035 + 0.03 * t).toFixed(3)}em`,
}}
>
{initials}
</span>
);
}
/* ------------------------------------------------------------------------------------------------
* Avatar
* --------------------------------------------------------------------------------------------- */
export function Avatar({
name,
initials: initialsProp,
src,
face = 'initials',
seed,
label,
size = 40,
status = 'none',
accent: accentProp,
className,
style,
}: AvatarProps) {
const accent = resolveAccent(accentProp);
const initials = initialsOf(name, initialsProp);
const who = label ?? (name || initials || 'Avatar');
const dot = dotFor(size);
return (
<span
className={`ember em-avatar${className ? ` ${className}` : ''}`}
data-accent={accent}
data-status={status}
data-face={src ? 'image' : face}
role="img"
aria-label={status === 'none' ? who : `${who}, ${status}`}
style={{
...style,
width: size,
height: size,
['--em-av-size' as string]: `${size}px`,
['--em-av-dot' as string]: `${dot}px`,
['--em-av-cut' as string]: `${cutFor(size)}px`,
// The dot's centre sits on the rim at 45°.
['--em-av-dot-at' as string]: `${(size * (0.5 + Math.SQRT1_2 / 2) - dot / 2).toFixed(2)}px`,
}}
>
<span className="em-avatar__face">
<Face initials={initials} src={src} face={face} seed={seed ?? (name || initials)} size={size} />
</span>
{status !== 'none' ? <span className="em-avatar__dot" aria-hidden="true" /> : null}
</span>
);
}
/* ------------------------------------------------------------------------------------------------
* AvatarStack: overlapped at rest, fanned apart on hover or focus.
* --------------------------------------------------------------------------------------------- */
const FAN_GAP = 6;
/** Each avatar a little softer than the one before, so the fan opens as a ripple. */
const springFor = (i: number): SpringConfig => ({ stiffness: Math.max(170, 320 - i * 22), damping: 24 });
export function AvatarStack({
items,
max = 5,
size = 40,
overlap = 0.32,
fanned = false,
label,
accent: accentProp,
className,
}: AvatarStackProps) {
const accent = resolveAccent(accentProp);
const shown = items.slice(0, Math.max(1, max));
const rest = Math.max(0, items.length - shown.length);
const count = shown.length + (rest ? 1 : 0);
const ov = clamp(overlap, 0, 0.6);
const rootRef = useRef<HTMLDivElement>(null);
const els = useRef<(HTMLSpanElement | null)[]>([]);
const capRef = useRef<HTMLSpanElement>(null);
const [say, announce] = useAnnouncer();
const sim = useRef({
xs: [] as Spring[],
lift: [] as Spring[],
cap: new Spring(0, { stiffness: 420, damping: 34 }),
open: false,
hot: -1,
hover: false,
focus: false,
touchOpen: false,
avail: 0,
});
const restX = (i: number) => i * size * (1 - ov);
// The fan opens as wide as its container allows, never past it.
const fanStep = () => {
const avail = sim.current.avail;
const want = size + FAN_GAP;
if (!avail || count < 2) return want;
return Math.max(size * (1 - ov), Math.min(want, (avail - size) / (count - 1)));
};
const fanX = (i: number) => i * fanStep();
{
const s = sim.current;
while (s.xs.length < count) {
const i = s.xs.length;
s.xs.push(new Spring(restX(i), springFor(i)));
s.lift.push(new Spring(0, { stiffness: 520, damping: 30 }));
}
s.xs.length = count;
s.lift.length = count;
}
const apply = () => {
const s = sim.current;
let right = 0;
s.xs.forEach((x, i) => {
const el = els.current[i];
if (!el) return;
const ty = s.lift[i].value;
el.style.transform = `translate3d(${snapPx(x.value)}px, ${ty.toFixed(2)}px, 0)`;
right = Math.max(right, x.value + size);
});
if (rootRef.current) rootRef.current.style.width = `${Math.max(size, right).toFixed(2)}px`;
const cap = capRef.current;
if (cap) cap.style.transform = `translate3d(${(s.cap.value + size / 2).toFixed(2)}px, 0, 0) translateX(-50%)`;
};
const loop = useLoop(
(dt) => {
const s = sim.current;
let moving = false;
for (const x of s.xs) if (x.step(dt)) moving = true;
for (const l of s.lift) if (l.step(dt)) moving = true;
if (s.cap.step(dt)) moving = true;
apply();
return moving;
},
(reduced) => {
if (!reduced) return;
const s = sim.current;
for (const x of s.xs) x.set(x.target);
for (const l of s.lift) l.set(l.target);
s.cap.set(s.cap.target);
},
);
const labelOf = (it: AvatarItem) => it.label ?? (it.name || initialsOf(it.name, it.initials) || 'Avatar');
const retarget = () => {
const s = sim.current;
const open = fanned || s.hover || s.focus || s.touchOpen;
s.open = open;
s.xs.forEach((x, i) => {
x.target = open ? fanX(i) : restX(i);
s.lift[i].target = open && i === s.hot ? -2 : 0;
if (loop.reduced) (x.set(x.target), s.lift[i].set(s.lift[i].target));
});
const hot = s.hot >= 0 && open ? s.hot : -1;
if (hot >= 0) {
const tx = fanX(hot);
const wasHidden = !capRef.current?.classList.contains('is-on');
s.cap.target = tx;
if (wasHidden || loop.reduced) s.cap.set(tx);
}
const cap = capRef.current;
if (cap) {
cap.classList.toggle('is-on', hot >= 0);
if (hot >= 0) cap.textContent = hot < shown.length ? labelOf(shown[hot]) : `${rest} more`;
}
rootRef.current?.setAttribute('data-open', open ? 'true' : 'false');
apply();
loop.wake();
};
useLayoutEffect(() => {
retarget();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fanned, size, ov, count]);
// Track the room the stack's container offers, so a fan never overflows it.
useLayoutEffect(() => {
const host = rootRef.current?.parentElement;
if (!host) return;
const measure = () => {
const w = host.clientWidth;
if (Math.abs(w - sim.current.avail) < 0.5) return;
sim.current.avail = w;
retarget();
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(host);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [size, ov, count]);
const indexAt = (clientX: number): number => {
const root = rootRef.current;
if (!root) return -1;
const x = clientX - root.getBoundingClientRect().left;
const s = sim.current;
for (let i = 0; i < s.xs.length; i++) {
const a = s.xs[i].value;
if (x >= a && x < a + size + (sim.current.open ? FAN_GAP : 0)) return i;
}
return -1;
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch') return;
const s = sim.current;
const i = indexAt(e.clientX);
if (!s.hover || i !== s.hot) {
s.hover = true;
s.hot = i;
retarget();
}
};
const onPointerLeave = (e: PointerEvent<HTMLDivElement>) => {
if (e.pointerType === 'touch') return;
const s = sim.current;
s.hover = false;
if (!s.focus) s.hot = -1;
retarget();
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.pointerType !== 'touch') return;
const s = sim.current;
s.touchOpen = !s.touchOpen;
s.hot = s.touchOpen ? indexAt(e.clientX) : -1;
retarget();
};
// A tap outside closes a stack opened by touch.
useEffect(() => {
const onDown = (e: globalThis.PointerEvent) => {
const s = sim.current;
if (!s.touchOpen || rootRef.current?.contains(e.target as Node)) return;
s.touchOpen = false;
s.hot = -1;
retarget();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
const s = sim.current;
s.focus = isFocusVisible(e.currentTarget);
if (s.focus && s.hot < 0) s.hot = 0;
retarget();
if (s.focus) announce(s.hot < shown.length ? labelOf(shown[s.hot]) : `${rest} more`);
};
const onBlur = () => {
const s = sim.current;
s.focus = false;
if (!s.hover) s.hot = -1;
retarget();
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const s = sim.current;
let next: number | null = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(count - 1, s.hot + 1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, s.hot - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = count - 1;
if (next === null) return;
e.preventDefault();
s.focus = true;
s.hot = next;
retarget();
announce(next < shown.length ? labelOf(shown[next]) : `${rest} more`);
};
const more: ReactElement | null = rest ? (
<span
className="em-avatar em-avatar--more em-mono"
role="img"
aria-label={`${rest} more`}
style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.3)) }}
>
+{rest}
</span>
) : null;
return (
<div
className={`ember em-avatar-stack${className ? ` ${className}` : ''}`}
data-accent={accent}
style={{ ['--em-av-size' as string]: `${size}px`, ['--em-av-gap' as string]: `${cutFor(size)}px` }}
>
<div
ref={rootRef}
className="em-avatar-stack__row"
role="group"
tabIndex={0}
aria-label={label ?? `${items.length} avatars`}
style={{ height: size }}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onPointerDown={onPointerDown}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
{shown.map((it, i) => (
<span
key={i}
ref={(el) => {
els.current[i] = el;
}}
className="em-avatar-stack__slot"
style={{ zIndex: count - i }}
>
<Avatar {...it} size={size} accent={accent} />
</span>
))}
{more ? (
<span
ref={(el) => {
els.current[shown.length] = el;
}}
className="em-avatar-stack__slot"
style={{ zIndex: 0 }}
>
{more}
</span>
) : null}
<span ref={capRef} className="em-avatar-stack__cap em-mono" aria-hidden="true" />
</div>
<span className="em-sr" aria-live="polite">
{say}
</span>
</div>
);
}
export default Avatar;
Customize
012345678900123456789048
live
idle
none
012345678907
012345678905
Status is a small dot cut out of the rim: live is the accent with a slow pulse, idle is a still grey. Stacks overlap with a cut-out and fan apart on hover or focus as a ripple, with the rest counted in a mono +n.
Install
npx shadcn@latest add https://obsydeui.com/r/avatar.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Avatar } from '@/components/obsyde/ember/avatar/Avatar';
export function Example() {
return (
<Avatar />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/avatar/Avatar.tsxcomponentember/avatar/avatar.cssstylesember/avatar/AvatarDemo.tsxdemoember/basics-kit/kit.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
faceFace | 'mixed' | 'initials' | 'glow' | 'contour' | 'eclipse' | 'mixed' |
sizeSize | number | 48 |
statusStatus | 'live' | 'idle' | 'none' | 'live' |
countPeople | number | 7 |
maxMax shown | number | 5 |
fannedHeld open | boolean | false |