;
+
+/** Falls back to initials when no image is given, next to an avatar rendering a provided image. */
+export const Default: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+};
+
+/** All size variants rendered side by side. */
+export const Sizes: Story = {
+ render: (args) => (
+
+ {AVATAR_SIZE_VALUES.map((size) => (
+
+ ))}
+
+ ),
+};
+
+/** The default circular shape next to the rounded-square shape used for moderators. */
+export const Types: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+};
+
+/** Different names deriving different colors from the deterministic fallback, next to an explicit custom color override. */
+export const Colors: Story = {
+ render: (args) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+/** Not talking next to the pulsing ring shown while the user is speaking. */
+export const Talking: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+};
diff --git a/src/components/Avatar/component.tsx b/src/components/Avatar/component.tsx
new file mode 100644
index 0000000..fe2e161
--- /dev/null
+++ b/src/components/Avatar/component.tsx
@@ -0,0 +1,72 @@
+import React, { JSX, useEffect, useState } from 'react';
+import Tippy from '@tippyjs/react';
+import 'tippy.js/dist/tippy.css';
+import * as Styled from './styles';
+import { AvatarProps } from './types';
+import { DEFAULT_AVATAR_SIZE, AVATAR_FALLBACK_COLORS } from './constants';
+import { colorUserYou } from '../../stylesheets/palette';
+
+function getInitials(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return '';
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
+ return `${words[0][0]}${words[words.length - 1][0]}`.toUpperCase();
+}
+
+function getFallbackColor(name: string): string {
+ const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+ return AVATAR_FALLBACK_COLORS[hash % AVATAR_FALLBACK_COLORS.length];
+}
+
+/**
+ * A user avatar component.
+ *
+ * Renders the user's avatar image when available, falling back to their initials on a
+ * deterministically-colored background when no image is provided or it fails to load.
+ *
+ */
+function Avatar({
+ name,
+ avatarUrl,
+ color,
+ size = DEFAULT_AVATAR_SIZE,
+ isModerator = false,
+ isYou = false,
+ isTalking = false,
+ disableTooltip = false,
+}: AvatarProps): JSX.Element {
+ const [hasImageError, setHasImageError] = useState(false);
+ const resolvedColor = color || (isYou ? colorUserYou : getFallbackColor(name));
+
+ useEffect(() => {
+ setHasImageError(false);
+ }, [avatarUrl]);
+
+ const avatarElement = avatarUrl && !hasImageError ? (
+ setHasImageError(true)}
+ />
+ ) : (
+
+ {getInitials(name)}
+
+ );
+
+ if (disableTooltip) {
+ return avatarElement;
+ }
+
+ return (
+
+ {avatarElement}
+
+ );
+}
+
+export default Avatar;
diff --git a/src/components/Avatar/constants.ts b/src/components/Avatar/constants.ts
new file mode 100644
index 0000000..7e9e260
--- /dev/null
+++ b/src/components/Avatar/constants.ts
@@ -0,0 +1,15 @@
+export const AVATAR_SIZES = {
+ SMALL: 'small',
+ MEDIUM: 'medium',
+ LARGE: 'large',
+} as const;
+
+export const AVATAR_SIZE_VALUES = Object.values(AVATAR_SIZES);
+export const DEFAULT_AVATAR_SIZE = AVATAR_SIZES.MEDIUM;
+
+// Mirrors the palette akka-bbb-apps' ColorPicker assigns to users server-side (round-robin per
+// meeting), so a name-hash fallback here lands on the same colors real BBB users get.
+export const AVATAR_FALLBACK_COLORS = [
+ '#7b1fa2', '#6a1b9a', '#4a148c', '#5e35b1', '#512da8', '#4527a0', '#311b92',
+ '#3949ab', '#303f9f', '#283593', '#1a237e', '#1976d2', '#1565c0', '#0d47a1', '#0277bd', '#01579b',
+];
diff --git a/src/components/Avatar/index.ts b/src/components/Avatar/index.ts
new file mode 100644
index 0000000..3d4170d
--- /dev/null
+++ b/src/components/Avatar/index.ts
@@ -0,0 +1 @@
+export { default as BBBAvatar } from './component';
diff --git a/src/components/Avatar/styles.ts b/src/components/Avatar/styles.ts
new file mode 100644
index 0000000..71ab5f7
--- /dev/null
+++ b/src/components/Avatar/styles.ts
@@ -0,0 +1,48 @@
+import styled, { css, keyframes } from 'styled-components';
+import { colorWhite } from '../../stylesheets/palette';
+import { AVATAR_SIZES } from './constants';
+import { StyledAvatarProps } from './types';
+
+const DIMENSIONS = {
+ [AVATAR_SIZES.SMALL]: { dimension: '1.625rem', fontSize: '0.625rem', fontWeight: '600' },
+ [AVATAR_SIZES.MEDIUM]: { dimension: '3rem', fontSize: '1.125rem', fontWeight: '500' },
+ [AVATAR_SIZES.LARGE]: { dimension: '6rem', fontSize: '2.75rem', fontWeight: '400' },
+};
+
+// Mirrors BBB's talking indicator (a ring, in the user's own color, that spreads and fades out).
+const talkingPulse = (color: string) => keyframes`
+ 0% { box-shadow: 0 0 0 0 ${color}; }
+ 100% { box-shadow: 0 0 0 4px transparent; }
+`;
+
+const talkingStyles = css`
+ ${({ $isTalking, $color }) => $isTalking && css`
+ animation: ${talkingPulse($color)} 1s infinite ease-in;
+ `}
+`;
+
+export const AvatarInitials = styled.div`
+ width: ${({ $size }) => DIMENSIONS[$size].dimension};
+ height: ${({ $size }) => DIMENSIONS[$size].dimension};
+ border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')};
+ flex-shrink: 0;
+ background: ${({ $color }) => $color};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: ${({ $size }) => DIMENSIONS[$size].fontSize};
+ font-weight: ${({ $size }) => DIMENSIONS[$size].fontWeight};
+ color: ${colorWhite};
+ text-transform: uppercase;
+ ${talkingStyles}
+`;
+
+export const AvatarImage = styled.img`
+ width: ${({ $size }) => DIMENSIONS[$size].dimension};
+ height: ${({ $size }) => DIMENSIONS[$size].dimension};
+ border-radius: ${({ $isModerator }) => ($isModerator ? '20%' : '50%')};
+ flex-shrink: 0;
+ object-fit: cover;
+ border: 2px solid ${({ $color }) => $color};
+ ${talkingStyles}
+`;
diff --git a/src/components/Avatar/types.ts b/src/components/Avatar/types.ts
new file mode 100644
index 0000000..978b74f
--- /dev/null
+++ b/src/components/Avatar/types.ts
@@ -0,0 +1,36 @@
+import { AVATAR_SIZE_VALUES } from './constants';
+
+export type AvatarSize = typeof AVATAR_SIZE_VALUES[number];
+
+export interface StyledAvatarProps {
+ $size: AvatarSize;
+ $color: string;
+ $isModerator: boolean;
+ $isTalking: boolean;
+}
+
+export interface AvatarProps {
+ /** Full name of the user; used to render initials, to derive a deterministic fallback color, and as the tooltip content. */
+ name: string;
+
+ /** URL of the user's avatar image. Falls back to initials when omitted or if the image fails to load. */
+ avatarUrl?: string;
+
+ /** Background color behind the initials (and border color on the image). @default a color deterministically derived from `name`, or BBB's "you" color when `isYou` is set */
+ color?: string;
+
+ /** Size variant of the avatar. @default 'medium' */
+ size?: AvatarSize;
+
+ /** Renders a rounded-square shape instead of a circle, matching BBB's moderator avatar treatment. @default false */
+ isModerator?: boolean;
+
+ /** Marks this avatar as belonging to the current user, applying BBB's "you" color in place of the fallback/computed color. Ignored when `color` is set. @default false */
+ isYou?: boolean;
+
+ /** Shows a pulsing ring around the avatar, in its own color, matching BBB's talking indicator. @default false */
+ isTalking?: boolean;
+
+ /** Disables the tooltip that shows the full `name` on hover. @default false */
+ disableTooltip?: boolean;
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 230d706..ab4ba37 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,4 +1,5 @@
export { BBBAccordion } from './Accordion';
+export { BBBAvatar } from './Avatar';
export { BBButton } from './Button';
export { BBBCheckbox } from './Checkbox';
export { BBBDivider } from './Divider';
diff --git a/src/stylesheets/colors.ts b/src/stylesheets/colors.ts
index 84251c7..fc5341a 100644
--- a/src/stylesheets/colors.ts
+++ b/src/stylesheets/colors.ts
@@ -9,6 +9,7 @@ import {
colorHoverDark, colorHoverLight, colorHoverNeutral,
colorOverlay,
colorShadowDefault,
+ colorUserYou,
} from './palette';
export const colors = {
@@ -66,6 +67,9 @@ export const colors = {
shadow: {
default: colorShadowDefault,
},
+ user: {
+ you: colorUserYou,
+ },
} as const;
export type Colors = typeof colors;
diff --git a/src/stylesheets/palette.ts b/src/stylesheets/palette.ts
index 1867278..6b43f8e 100644
--- a/src/stylesheets/palette.ts
+++ b/src/stylesheets/palette.ts
@@ -66,3 +66,6 @@ export const colorOverlay = 'var(--color-overlay, rgba(0, 0, 0, 0.75))';
//Shadow colors
export const colorShadowDefault = 'var(--color-shadow-default, rgb(0 35 11 / 20%))';
+
+// User colors
+export const colorUserYou = 'var(--color-user-you, #19237C)';
diff --git a/webpack.config.babel.js b/webpack.config.babel.js
index 461ae7f..90d5502 100644
--- a/webpack.config.babel.js
+++ b/webpack.config.babel.js
@@ -3,6 +3,7 @@ import path from 'path';
export default {
entry: {
Accordion: './src/components/Accordion/index.ts',
+ Avatar: './src/components/Avatar/index.ts',
Button: './src/components/Button/index.ts',
Checkbox: './src/components/Checkbox/index.ts',
Divider: './src/components/Divider/index.ts',