diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..235523054c390fd62e2c15bc79e9a63510f76322
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.stories.js
@@ -0,0 +1,19 @@
+import { groups } from 'jest/vue_shared/components/groups_list/mock_data';
+import GroupsList from './groups_list.vue';
+
+export default {
+  component: GroupsList,
+  title: 'vue_shared/groups_list',
+};
+
+const Template = (args, { argTypes }) => ({
+  components: { GroupsList },
+  props: Object.keys(argTypes),
+  template: '<groups-list v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+  groups,
+  showGroupIcon: true,
+};
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7da45169fee61787a965cddda17dd4ffd1c99601
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue
@@ -0,0 +1,29 @@
+<script>
+import GroupsListItem from './groups_list_item.vue';
+
+export default {
+  components: { GroupsListItem },
+  props: {
+    groups: {
+      type: Array,
+      required: true,
+    },
+    showGroupIcon: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+};
+</script>
+
+<template>
+  <ul class="gl-p-0 gl-list-style-none">
+    <groups-list-item
+      v-for="group in groups"
+      :key="group.id"
+      :group="group"
+      :show-group-icon="showGroupIcon"
+    />
+  </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8a301cd0dd05e6245d793459ea126a6c45837ff7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui';
+
+import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { __ } from '~/locale';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+  i18n: {
+    subgroups: __('Subgroups'),
+    projects: __('Projects'),
+    directMembers: __('Direct members'),
+    showMore: __('Show more'),
+    showLess: __('Show less'),
+  },
+  avatarSize: { default: 32, md: 48 },
+  safeHtmlConfig: {
+    ADD_TAGS: ['gl-emoji'],
+  },
+  components: {
+    GlAvatarLabeled,
+    GlIcon,
+    UserAccessRoleBadge,
+    GlTruncateText,
+  },
+  directives: {
+    GlTooltip: GlTooltipDirective,
+    SafeHtml,
+  },
+  props: {
+    group: {
+      type: Object,
+      required: true,
+    },
+    showGroupIcon: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  computed: {
+    visibility() {
+      return this.group.visibility;
+    },
+    visibilityIcon() {
+      return VISIBILITY_TYPE_ICON[this.visibility];
+    },
+    visibilityTooltip() {
+      return GROUP_VISIBILITY_TYPE[this.visibility];
+    },
+    accessLevel() {
+      return this.group.accessLevel?.integerValue;
+    },
+    accessLevelLabel() {
+      return ACCESS_LEVEL_LABELS[this.accessLevel];
+    },
+    shouldShowAccessLevel() {
+      return this.accessLevel !== undefined;
+    },
+    groupIconName() {
+      return this.group.parent ? 'subgroup' : 'group';
+    },
+    statsPadding() {
+      return this.showGroupIcon ? 'gl-pl-11' : 'gl-pl-8';
+    },
+    descendantGroupsCount() {
+      return numberToMetricPrefix(this.group.descendantGroupsCount);
+    },
+    projectsCount() {
+      return numberToMetricPrefix(this.group.projectsCount);
+    },
+    groupMembersCount() {
+      return numberToMetricPrefix(this.group.groupMembersCount);
+    },
+  },
+};
+</script>
+
+<template>
+  <li class="groups-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
+    <div class="gl-display-flex gl-flex-grow-1">
+      <gl-icon
+        v-if="showGroupIcon"
+        class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary"
+        :name="groupIconName"
+      />
+      <gl-avatar-labeled
+        :entity-id="group.id"
+        :entity-name="group.fullName"
+        :label="group.fullName"
+        :label-link="group.webUrl"
+        shape="rect"
+        :size="$options.avatarSize"
+      >
+        <template #meta>
+          <div class="gl-px-2">
+            <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap">
+              <div class="gl-px-2">
+                <gl-icon
+                  v-if="visibility"
+                  v-gl-tooltip="visibilityTooltip"
+                  :name="visibilityIcon"
+                  class="gl-text-secondary"
+                />
+              </div>
+              <div class="gl-px-2">
+                <user-access-role-badge v-if="shouldShowAccessLevel">{{
+                  accessLevelLabel
+                }}</user-access-role-badge>
+              </div>
+            </div>
+          </div>
+        </template>
+        <gl-truncate-text
+          v-if="group.descriptionHtml"
+          :lines="2"
+          :mobile-lines="2"
+          :show-more-text="$options.i18n.showMore"
+          :show-less-text="$options.i18n.showLess"
+          class="gl-mt-2"
+        >
+          <div
+            v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml"
+            class="gl-font-sm md"
+            data-testid="group-description"
+          ></div>
+        </gl-truncate-text>
+      </gl-avatar-labeled>
+    </div>
+    <div
+      class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3"
+      :class="statsPadding"
+    >
+      <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+        <div
+          v-gl-tooltip="$options.i18n.subgroups"
+          :aria-label="$options.i18n.subgroups"
+          class="gl-text-secondary"
+          data-testid="subgroups-count"
+        >
+          <gl-icon name="subgroup" />
+          <span>{{ descendantGroupsCount }}</span>
+        </div>
+        <div
+          v-gl-tooltip="$options.i18n.projects"
+          :aria-label="$options.i18n.projects"
+          class="gl-text-secondary"
+          data-testid="projects-count"
+        >
+          <gl-icon name="project" />
+          <span>{{ projectsCount }}</span>
+        </div>
+        <div
+          v-gl-tooltip="$options.i18n.directMembers"
+          :aria-label="$options.i18n.directMembers"
+          class="gl-text-secondary"
+          data-testid="members-count"
+        >
+          <gl-icon name="users" />
+          <span>{{ groupMembersCount }}</span>
+        </div>
+      </div>
+    </div>
+  </li>
+</template>
diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..877de4f4695f567a246cb83f930de51f784d1a09
--- /dev/null
+++ b/spec/frontend/vue_shared/components/groups_list/groups_list_item_spec.js
@@ -0,0 +1,182 @@
+import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import {
+  VISIBILITY_TYPE_ICON,
+  VISIBILITY_LEVEL_INTERNAL_STRING,
+  GROUP_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { groups } from './mock_data';
+
+describe('GroupsListItem', () => {
+  let wrapper;
+
+  const [group] = groups;
+
+  const defaultPropsData = { group };
+
+  const createComponent = ({ propsData = {} } = {}) => {
+    wrapper = mountExtended(GroupsListItem, {
+      propsData: { ...defaultPropsData, ...propsData },
+      directives: {
+        GlTooltip: createMockDirective('gl-tooltip'),
+      },
+    });
+  };
+
+  const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled);
+  const findGroupDescription = () => wrapper.findByTestId('group-description');
+  const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon);
+
+  it('renders group avatar', () => {
+    createComponent();
+
+    const avatarLabeled = findAvatarLabeled();
+
+    expect(avatarLabeled.props()).toMatchObject({
+      label: group.fullName,
+      labelLink: group.webUrl,
+    });
+
+    expect(avatarLabeled.attributes()).toMatchObject({
+      'entity-id': group.id.toString(),
+      'entity-name': group.fullName,
+      shape: 'rect',
+    });
+  });
+
+  it('renders visibility icon with tooltip', () => {
+    createComponent();
+
+    const icon = findAvatarLabeled().findComponent(GlIcon);
+    const tooltip = getBinding(icon.element, 'gl-tooltip');
+
+    expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_INTERNAL_STRING]);
+    expect(tooltip.value).toBe(GROUP_VISIBILITY_TYPE[VISIBILITY_LEVEL_INTERNAL_STRING]);
+  });
+
+  it('renders subgroup count', () => {
+    createComponent();
+
+    const countWrapper = wrapper.findByTestId('subgroups-count');
+    const tooltip = getBinding(countWrapper.element, 'gl-tooltip');
+
+    expect(tooltip.value).toBe(GroupsListItem.i18n.subgroups);
+    expect(countWrapper.text()).toBe(group.descendantGroupsCount.toString());
+    expect(countWrapper.findComponent(GlIcon).props('name')).toBe('subgroup');
+  });
+
+  it('renders projects count', () => {
+    createComponent();
+
+    const countWrapper = wrapper.findByTestId('projects-count');
+    const tooltip = getBinding(countWrapper.element, 'gl-tooltip');
+
+    expect(tooltip.value).toBe(GroupsListItem.i18n.projects);
+    expect(countWrapper.text()).toBe(group.projectsCount.toString());
+    expect(countWrapper.findComponent(GlIcon).props('name')).toBe('project');
+  });
+
+  it('renders members count', () => {
+    createComponent();
+
+    const countWrapper = wrapper.findByTestId('members-count');
+    const tooltip = getBinding(countWrapper.element, 'gl-tooltip');
+
+    expect(tooltip.value).toBe(GroupsListItem.i18n.directMembers);
+    expect(countWrapper.text()).toBe(group.groupMembersCount.toString());
+    expect(countWrapper.findComponent(GlIcon).props('name')).toBe('users');
+  });
+
+  describe('when visibility is not provided', () => {
+    it('does not render visibility icon', () => {
+      const { visibility, ...groupWithoutVisibility } = group;
+      createComponent({
+        propsData: {
+          group: groupWithoutVisibility,
+        },
+      });
+
+      expect(findVisibilityIcon().exists()).toBe(false);
+    });
+  });
+
+  it('renders access role badge', () => {
+    createComponent();
+
+    expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe(
+      ACCESS_LEVEL_LABELS[group.accessLevel.integerValue],
+    );
+  });
+
+  describe('when group has a description', () => {
+    it('renders description', () => {
+      const descriptionHtml = '<p>Foo bar</p>';
+
+      createComponent({
+        propsData: {
+          group: {
+            ...group,
+            descriptionHtml,
+          },
+        },
+      });
+
+      expect(findGroupDescription().element.innerHTML).toBe(descriptionHtml);
+    });
+  });
+
+  describe('when group does not have a description', () => {
+    it('does not render description', () => {
+      createComponent({
+        propsData: {
+          group: {
+            ...group,
+            descriptionHtml: null,
+          },
+        },
+      });
+
+      expect(findGroupDescription().exists()).toBe(false);
+    });
+  });
+
+  describe('when `showGroupIcon` prop is `true`', () => {
+    describe('when `parent` attribute is `null`', () => {
+      it('shows group icon', () => {
+        createComponent({ propsData: { showGroupIcon: true } });
+
+        expect(wrapper.findByTestId('group-icon').exists()).toBe(true);
+      });
+    });
+
+    describe('when `parent` attribute is set', () => {
+      it('shows subgroup icon', () => {
+        createComponent({
+          propsData: {
+            showGroupIcon: true,
+            group: {
+              ...group,
+              parent: {
+                id: 'gid://gitlab/Group/35',
+              },
+            },
+          },
+        });
+
+        expect(wrapper.findByTestId('subgroup-icon').exists()).toBe(true);
+      });
+    });
+  });
+
+  describe('when `showGroupIcon` prop is `false`', () => {
+    it('does not show group icon', () => {
+      createComponent();
+
+      expect(wrapper.findByTestId('group-icon').exists()).toBe(false);
+    });
+  });
+});
diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c65aa347bcf9d4d729de5091347d6c507ccc8dc0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/groups_list/groups_list_spec.js
@@ -0,0 +1,34 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue';
+import GroupsListItem from '~/vue_shared/components/groups_list/groups_list_item.vue';
+import { groups } from './mock_data';
+
+describe('GroupsList', () => {
+  let wrapper;
+
+  const defaultPropsData = {
+    groups,
+  };
+
+  const createComponent = () => {
+    wrapper = shallowMountExtended(GroupsList, {
+      propsData: defaultPropsData,
+    });
+  };
+
+  it('renders list with `GroupsListItem` component', () => {
+    createComponent();
+
+    const groupsListItemWrappers = wrapper.findAllComponents(GroupsListItem).wrappers;
+    const expectedProps = groupsListItemWrappers.map((groupsListItemWrapper) =>
+      groupsListItemWrapper.props(),
+    );
+
+    expect(expectedProps).toEqual(
+      defaultPropsData.groups.map((group) => ({
+        group,
+        showGroupIcon: false,
+      })),
+    );
+  });
+});
diff --git a/spec/frontend/vue_shared/components/groups_list/mock_data.js b/spec/frontend/vue_shared/components/groups_list/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..0dad27f83116f6907e0de643318401064651d738
--- /dev/null
+++ b/spec/frontend/vue_shared/components/groups_list/mock_data.js
@@ -0,0 +1,35 @@
+export const groups = [
+  {
+    id: 1,
+    fullName: 'Gitlab Org',
+    parent: null,
+    webUrl: 'http://127.0.0.1:3000/groups/gitlab-org',
+    descriptionHtml:
+      '<p data-sourcepos="1:1-1:64" dir="auto">Dolorem dolorem omnis impedit cupiditate pariatur officia velit. Fusce eget orci a ipsum tempus vehicula. Donec rhoncus ante sed lacus pharetra, vitae imperdiet felis lobortis. Donec maximus dapibus orci, sit amet euismod dolor rhoncus vel. In nec mauris nibh.</p>',
+    avatarUrl: null,
+    descendantGroupsCount: 1,
+    projectsCount: 1,
+    groupMembersCount: 2,
+    visibility: 'internal',
+    accessLevel: {
+      integerValue: 10,
+    },
+  },
+  {
+    id: 2,
+    fullName: 'Gitlab Org / test subgroup',
+    parent: {
+      id: 1,
+    },
+    webUrl: 'http://127.0.0.1:3000/groups/gitlab-org/test-subgroup',
+    descriptionHtml: '',
+    avatarUrl: null,
+    descendantGroupsCount: 4,
+    projectsCount: 4,
+    groupMembersCount: 4,
+    visibility: 'private',
+    accessLevel: {
+      integerValue: 20,
+    },
+  },
+];