diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md
index 963fb90ca1..2675489420 100644
--- a/frontend/packages/ui/AGENTS.md
+++ b/frontend/packages/ui/AGENTS.md
@@ -64,6 +64,7 @@ Components are organised to mirror the CLJS source tree
| `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` |
| `ds/product/cta.cljs` | `src/lib/product/Cta.tsx` |
| `ds/product/loader.cljs` | `src/lib/product/Loader.tsx` |
+| `ds/product/avatar.cljs` | `src/lib/product/Avatar.tsx` |
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |
diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts
index cb581766a9..ea2dcb6b2f 100644
--- a/frontend/packages/ui/src/index.ts
+++ b/frontend/packages/ui/src/index.ts
@@ -13,6 +13,12 @@ export { Cta } from "./lib/product/Cta";
export type { CtaProps } from "./lib/product/Cta";
export { Loader } from "./lib/product/Loader";
export type { LoaderProps, LoaderTip } from "./lib/product/Loader";
+export { Avatar } from "./lib/product/Avatar";
+export type {
+ AvatarProps,
+ AvatarProfile,
+ AvatarVariant,
+} from "./lib/product/Avatar";
export { Icon, iconIds } from "./lib/foundations/assets/Icon";
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg";
diff --git a/frontend/packages/ui/src/lib/product/Avatar.module.scss b/frontend/packages/ui/src/lib/product/Avatar.module.scss
new file mode 100644
index 0000000000..caa5017ca9
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Avatar.module.scss
@@ -0,0 +1,53 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../_ds/_sizes.scss" as *;
+@use "../_ds/_borders.scss" as *;
+
+.avatar {
+ border-radius: $br-circle;
+ overflow: hidden;
+ border: $b-1 solid var(--border-color, none);
+
+ &:hover,
+ &:focus {
+ --border-color: var(--color-accent-primary);
+ }
+}
+
+.avatar-small {
+ inline-size: $sz-24;
+ block-size: $sz-24;
+}
+
+.avatar-medium {
+ inline-size: $sz-32;
+ block-size: $sz-32;
+}
+
+.avatar-large {
+ inline-size: $sz-40;
+ block-size: $sz-40;
+}
+
+.avatar-image {
+ overflow: hidden;
+ border-radius: $br-circle;
+ background-color: var(--avatar-color);
+
+ img {
+ inline-size: 100%;
+ block-size: 100%;
+ object-fit: cover;
+ display: block;
+ }
+}
+
+.is-selected {
+ --border-color: var(--color-accent-primary);
+
+ padding: var(--sp-xxs);
+}
diff --git a/frontend/packages/ui/src/lib/product/Avatar.spec.tsx b/frontend/packages/ui/src/lib/product/Avatar.spec.tsx
new file mode 100644
index 0000000000..ef123d24a4
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Avatar.spec.tsx
@@ -0,0 +1,101 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Avatar } from "./Avatar";
+
+const profile = { fullname: "Ada Lovelace" };
+const profileWithPhoto = {
+ fullname: "Ada Lovelace",
+ photoUrl: "https://example.com/photo.jpg",
+};
+
+describe("Avatar", () => {
+ it("should render successfully", () => {
+ const { baseElement } = render();
+ expect(baseElement).toBeTruthy();
+ });
+
+ it("should render a div root by default", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.tagName.toLowerCase()).toBe("div");
+ });
+
+ it("should render with a custom tag", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.tagName.toLowerCase()).toBe("button");
+ });
+
+ it("should apply avatar class to root element", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "avatar",
+ );
+ });
+
+ it("should apply avatar-small class by default", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "avatar-small",
+ );
+ });
+
+ it("should apply avatar-medium class when variant='M'", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "avatar-medium",
+ );
+ });
+
+ it("should apply avatar-large class when variant='L'", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "avatar-large",
+ );
+ });
+
+ it("should apply is-selected class when selected=true", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "is-selected",
+ );
+ });
+
+ it("should set title to profile fullname on root element", () => {
+ const { container } = render();
+ expect(container.firstElementChild?.getAttribute("title")).toBe(
+ "Ada Lovelace",
+ );
+ });
+
+ it("should render img with alt text from fullname", () => {
+ const { container } = render();
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("alt")).toBe("Ada Lovelace");
+ });
+
+ it("should use photoUrl as img src when provided", () => {
+ const { container } = render();
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("src")).toBe("https://example.com/photo.jpg");
+ });
+
+ it("should generate a data URI src when no photoUrl provided", () => {
+ const { container } = render();
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("src")).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it("should forward className to the root element", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstElementChild?.getAttribute("class")).toContain(
+ "custom-cls",
+ );
+ });
+});
diff --git a/frontend/packages/ui/src/lib/product/Avatar.stories.tsx b/frontend/packages/ui/src/lib/product/Avatar.stories.tsx
new file mode 100644
index 0000000000..995940875d
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Avatar.stories.tsx
@@ -0,0 +1,61 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Avatar } from "./Avatar";
+
+const profile = {
+ fullname: "Ada Lovelace",
+ photoUrl: "/images/avatar-blue.jpg",
+};
+
+const profileNoPhoto = {
+ fullname: "Ada Lovelace",
+};
+
+const meta = {
+ title: "Product/Avatar",
+ component: Avatar,
+ args: {
+ profile,
+ variant: "S",
+ selected: false,
+ },
+ argTypes: {
+ variant: {
+ options: ["S", "M", "L"],
+ control: { type: "select" },
+ },
+ selected: { control: "boolean" },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const NoURL: Story = {
+ args: {
+ profile: profileNoPhoto,
+ },
+};
+
+export const Small: Story = {
+ args: { variant: "S" },
+};
+
+export const Medium: Story = {
+ args: { variant: "M" },
+};
+
+export const Large: Story = {
+ args: { variant: "L" },
+};
+
+export const Selected: Story = {
+ args: { selected: true },
+};
diff --git a/frontend/packages/ui/src/lib/product/Avatar.tsx b/frontend/packages/ui/src/lib/product/Avatar.tsx
new file mode 100644
index 0000000000..fa33cd45a1
--- /dev/null
+++ b/frontend/packages/ui/src/lib/product/Avatar.tsx
@@ -0,0 +1,106 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { type ElementType, memo, useMemo } from "react";
+import clsx from "clsx";
+import styles from "./Avatar.module.scss";
+
+// ---------------------------------------------------------------------------
+// Initials avatar generator (SVG-based, mirrors app.util.avatars/generate*)
+// ---------------------------------------------------------------------------
+
+const AVATAR_COLORS = [
+ "#5b57ff",
+ "#b1b1e5",
+ "#e55b5b",
+ "#e5a45b",
+ "#5be587",
+ "#5bd6e5",
+];
+
+function hashString(s: string): number {
+ let hash = 0;
+ for (let i = 0; i < s.length; i++) {
+ hash = (hash << 5) - hash + s.charCodeAt(i);
+ hash |= 0;
+ }
+ return Math.abs(hash);
+}
+
+function getInitials(name: string): string {
+ const parts = name.trim().toUpperCase().split(/\s+/);
+ if (parts.length === 0 || parts[0] === "") return "?";
+ if (parts.length === 1) return parts[0][0] ?? "?";
+ return (parts[0][0] ?? "") + (parts[1][0] ?? "");
+}
+
+function generateAvatarDataUri(name: string): string {
+ const initials = getInitials(name);
+ const color = AVATAR_COLORS[hashString(name) % AVATAR_COLORS.length];
+ const svg = ``;
+ return `data:image/svg+xml;base64,${btoa(svg)}`;
+}
+
+// ---------------------------------------------------------------------------
+// Profile type
+// ---------------------------------------------------------------------------
+
+export interface AvatarProfile {
+ fullname: string;
+ /** Pre-resolved photo URL. Takes precedence over photoId. */
+ photoUrl?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export type AvatarVariant = "S" | "M" | "L";
+
+export interface AvatarProps {
+ profile: AvatarProfile;
+ /** Size variant. Defaults to "S". */
+ variant?: AvatarVariant;
+ /** Whether the avatar shows a selected ring. */
+ selected?: boolean;
+ /** Override the root element tag. Defaults to "div". */
+ tag?: ElementType;
+ /** Additional CSS class names */
+ className?: string;
+}
+
+function AvatarInner({
+ profile,
+ variant = "S",
+ selected = false,
+ tag: Tag = "div",
+ className,
+}: AvatarProps) {
+ const src = useMemo(() => {
+ return profile.photoUrl ?? generateAvatarDataUri(profile.fullname);
+ }, [profile.photoUrl, profile.fullname]);
+
+ const rootClass = clsx(
+ styles.avatar,
+ {
+ [styles["avatar-small"]]: variant === "S",
+ [styles["avatar-medium"]]: variant === "M",
+ [styles["avatar-large"]]: variant === "L",
+ [styles["is-selected"]]: selected,
+ },
+ className,
+ );
+
+ return (
+
+
+

+
+
+ );
+}
+
+export const Avatar = memo(AvatarInner);