diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
index 5c288098a503..657d43306627 100644
--- a/src/components/Avatar.tsx
+++ b/src/components/Avatar.tsx
@@ -139,4 +139,5 @@ function Avatar({
Avatar.displayName = 'Avatar';
+export type {AvatarProps};
export default Avatar;
diff --git a/src/components/AvatarSelector.tsx b/src/components/AvatarSelector.tsx
new file mode 100644
index 000000000000..bf059e0e197f
--- /dev/null
+++ b/src/components/AvatarSelector.tsx
@@ -0,0 +1,81 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {ALL_CUSTOM_AVATARS} from '@libs/Avatars/CustomAvatarCatalog';
+import {CUSTOM_AVATAR_CATALOG} from '@libs/Avatars/CustomAvatarCatalog';
+import type {AvatarSizeName} from '@styles/utils';
+import CONST from '@src/CONST';
+import Avatar from './Avatar';
+import {PressableWithFeedback} from './Pressable';
+import Text from './Text';
+
+type AvatarSelectorProps = {
+ /** Currently selected avatar ID */
+ selectedID?: keyof typeof ALL_CUSTOM_AVATARS;
+
+ /** Called when an avatar is selected */
+ onSelect: (id: keyof typeof ALL_CUSTOM_AVATARS) => void;
+
+ /** Optional: size of avatars in grid */
+ size?: AvatarSizeName;
+
+ /** Optional label to display above the grid */
+ label?: string;
+};
+
+/**
+ * AvatarSelector — renders a grid of selectable avatars.
+ * Note: This component should be placed inside a ScrollView.
+ */
+function AvatarSelector({selectedID, onSelect, label, size = CONST.AVATAR_SIZE.MEDIUM}: AvatarSelectorProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const [selected, setSelected] = useState(selectedID);
+
+ const handleSelect = (id: keyof typeof ALL_CUSTOM_AVATARS) => {
+ setSelected(id);
+ onSelect(id);
+ };
+
+ return (
+ <>
+ {!!label && (
+
+ {label}
+
+ )}
+
+ {CUSTOM_AVATAR_CATALOG.map(({id, local}) => {
+ const isSelected = selected === id;
+
+ return (
+ handleSelect(id)}
+ style={[styles.avatarSelectorWrapper, isSelected && {borderColor: theme.success, borderWidth: 2}]}
+ >
+
+
+ );
+ })}
+
+ >
+ );
+}
+
+AvatarSelector.displayName = 'AvatarSelector';
+
+export type {AvatarSelectorProps};
+export default AvatarSelector;
diff --git a/src/libs/Avatars/CustomAvatarCatalog.ts b/src/libs/Avatars/CustomAvatarCatalog.ts
index edb8344c6d12..d88a5992ddac 100644
--- a/src/libs/Avatars/CustomAvatarCatalog.ts
+++ b/src/libs/Avatars/CustomAvatarCatalog.ts
@@ -116,13 +116,78 @@ const SEASON_F1: Record = {
'wrenches-pink600': {local: SeasonF1.WrenchesPink600, url: `${CDN_SEASON_F1}/wrenches-pink600.png`},
};
+const DISPLAY_ORDER = [
+ 'car-blue100',
+ 'default-avatar_1',
+ 'helmet-blue400',
+ 'default-avatar_13',
+ 'default-avatar_7',
+ 'podium-blue400',
+ 'flag-blue600',
+ 'default-avatar_19',
+ 'car-green100',
+ 'default-avatar_2',
+ 'helmet-green400',
+ 'default-avatar_14',
+ 'default-avatar_8',
+ 'tire-green400',
+ 'champagne-green400',
+ 'default-avatar_20',
+ 'car-yellow100',
+ 'default-avatar_3',
+ 'helmet-yellow400',
+ 'default-avatar_15',
+ 'default-avatar_9',
+ 'medal-yellow400',
+ 'trophy-yellow600',
+ 'default-avatar_21',
+ 'car-tangerine100',
+ 'default-avatar_4',
+ 'helmet-tangerine400',
+ 'default-avatar_16',
+ 'default-avatar_10',
+ 'gasoline-tangerine400',
+ 'cone-tangerine700',
+ 'default-avatar_22',
+ 'car-pink100',
+ 'default-avatar_5',
+ 'helmet-pink400',
+ 'default-avatar_17',
+ 'default-avatar_11',
+ 'steeringwheel-pink400',
+ 'wrenches-pink600',
+ 'default-avatar_23',
+ 'car-ice100',
+ 'default-avatar_6',
+ 'helmet-ice400',
+ 'default-avatar_18',
+ 'default-avatar_12',
+ 'speedometer-ice400',
+ 'stopwatch-ice600',
+ 'default-avatar_24',
+] as const satisfies readonly CustomAvatarID[];
+
const ALL_CUSTOM_AVATARS: Record = {
...DEFAULTS,
...SEASON_F1,
};
+const buildOrderedAvatars = (): Array<{id: CustomAvatarID} & AvatarEntry> => {
+ const allIDS = Object.keys(ALL_CUSTOM_AVATARS) as CustomAvatarID[];
+ const explicit = DISPLAY_ORDER.filter((id) => id in ALL_CUSTOM_AVATARS);
+ const explicitSet = new Set(explicit);
+ const leftovers = allIDS.filter((id) => !explicitSet.has(id)).sort();
+ const finalIDOrder = [...explicit, ...leftovers];
+ return finalIDOrder.map((id) => ({
+ id,
+ ...ALL_CUSTOM_AVATARS[id],
+ }));
+};
+
+const CUSTOM_AVATAR_CATALOG = buildOrderedAvatars();
+
const getAvatarLocal = (id: CustomAvatarID) => ALL_CUSTOM_AVATARS[id].local;
const getAvatarURL = (id: CustomAvatarID) => ALL_CUSTOM_AVATARS[id].url;
-export {ALL_CUSTOM_AVATARS, getAvatarLocal, getAvatarURL};
+export {ALL_CUSTOM_AVATARS, CUSTOM_AVATAR_CATALOG, getAvatarLocal, getAvatarURL};
export type {DefaultAvatarIDs, SeasonF1AvatarIDs, CustomAvatarID};
diff --git a/src/stories/Avatar.stories.tsx b/src/stories/Avatar.stories.tsx
new file mode 100644
index 000000000000..546a2ea661f1
--- /dev/null
+++ b/src/stories/Avatar.stories.tsx
@@ -0,0 +1,61 @@
+/* eslint-disable react/jsx-props-no-spreading */
+import type {Meta, StoryFn} from '@storybook/react';
+import React from 'react';
+import {View} from 'react-native';
+import type {AvatarProps} from '@components/Avatar';
+import Avatar from '@components/Avatar';
+import * as Expensicons from '@components/Icon/Expensicons';
+import {ALL_CUSTOM_AVATARS} from '@libs/Avatars/CustomAvatarCatalog';
+import CONST from '@src/CONST';
+
+const AVATAR_URL = ALL_CUSTOM_AVATARS['car-blue100'].url;
+
+type AvatarStory = StoryFn;
+
+const story: Meta = {
+ title: 'Components/Avatar',
+ component: Avatar,
+};
+
+function Template(props: AvatarProps) {
+ return (
+
+
+
+ );
+}
+
+const Default: AvatarStory = Template.bind({});
+Default.args = {
+ type: CONST.ICON_TYPE_AVATAR,
+ source: AVATAR_URL,
+ name: 'John Doe',
+ size: CONST.AVATAR_SIZE.DEFAULT,
+};
+
+const WorkspaceAvatar: AvatarStory = Template.bind({});
+WorkspaceAvatar.args = {
+ type: CONST.ICON_TYPE_WORKSPACE,
+ name: 'Cathy’s Croissants',
+ avatarID: 'policy_123',
+ size: CONST.AVATAR_SIZE.LARGE,
+};
+
+const FallbackAvatar: AvatarStory = Template.bind({});
+FallbackAvatar.args = {
+ type: CONST.ICON_TYPE_AVATAR,
+ fallbackIcon: Expensicons.FallbackAvatar,
+ name: 'Offline User',
+ size: CONST.AVATAR_SIZE.DEFAULT,
+};
+
+const SmallAvatar: AvatarStory = Template.bind({});
+SmallAvatar.args = {
+ type: CONST.ICON_TYPE_AVATAR,
+ source: AVATAR_URL,
+ name: 'Jane',
+ size: CONST.AVATAR_SIZE.SMALL,
+};
+
+export default story;
+export {Default, WorkspaceAvatar, FallbackAvatar, SmallAvatar};
diff --git a/src/stories/AvatarSelector.stories.tsx b/src/stories/AvatarSelector.stories.tsx
new file mode 100644
index 000000000000..e37ac62c1439
--- /dev/null
+++ b/src/stories/AvatarSelector.stories.tsx
@@ -0,0 +1,60 @@
+/* eslint-disable react/jsx-props-no-spreading */
+import type {Meta, StoryFn} from '@storybook/react';
+import React, {useState} from 'react';
+import type {AvatarSelectorProps} from '@components/AvatarSelector';
+import AvatarSelector from '@components/AvatarSelector';
+import CONST from '@src/CONST';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+
+type AvatarSelectorStory = StoryFn;
+
+const story: Meta = {
+ title: 'Components/AvatarSelector',
+ component: AvatarSelector,
+};
+
+function Template(props: AvatarSelectorProps) {
+ const [selected, setSelected] = useState(props.selectedID);
+
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ return (
+
+ );
+}
+
+const Default: AvatarSelectorStory = Template.bind({});
+Default.args = {
+ selectedID: undefined,
+ label: 'Or choose an avatar',
+};
+
+const WithPreselectedAvatar: AvatarSelectorStory = Template.bind({});
+WithPreselectedAvatar.args = {
+ selectedID: 'default-avatar_3',
+ label: 'With preselected avatar',
+};
+
+const LargeAvatars: AvatarSelectorStory = Template.bind({});
+LargeAvatars.args = {
+ selectedID: 'helmet-blue400',
+ size: CONST.AVATAR_SIZE.LARGE,
+ label: 'Large avatars',
+};
+
+const SmallAvatars: AvatarSelectorStory = Template.bind({});
+SmallAvatars.args = {
+ size: CONST.AVATAR_SIZE.SMALL,
+ label: 'Small avatars',
+};
+
+export default story;
+export {Default, WithPreselectedAvatar, LargeAvatars, SmallAvatars};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 49afc0f74c3e..b8bcdc8d4b24 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5173,6 +5173,24 @@ const staticStyles = (theme: ThemeColors) =>
marginLeft: 4,
},
+ avatarSelectorWrapper: {
+ margin: 5,
+ borderRadius: 50,
+ padding: 3,
+ borderWidth: 2,
+ borderColor: 'transparent',
+ },
+
+ avatarSelectorContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
+ avatarSelectorListContainer: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+
expenseWidgetRadius: {
borderRadius: variables.componentBorderRadiusNormal,
},
diff --git a/tests/ui/AvatarSelector.test.tsx b/tests/ui/AvatarSelector.test.tsx
new file mode 100644
index 000000000000..fc7f4da77ced
--- /dev/null
+++ b/tests/ui/AvatarSelector.test.tsx
@@ -0,0 +1,86 @@
+import {fireEvent, render, screen} from '@testing-library/react-native';
+import React from 'react';
+import AvatarSelector from '@components/AvatarSelector';
+import ComposeProviders from '@components/ComposeProviders';
+import {LocaleContextProvider} from '@components/LocaleContextProvider';
+import OnyxListItemProvider from '@components/OnyxListItemProvider';
+import {ALL_CUSTOM_AVATARS} from '@libs/Avatars/CustomAvatarCatalog';
+import * as TestHelper from '../utils/TestHelper';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+TestHelper.setupApp();
+
+describe('AvatarSelector', () => {
+ const onSelectMock = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderAvatarSelector = (props = {}) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ it('renders all avatars from catalog', async () => {
+ renderAvatarSelector();
+ await waitForBatchedUpdates();
+
+ // Check that all avatars are rendered
+ const avatars = Object.keys(ALL_CUSTOM_AVATARS);
+ avatars.forEach((id) => {
+ expect(screen.getByTestId(`AvatarSelector_${id}`)).toBeOnTheScreen();
+ });
+ });
+
+ it('calls onSelect when avatar is pressed', async () => {
+ renderAvatarSelector();
+ await waitForBatchedUpdates();
+
+ const avatars = Object.keys(ALL_CUSTOM_AVATARS);
+ const firstAvatarId = avatars.at(0);
+ const firstAvatar = screen.getByTestId(`AvatarSelector_${firstAvatarId}`);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain
+ fireEvent.press(firstAvatar.parent?.parent!);
+ await waitForBatchedUpdates();
+
+ expect(onSelectMock).toHaveBeenCalledWith(firstAvatarId);
+ expect(onSelectMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders with label when provided', async () => {
+ const label = 'Choose an avatar';
+ renderAvatarSelector({label});
+ await waitForBatchedUpdates();
+
+ expect(screen.getByText(label)).toBeOnTheScreen();
+ });
+
+ it('does not render label when not provided', async () => {
+ renderAvatarSelector();
+ await waitForBatchedUpdates();
+
+ // Label should not exist when not provided
+ const text = screen.queryByText('Choose an avatar');
+ expect(text).not.toBeOnTheScreen();
+ });
+
+ it('shows selected avatar with different styling', async () => {
+ const avatars = Object.keys(ALL_CUSTOM_AVATARS);
+ const selectedId = avatars.at(0) as keyof typeof ALL_CUSTOM_AVATARS;
+
+ renderAvatarSelector({selectedID: selectedId});
+ await waitForBatchedUpdates();
+
+ const selectedAvatar = screen.getByTestId(`AvatarSelector_${selectedId}`);
+ expect(selectedAvatar).toBeOnTheScreen();
+ });
+});