Elements

Avatar

An avatar shows a person’s picture: a member of a team, the author of a comment, the account in a header. Until the picture has loaded, when it fails and when there is none, it shows a fallback: the initials of the person’s name, or the app’s own content (an icon). It owns the picture’s loading state, run natively from the image’s own load and error events with no JavaScript, and what screen readers get: an image labelled with the person’s name. The look is the app’s: an Avatar has no size, shape, colors or font of its own. An AvatarGroup shows the first few avatars of a row and an overflow that stands for the others (“+5”).

el-avatar.tsx
el-avatar.tsxLive
import { type JSX, mount, reduceMotion, signal, Text, View } from "nsh";
import { Avatar, AvatarGroup, Button } from "nsh/base";
import { AVATAR_BROKEN, avatarIdentity, avatarPortrait, button, buttonText, desktop, focusRing, hoverFill, Log, Panel, Segmented, Split, ui } from "./palette";

// The look is the demo's: initials on a tint of the person's hue, the
// picture round over them (the parts are rounded, not the avatar, so a
// presence dot over it is not clipped), a ring of the card around stacked
// avatars, a dot whose color fades as it changes, a picture that fades in.

type Presence = "online" | "away" | "busy";
const PRESENCE: Presence[] = ["online", "away", "busy"];
const dotColor = (p: Presence) => (p === "online" ? ui().up : p === "away" ? ui().warn : ui().danger);
const surface = () => ui().card.backgroundColor;

function Person(props: {
  name: string;
  side: number;
  src?: string;
  label?: string;
  ring?: boolean;
  onStatus?: (status: string) => void;
  children?: JSX.Element;
}) {
  const id = () => avatarIdentity(props.name);
  return (
    <Avatar
      src={props.src}
      name={props.name}
      accessibilityLabel={props.label}
      fallbackDelay={300}
      onStatusChange={({ status }) => props.onStatus?.(status)}
      style={{
        width: props.side,
        height: props.side,
        ...(props.ring ? { borderRadius: props.side / 2, borderWidth: 2, borderColor: surface(), backgroundColor: surface() } : {}),
      }}
      imageClass={`rounded-full overflow-hidden ${reduceMotion() ? "" : "transition-opacity duration-300"}`}
      fallbackClass={`rounded-full overflow-hidden items-center justify-center font-semibold text-[${Math.round(props.side / 2.8)}px] text-[${id().foreground}]`}
      fallbackStyle={{ backgroundColor: id().background, borderWidth: 1, borderColor: id().border }}
    >
      {props.children}
    </Avatar>
  );
}

const TEAM = ["Ada Lovelace", "Grace Hopper", "Alan Turing"];
const MEMBERS = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Katherine Johnson", "Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "Radia Perlman", "Ken Thompson"];

function App() {
  const presence = signal<Presence[]>(["online", "busy", "away"]);
  const members = signal(8);
  const limit = signal(3);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const side = desktop ? 36 : 44;
  const more = () => Math.max(0, members() - limit());
  const label = { fontSize: desktop ? 13 : 15, color: ui().text } as const;
  const detail = { fontSize: 12, color: ui().muted } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>avatar</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a member to change their presence" : "Tap a member to change their presence"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="AvatarGroup · limit">
              <Segmented options={[2, 3, 4]} value={limit()} onChange={(v) => (limit.set(v), log(`limit ${v}`))} />
              <View style={{ flexDirection: "row", gap: 8, marginTop: 6 }}>
                <Button onPress={() => (members.set(Math.min(MEMBERS.length, members() + 1)), log(`members ${members()}`))} disabled={members() >= MEMBERS.length} {...button("secondary")}>
                  <Text style={buttonText()}>Add</Text>
                </Button>
                <Button onPress={() => (members.set(Math.max(1, members() - 1)), log(`members ${members()}`))} disabled={members() <= 1} {...button("ghost")}>
                  <Text style={buttonText("ghost")}>Remove</Text>
                </Button>
              </View>
            </Panel>
            <Log title="onStatusChange" lines={events()} minHeight={110} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 12 : 16, gap: 14 }}>
          <View>
            {TEAM.map((name, i) => (
              <Button
                onPress={() => {
                  const next = PRESENCE[(PRESENCE.indexOf(presence()[i]) + 1) % PRESENCE.length];
                  presence.set(presence().map((p, j) => (j === i ? next : p)));
                  log(`${name}: ${next}`);
                }}
                class="-mx-2 flex-row items-center gap-3 rounded-lg px-2 py-2 active:opacity-70"
                hoverStyle={hoverFill()}
                focusStyle={focusRing()}
              >
                <Person name={name} side={side} src={i === 2 ? undefined : avatarPortrait(name)} label={`${name}, ${presence()[i]}`} onStatus={(s) => log(`${name}: ${s}`)}>
                  <View
                    class={reduceMotion() ? "" : "transition-colors duration-200"}
                    style={{ position: "absolute", right: -1, bottom: -1, width: 11, height: 11, borderRadius: 6, borderWidth: 2, borderColor: surface(), backgroundColor: dotColor(presence()[i]) }}
                  />
                </Person>
                <View style={{ flex: 1, gap: 2 }}>
                  <Text style={{ ...label, fontWeight: "500" }}>{name}</Text>
                  <Text style={detail}>{presence()[i]}</Text>
                </View>
              </Button>
            ))}
          </View>
          <View style={{ height: 1, backgroundColor: ui().line }} />
          <View class="flex-row items-center gap-3">
            <AvatarGroup
              limit={limit()}
              accessibilityLabel={`${members()} members`}
              class="flex-row items-center"
              overflowClass="items-center justify-center rounded-full"
              overflowStyle={{ ...ui().raised, width: side, height: side, marginLeft: -side / 4, borderWidth: 2, borderColor: surface() }}
              overflowAccessibilityLabel={`${more()} more people`}
              overflow={<Text style={{ fontSize: side / 3.2, fontWeight: "600", color: ui().muted, fontVariant: ["tabular-nums"] }}>{`+${more()}`}</Text>}
            >
              <Each of={MEMBERS.slice(0, members())}>
                {(name, i) => (
                  <View style={{ marginLeft: i() === 0 ? 0 : -side / 4 }}>
                    <Person name={name()} side={side} ring src={i() % 3 === 2 ? undefined : avatarPortrait(name())} />
                  </View>
                )}
              </Each>
            </AvatarGroup>
            <Text style={detail}>{`${members()} members`}</Text>
          </View>
          <View style={{ height: 1, backgroundColor: ui().line }} />
          <View class="flex-row items-start gap-3">
            <Person name="Barbara Liskov" side={desktop ? 28 : 32} src={AVATAR_BROKEN} label="" onStatus={(s) => log(`Barbara Liskov: ${s}`)} />
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={detail}>
                <Text style={{ fontWeight: "600", color: ui().text }}>Barbara Liskov</Text>
                {"  40m · her picture failed to load"}
              </Text>
              <Text style={label}>Same as before within a millisecond.</Text>
            </View>
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{avatar, theme};
use nsh_core::prelude::*;

fn author(name: &'static str, picture: &'static str) -> impl IntoElement {
    avatar()
        .src(picture)
        .name(name)
        .size(px(40.0))
        .image_style(|s| s.rounded_full())
        .fallback_style(|s| {
            s.rounded_full()
                .items_center()
                .justify_center()
                .bg(move || theme().muted)
                .text_color(move || theme().muted_foreground)
                .text_sm()
        })
}

Avatar and AvatarGroup come from nsh/base, the nsh-base crate in Rust. In Rust, avatar() and avatar_group() make them with a setter per prop (.src(..), .fallback_delay(..)), the typed style methods for the avatar itself and .child(..) for what sits over the picture.

#Props

PropRustTypeDefaultDescription
src.src(..)string | ImageAssetnoneThe picture, as <Image>’s src takes it: a URL, an asset:/ or data: URI, a blob: URL (a photo the user picked: URL.createObjectURL(file)), or an imported image. It shows once it has loaded; an empty string is no picture.
name.name(..)stringnoneThe person’s name: what screen readers say, and the initials the fallback shows when the app passes no fallback.
accessibilityLabel.accessibility_label(..)stringnameWhat screen readers say, over name. "" makes the avatar decorative, hidden from screen readers.
fallbackDelay.fallback_delay(..)number (ms)0How long the fallback waits while the picture loads, so a picture that loads quickly shows without the fallback flashing first.
imageClass, imageStyle.image_style(|s| ..)string, StyleProp<ViewStyle>noneThe picture: its fit, radius and motion.
fallbackClass, fallbackStyle.fallback_style(|s| ..)string, StyleProp<ViewStyle>noneThe fallback: its background, text color and size, and how it aligns its content.
fallback.fallback(..)JSX.Elementthe initialsThe fallback’s content in place of the initials: an icon, a placeholder for someone without a name.
children.child(..)JSX.ElementnoneWhat shows over the picture: a presence dot, a badge.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe avatar itself: its size and shape.
EventRustPayloadDescription
onStatusChange.on_status_change(..){ status: AvatarStatus }The picture’s status changed: "idle" (no src), "loading", "loaded" or "error".

#Parts

PartInsideStyled with
The avatarclass, style
The fallbackthe avatar, first, as large as it; there while the picture loads (after fallbackDelay), fails or is missingfallbackClass, fallbackStyle; its content the initials or fallback
The picturethe avatar, over the fallback, filling itimageClass, imageStyle
The app’s contentthe avatar, lastits own elements (children)

Give the avatar its size (class="size-10"): the fallback and the picture fill it, so the fallback leaving lays nothing out again. The picture is laid out from the start, transparent, and turns opaque once it has loaded; the fallback then leaves. A transition-opacity in imageClass fades it in (reduced motion shows it at once). Round the picture and the fallback (rounded-full on each) rather than clipping the avatar, so a presence dot placed at its edge (absolute bottom-0 right-0) stays whole.

The initials are the first letters of the name’s first two words, in upper case (“Ada Lovelace” is “AL”); a single word gives its first two letters (“huacnlee” is “HU”), and a name in a script without letter case its first character (“王小明” is “王”).

#Groups

import { Text } from "nsh";
import { Avatar, AvatarGroup } from "nsh/base";

<AvatarGroup
  limit={3}
  total={members.length}
  accessibilityLabel={`${members.length} members`}
  class="flex-row"
  overflowClass="-ml-2 size-8 items-center justify-center rounded-full bg-muted ring-2 ring-background"
  overflowAccessibilityLabel={`${members.length - 3} more people`}
  overflow={<Text class="text-xs">{`+${members.length - 3}`}</Text>}
>
  <Each of={members.slice(0, 3)} key={(m) => m.id}>
    {(m, i) => <Avatar src={m().picture} name={m().name} class={i() > 0 ? "-ml-2 size-8" : "size-8"} />}
  </Each>
</AvatarGroup>
PropRustTypeDefaultDescription
limit.limit(..)number3At most this many of its avatars show, the first ones; the others make no views.
total.total(..)numberthe number of childrenHow many people the group stands for, when the app passes only the first of them.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say of the group.
overflowClass, overflowStyle.overflow_style(|s| ..)string, StyleProp<ViewStyle>noneThe overflow part, after the avatars shown.
overflow.overflow(..)JSX.ElementnoneWhat stands for the people not shown (“+5”, ”⋯”), there while more than the avatars shown are in the group.
overflowAccessibilityLabel.overflow_accessibility_label(..)stringnoneWhat screen readers say of the overflow in place of its content: “5 more people” for a “+5”.
children.child(..)JSX.ElementnoneThe avatars, in order.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe row: its direction (flex-row) and spacing.

The overlap, the rings and the overflow’s shape are the app’s: a negative margin on every avatar after the first (-ml-2) stacks them, and a ring of the background’s color separates them. For a long list, pass the first limit people and total: only the avatars shown are made.

#Accessibility

  • An avatar is one image for screen readers, labelled with accessibilityLabel or name: the name is said once. The fallback and the picture are hidden from them, so the initials are not read apart. An avatar with neither, or with accessibilityLabel="", is decorative: hidden from screen readers, as an avatar beside the name it shows should be.
  • A group is a group, labelled with its accessibilityLabel (“Ada, Grace and 5 others”); the avatars shown are images inside it. The overflow is read as its content (“+5”), or as overflowAccessibilityLabel (“5 more people”) when the app gives one: NSH writes no words of its own.
  • Neither takes presses or keyboard focus: put an avatar in a Button to make it one.

See Accessibility for labels, roles and announcements in general.

#Platform notes

  • Every platform loads the picture with its own image loader and caches (see Image); its load and error events change the avatar’s state in Rust, so a loading or failed picture changes the picture and the fallback and nothing else.
  • The picture starts loading once it has a frame, so it stays laid out while transparent.