Skip to content

Avatar

Deterministic initials on a name-derived tint, in five sizes, with an optional indicator.

Datastarteravataridentityinitialsuser

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Avatar } from '@/components/ui/avatar'
import { DemoRow, DemoStage } from './_kit'

export default function AvatarDemo() {
  return (
    <DemoStage>
      <DemoRow
        label="Sizes"
        description="Initials are derived from the name; the tint is deterministic."
      >
        <Avatar name="Priya Raman" size="xs" />
        <Avatar name="Priya Raman" size="sm" />
        <Avatar name="Priya Raman" size="md" />
        <Avatar name="Priya Raman" size="lg" />
        <Avatar name="Priya Raman" size="xl" />
      </DemoRow>

      <DemoRow label="Distinct people, distinct tints">
        <Avatar name="Priya Raman" />
        <Avatar name="Tomas Lindqvist" />
        <Avatar name="Amara Osei" />
        <Avatar name="Jun Watanabe" />
        <Avatar name="Elena Rossi" />
        <Avatar name="Cass" />
      </DemoRow>

      <DemoRow label="Square" description="For organisations and projects rather than people.">
        <Avatar name="Acme Platform" shape="square" size="lg" />
        <Avatar name="Northwind Labs" shape="square" size="lg" />
      </DemoRow>

      <DemoRow label="With an indicator">
        <Avatar
          name="Priya Raman"
          size="lg"
          indicator={
            <span className="block size-3 rounded-full border-2 border-[var(--color-surface)] bg-success" />
          }
        />
        <Avatar
          name="Tomas Lindqvist"
          size="lg"
          indicator={
            <span className="block size-3 rounded-full border-2 border-[var(--color-surface)] bg-ink-subtle" />
          }
        />
      </DemoRow>
    </DemoStage>
  )
}

components/demos/avatar.tsx

import { Avatar } from '@/components/ui/avatar'
import { DemoRow, DemoStage } from './_kit'

export default function AvatarDemo() {
  return (
    <DemoStage>
      <DemoRow
        label="Sizes"
        description="Initials are derived from the name; the tint is deterministic."
      >
        <Avatar name="Priya Raman" size="xs" />
        <Avatar name="Priya Raman" size="sm" />
        <Avatar name="Priya Raman" size="md" />
        <Avatar name="Priya Raman" size="lg" />
        <Avatar name="Priya Raman" size="xl" />
      </DemoRow>

      <DemoRow label="Distinct people, distinct tints">
        <Avatar name="Priya Raman" />
        <Avatar name="Tomas Lindqvist" />
        <Avatar name="Amara Osei" />
        <Avatar name="Jun Watanabe" />
        <Avatar name="Elena Rossi" />
        <Avatar name="Cass" />
      </DemoRow>

      <DemoRow label="Square" description="For organisations and projects rather than people.">
        <Avatar name="Acme Platform" shape="square" size="lg" />
        <Avatar name="Northwind Labs" shape="square" size="lg" />
      </DemoRow>

      <DemoRow label="With an indicator">
        <Avatar
          name="Priya Raman"
          size="lg"
          indicator={
            <span className="block size-3 rounded-full border-2 border-[var(--color-surface)] bg-success" />
          }
        />
        <Avatar
          name="Tomas Lindqvist"
          size="lg"
          indicator={
            <span className="block size-3 rounded-full border-2 border-[var(--color-surface)] bg-ink-subtle" />
          }
        />
      </DemoRow>
    </DemoStage>
  )
}

components/ui/avatar.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'

/**
 * Avatar / AvatarGroup
 *
 * Foundry ships no photography, so avatars render deterministic initials on a
 * tinted surface. The tint is derived from the name's character codes, which
 * keeps the same person the same colour on every page without a colour field
 * in the data.
 *
 * A decorative avatar next to a visible name is `aria-hidden`; a standalone
 * one exposes the name as its label.
 */
export interface AvatarProps {
  name: string
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  /** Suppresses the accessible name when the name is already on screen. */
  decorative?: boolean
  /** Small badge anchored bottom-right, e.g. a presence dot. */
  indicator?: ReactNode
  shape?: 'circle' | 'square'
  className?: string
}

const sizes = {
  xs: 'size-5 text-2xs',
  sm: 'size-7 text-2xs',
  md: 'size-9 text-xs',
  lg: 'size-12 text-sm',
  xl: 'size-16 text-lg',
} as const

const tints = [
  'bg-accent-soft text-accent-soft-ink',
  'bg-success-soft text-success',
  'bg-warning-soft text-warning',
  'bg-info-soft text-info',
  'bg-danger-soft text-danger',
  'bg-surface-sunken text-ink-muted',
] as const

function tintFor(name: string): string {
  let hash = 0
  for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
  return tints[hash % tints.length] ?? tints[0]
}

export function Avatar({
  name,
  size = 'md',
  decorative = false,
  indicator,
  shape = 'circle',
  className,
}: AvatarProps) {
  return (
    <span className={cn('relative inline-flex shrink-0', className)}>
      <span
        role={decorative ? undefined : 'img'}
        aria-label={decorative ? undefined : name}
        aria-hidden={decorative || undefined}
        className={cn(
          'inline-flex items-center justify-center border border-line font-semibold select-none',
          shape === 'circle' ? 'rounded-full' : 'rounded-md',
          sizes[size],
          tintFor(name),
        )}
      >
        {toInitials(name)}
      </span>
      {indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
    </span>
  )
}

export interface AvatarGroupProps {
  names: string[]
  size?: AvatarProps['size']
  /** Names beyond this count collapse into a "+n" chip. */
  max?: number
  className?: string
  label?: string
}

export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
  const visible = names.slice(0, max)
  const overflow = names.length - visible.length

  return (
    <span
      className={cn('flex items-center', className)}
      role="group"
      aria-label={label ?? `${names.length} people`}
    >
      {visible.map((name) => (
        <span
          key={name}
          className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
        >
          <Avatar name={name} size={size} decorative />
        </span>
      ))}
      {overflow > 0 ? (
        <span
          className={cn(
            '-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
            sizes[size],
          )}
        >
          +{overflow}
        </span>
      ) : null}
      <span className="sr-only">{names.join(', ')}</span>
    </span>
  )
}

lib/format.ts

/** Small formatting helpers shared across catalogue and starter surfaces. */

export function titleCase(value: string): string {
  return value
    .split(/[-_\s]+/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ')
}

export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
  return `${count} ${count === 1 ? singular : plural}`
}

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  minimumFractionDigits: 2,
})

export function formatCurrency(cents: number): string {
  return currencyFormatter.format(cents / 100)
}

const compactFormatter = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  maximumFractionDigits: 1,
})

export function formatCompact(value: number): string {
  return compactFormatter.format(value)
}

/**
 * Dates in Foundry are authored as ISO date strings so that server and client
 * renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
 * same reason — no hydration drift from the visitor's locale or timezone.
 */
export function formatDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function formatShortDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return '?'
  if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
  return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}

export function slugify(value: string): string {
  return value
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[^\w\s-]/g, '')
    .trim()
    .replace(/[\s_]+/g, '-')
    .replace(/-+/g, '-')
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Foundry ships no photography, so avatars render initials. The tint is derived from the name’s character codes, which keeps the same person the same colour everywhere without a colour field in the data.

  • Set `decorative` when the name is already visible next to the avatar, so it is not announced twice.
  • Square avatars read as organisations and projects; circles read as people.
  • Initials handle single-word names and very long names without overflowing.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Five sizes
  • Circle and square
  • With presence indicator
  • Decorative

Accessibility

Naming
A standalone avatar is `role="img"` with the name as its label; a decorative one is hidden entirely.
Indicators
A presence dot needs adjacent text — colour alone does not convey status.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.