Avatar

Identifies a person or entity at a glance. Use it in lists, tables, comment threads, and

Display Avatar.jsx Avatar.d.ts Avatar.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Avatar is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Avatar } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Identifies a person or entity at a glance. Use it in lists, tables, comment threads, and account menus, anywhere a name alone reads slower than a face.

Rules

  • name is required even when src is set. It is the image alt text and the initials fallback. An avatar with no name is unlabelled to a screen reader.
  • Use xs and sm inside dense rows, md as the default, lg and xl for profile headers only.
  • status is presence, not role or state. Do not repurpose it as a notification dot; use Badge for that.
  • Square avatars are for organisations and workspaces. Circles are for people.

Tradeoffs

Initials collide often in large directories. If your data set has many shared initials, supply images or add a secondary identifier next to the avatar.

Props

The typed contract, from Avatar.d.ts.

import * as React from "react";

/** Person or entity thumbnail. Falls back to initials when no image is supplied. */
export interface AvatarProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Full name. Required — it is the accessible label and the initials source. */
  name: string;
  /** Image URL. When absent, initials render instead. */
  src?: string;
  /** @default "md" */
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** @default "circle" */
  shape?: "circle" | "square";
  /** Presence indicator in the lower-right corner. */
  status?: "online" | "busy" | "away" | "offline";
}

export declare function Avatar(props: AvatarProps): React.JSX.Element;

Tokens it reads

Every custom property Avatar.jsx resolves, read from the source: 16 semantic, 1 primitive, 1 undeclared. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
  • none Referenced by the source but declared by no tier, so it resolves to nothing.
TokenTierDeclared as
--dt-border-strongsemanticvar(--dt-color-neutral-400)
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-surface-basesemanticvar(--dt-color-white)
--dt-surface-dangersemanticvar(--dt-color-red-600)
--dt-surface-successsemanticvar(--dt-color-green-600)
--dt-surface-sunkensemanticvar(--dt-color-neutral-100)
--dt-surface-warningsemanticvar(--dt-color-amber-500)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-font-weight-mediumprimitive500
--dt-border-width-thicknonenot declared

Source

React only, no dependencies, every value a token.

import React from "react";

const SIZES = { xs: 20, sm: 24, md: 32, lg: 40, xl: 56 };
const TEXT = { xs: "var(--dt-text-label-sm-size)", sm: "var(--dt-text-label-sm-size)", md: "var(--dt-text-label-md-size)", lg: "var(--dt-text-label-lg-size)", xl: "var(--dt-text-heading-xs-size)" };

function initials(name) {
  if (!name) return "";
  return name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase();
}

export function Avatar({ name, src, size = "md", shape = "circle", status, style, ...rest }) {
  const px = SIZES[size] || SIZES.md;
  const statusColor = { online: "var(--dt-surface-success)", busy: "var(--dt-surface-danger)", away: "var(--dt-surface-warning)", offline: "var(--dt-border-strong)" }[status];
  return (
    <span style={{ position: "relative", display: "inline-flex", flex: "none", ...style }} {...rest}>
      <span
        aria-label={src ? undefined : name}
        role={src ? undefined : "img"}
        style={{
          width: px, height: px, borderRadius: shape === "circle" ? "var(--dt-radius-pill)" : "var(--dt-radius-control)",
          background: "var(--dt-surface-sunken)", color: "var(--dt-text-secondary)",
          display: "inline-flex", alignItems: "center", justifyContent: "center", overflow: "hidden",
          fontFamily: "var(--dt-text-label-md-family)", fontSize: TEXT[size], fontWeight: "var(--dt-font-weight-medium)",
          border: "var(--dt-border-width-default) solid var(--dt-border-subtle)", userSelect: "none",
        }}
      >
        {src ? <img src={src} alt={name || ""} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : initials(name)}
      </span>
      {status && (
        <span aria-label={status} role="img" style={{
          position: "absolute", right: -1, bottom: -1, width: Math.max(6, px * 0.28), height: Math.max(6, px * 0.28),
          borderRadius: "var(--dt-radius-pill)", background: statusColor,
          border: "var(--dt-border-width-thick, 2px) solid var(--dt-surface-base)",
        }} />
      )}
    </span>
  );
}

In context

Avatar also appears on the display reference cards, alongside the rest of its family.