nucleusUI
All components

NucleusAvatar

Data displayNew

An avatar with graceful initials fallback on a stable pastel background, plus an overlapping NucleusAvatarGroup with an overflow chip.

Preview

RMTom BrekkeJP
RMTBJP+2

Installation

  1. 1shadcn/ui initialized (`npx shadcn@latest init`)
  2. 2Tailwind CSS v4 configured with the nucleusUI theme tokens
npm install lucide-react clsx tailwind-merge

Add to your globals.css

The nucleusUI accent lives in these CSS variables. Without them the component falls back to the default shadcn zinc theme.

app/globals.css
:root {
  --primary: oklch(0.54 0.24 291);
  --primary-foreground: oklch(0.985 0 0);
  --ring: oklch(0.54 0.24 291);
  --accent: oklch(0.96 0.03 291);
  --accent-foreground: oklch(0.4 0.19 291);
}

.dark {
  --primary: oklch(0.68 0.21 291);
  --primary-foreground: oklch(0.145 0 0);
  --ring: oklch(0.68 0.21 291);
  --accent: oklch(0.26 0.06 291);
  --accent-foreground: oklch(0.87 0.09 291);
}

Usage

tsx
import {
  NucleusAvatar,
  NucleusAvatarGroup,
} from "@/components/nucleus/nucleus-avatar";

export function Example() {
  return (
    <NucleusAvatarGroup max={3}>
      <NucleusAvatar name="Rania Moussa" />
      <NucleusAvatar name="Tom Brekke" src="/avatars/tom.jpg" />
      <NucleusAvatar name="June Park" size="md" ring />
      <NucleusAvatar name="Ada Figueroa" />
    </NucleusAvatarGroup>
  );
}

Source

Copy this file into components/nucleus/nucleus-avatar.tsx in your project, it's yours to own and modify.

components/nucleus/nucleus-avatar.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface NucleusAvatarProps extends React.ComponentProps<"span"> {
  /** Image URL. Falls back to initials when missing or broken. */
  src?: string;
  /** Alt text for the image (ignored when rendering initials). */
  alt?: string;
  /** Name used to derive initials (first letters of the first two words). */
  name: string;
  /** Visual size. */
  size?: "sm" | "md" | "lg";
  /** Adds a 2px violet ring. */
  ring?: boolean;
}

const sizeClasses = {
  sm: "size-8 text-xs",
  md: "size-10 text-sm",
  lg: "size-12 text-base",
} as const;

/** Deterministic fallback hue from the name, so each person keeps their color. */
function hueFromName(name: string) {
  let hash = 0;
  for (let i = 0; i < name.length; i++) {
    hash = (hash * 31 + name.charCodeAt(i)) % 360;
  }
  return hash;
}

function initialsOf(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((w) => w[0]!.toUpperCase())
    .join("");
}

/**
 * Avatar with graceful fallback: shows initials on a stable pastel
 * background when no image is provided (or the image fails to load).
 */
export function NucleusAvatar({
  src,
  alt,
  name,
  size = "md",
  ring = false,
  className,
  ...props
}: NucleusAvatarProps) {
  const [broken, setBroken] = React.useState(false);
  const showImage = Boolean(src) && !broken;
  const hue = hueFromName(name);

  return (
    <span
      data-slot="nucleus-avatar"
      className={cn(
        "relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full font-semibold text-foreground",
        sizeClasses[size],
        ring && "ring-2 ring-ring/60",
        className
      )}
      style={
        showImage
          ? undefined
          : {
              backgroundColor: `oklch(0.85 0.09 ${hue})`,
              color: `oklch(0.35 0.12 ${hue})`,
            }
      }
      {...props}
    >
      {showImage ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          src={src}
          alt={alt ?? name}
          onError={() => setBroken(true)}
          className="size-full object-cover"
        />
      ) : (
        initialsOf(name)
      )}
    </span>
  );
}

/** Horizontally overlapping row of avatars, capped with a "+n" chip. */
export function NucleusAvatarGroup({
  max = 4,
  className,
  children,
  ...rest
}: React.ComponentProps<"div"> & {
  /** Visible avatars before the overflow chip appears. */
  max?: number;
  className?: string;
}) {
  const list = React.Children.toArray(children) as React.ReactElement[];
  const visible = list.slice(0, max);
  const overflow = list.length - visible.length;

  return (
    <div
      data-slot="nucleus-avatar-group"
      className={cn("flex -space-x-2.5", className)}
      {...rest}
    >
      {visible}
      {overflow > 0 && (
        <span
          className={cn(
            "inline-flex items-center justify-center rounded-full border border-border bg-muted font-semibold text-foreground",
            sizeClasses.md
          )}
        >
          +{overflow}
        </span>
      )}
    </div>
  );
}

Props

PropTypeDescription
namestringName used to derive initials and the fallback hue.
srcstringImage URL. Falls back to initials when missing or broken.
altstringAlt text for the image (ignored when rendering initials).
size"sm" | "md" | "lg"Visual size of the avatar.
ringbooleanAdds a 2px violet ring.
maxnumberVisible avatars in NucleusAvatarGroup before the overflow chip.