diff --git a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js
index 0dd6f4bbb13c16a03bab2c6f77e48972437de6a4..61517d1cd56b2e9129d66563fd279dbc8894c193 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/formatter.js
+++ b/app/assets/javascripts/vue_shared/components/projects_list/formatter.js
@@ -17,6 +17,7 @@ export const formatGraphQLProjects = (projects) =>
       ...project,
       id: getIdFromGraphQLId(id),
       name: nameWithNamespace,
+      avatarLabel: nameWithNamespace,
       mergeRequestsAccessLevel: mergeRequestsAccessLevel.stringValue,
       issuesAccessLevel: issuesAccessLevel.stringValue,
       forkingAccessLevel: forkingAccessLevel.stringValue,
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue
index a5706a982bc1473cd84179a1c1908eda83631c72..1d8759ac78e40379a2436e564a8fb88e81fa0594 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/project_list_item_description.vue
@@ -1,17 +1,11 @@
 <script>
-import { GlTruncateText } from '@gitlab/ui';
-import { __ } from '~/locale';
 import SafeHtml from '~/vue_shared/directives/safe_html';
+import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue';
 
 export default {
   name: 'ProjectListItemDescription',
-  i18n: {
-    showMore: __('Show more'),
-    showLess: __('Show less'),
-  },
-  truncateTextToggleButtonProps: { class: '!gl-text-sm' },
   components: {
-    GlTruncateText,
+    ListItemDescription,
   },
   directives: {
     SafeHtml,
@@ -31,19 +25,5 @@ export default {
 </script>
 
 <template>
-  <gl-truncate-text
-    v-if="showDescription"
-    :lines="2"
-    :mobile-lines="2"
-    :show-more-text="$options.i18n.showMore"
-    :show-less-text="$options.i18n.showLess"
-    :toggle-button-props="$options.truncateTextToggleButtonProps"
-    class="gl-mt-2 gl-max-w-88"
-  >
-    <div
-      v-safe-html="project.descriptionHtml"
-      class="md md-child-content-text-subtle gl-text-sm"
-      data-testid="project-description"
-    ></div>
-  </gl-truncate-text>
+  <list-item-description v-if="showDescription" :description-html="project.descriptionHtml" />
 </template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
index 4b03d5b6f14fc32fecbd4eafc4eba96a2e4385ff..bf634f7305ddb08c70da0de06e3dba4c6c33c48e 100644
--- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -1,13 +1,5 @@
 <script>
-import {
-  GlAvatarLabeled,
-  GlIcon,
-  GlLink,
-  GlBadge,
-  GlTooltipDirective,
-  GlPopover,
-  GlSprintf,
-} from '@gitlab/ui';
+import { GlIcon, GlBadge, GlTooltipDirective, GlPopover, GlSprintf } from '@gitlab/ui';
 import uniqueId from 'lodash/uniqueId';
 
 import {
@@ -23,7 +15,6 @@ import { FEATURABLE_ENABLED } from '~/featurable/constants';
 import { __, s__ } from '~/locale';
 import { numberToMetricPrefix } from '~/lib/utils/number_utils';
 import { truncate } from '~/lib/utils/text_utility';
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
 import { ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
 import DeleteModal from '~/projects/components/shared/delete_modal.vue';
 import {
@@ -32,6 +23,8 @@ import {
 } from '~/vue_shared/components/resource_lists/constants';
 import { deleteProject } from '~/rest_api';
 import { createAlert } from '~/alert';
+import ListItem from '~/vue_shared/components/resource_lists/list_item.vue';
+import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue';
 
 const MAX_TOPICS_TO_SHOW = 3;
 const MAX_TOPIC_TITLE_LENGTH = 15;
@@ -45,8 +38,6 @@ export default {
     topics: __('Topics'),
     topicsPopoverTargetText: __('+ %{count} more'),
     moreTopics: __('More topics'),
-    [TIMESTAMP_TYPE_CREATED_AT]: __('Created'),
-    [TIMESTAMP_TYPE_UPDATED_AT]: __('Updated'),
     project: __('Project'),
     deleteErrorMessage: s__(
       'Projects|An error occurred deleting the project. Please refresh the page to try again.',
@@ -54,13 +45,12 @@ export default {
     ciCatalogBadge: s__('CiCatalog|CI/CD Catalog project'),
   },
   components: {
-    GlAvatarLabeled,
     GlIcon,
-    GlLink,
     GlBadge,
     GlPopover,
     GlSprintf,
-    TimeAgoTooltip,
+    ListItem,
+    ListItemStat,
     DeleteModal,
     ProjectListItemDescription,
     ProjectListItemActions,
@@ -212,12 +202,6 @@ export default {
     hasActionDelete() {
       return this.project.availableActions?.includes(ACTION_DELETE);
     },
-    timestampText() {
-      return this.$options.i18n[this.timestampType];
-    },
-    timestamp() {
-      return this.project[this.timestampType];
-    },
   },
   methods: {
     topicPath(topic) {
@@ -255,170 +239,129 @@ export default {
 </script>
 
 <template>
-  <li class="projects-list-item gl-border-b gl-flex gl-py-5">
-    <div class="gl-grow md:gl-flex">
-      <div class="gl-flex gl-grow gl-items-start">
-        <div v-if="showProjectIcon" class="gl-mr-3 gl-flex gl-h-9 gl-shrink-0 gl-items-center">
-          <gl-icon name="project" variant="subtle" />
-        </div>
-        <gl-avatar-labeled
-          :entity-id="project.id"
-          :entity-name="project.name"
-          :label="project.name"
-          :label-link="project.webUrl"
-          :src="project.avatarUrl"
-          shape="rect"
-          :size="48"
+  <list-item
+    :resource="project"
+    :show-icon="showProjectIcon"
+    icon-name="project"
+    :timestamp-type="timestampType"
+  >
+    <template #avatar-meta>
+      <gl-icon
+        v-if="visibility"
+        v-gl-tooltip="visibilityTooltip"
+        :name="visibilityIcon"
+        variant="subtle"
+      />
+      <gl-badge
+        v-if="project.isCatalogResource"
+        icon="catalog-checkmark"
+        variant="info"
+        data-testid="ci-catalog-badge"
+        :href="project.exploreCatalogPath"
+        >{{ $options.i18n.ciCatalogBadge }}</gl-badge
+      >
+      <gl-badge v-if="shouldShowAccessLevel" class="gl-block" data-testid="access-level-badge">{{
+        accessLevelLabel
+      }}</gl-badge>
+    </template>
+
+    <template #avatar-default>
+      <project-list-item-description :project="project" />
+      <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+        <div
+          class="-gl-mx-2 -gl-my-2 gl-inline-flex gl-w-full gl-flex-wrap gl-items-center gl-text-base gl-font-normal"
         >
-          <template #meta>
-            <div class="gl-px-2">
-              <div class="-gl-mx-2 gl-flex gl-flex-wrap gl-items-center">
-                <div class="gl-px-2">
-                  <gl-icon
-                    v-if="visibility"
-                    v-gl-tooltip="visibilityTooltip"
-                    :name="visibilityIcon"
-                    variant="subtle"
-                  />
-                </div>
-                <div v-if="project.isCatalogResource" class="gl-px-2">
-                  <gl-badge
-                    icon="catalog-checkmark"
-                    variant="info"
-                    data-testid="ci-catalog-badge"
-                    :href="project.exploreCatalogPath"
-                    >{{ $options.i18n.ciCatalogBadge }}</gl-badge
-                  >
-                </div>
-                <div class="gl-px-2">
-                  <gl-badge
-                    v-if="shouldShowAccessLevel"
-                    class="gl-block"
-                    data-testid="access-level-badge"
-                    >{{ accessLevelLabel }}</gl-badge
-                  >
-                </div>
-              </div>
-            </div>
-          </template>
-          <project-list-item-description :project="project" />
-          <div v-if="hasTopics" class="gl-mt-3" data-testid="project-topics">
+          <span class="gl-p-2 gl-text-sm gl-text-subtle">{{ $options.i18n.topics }}:</span>
+          <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
+            <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+              {{ topicTitle(topic) }}
+            </gl-badge>
+          </div>
+          <template v-if="popoverTopics.length">
             <div
-              class="-gl-mx-2 -gl-my-2 gl-inline-flex gl-w-full gl-flex-wrap gl-items-center gl-text-base gl-font-normal"
+              :id="topicsPopoverTarget"
+              class="gl-p-2 gl-text-sm gl-text-subtle"
+              role="button"
+              tabindex="0"
             >
-              <span class="gl-p-2 gl-text-sm gl-text-subtle">{{ $options.i18n.topics }}:</span>
-              <div v-for="topic in visibleTopics" :key="topic" class="gl-p-2">
-                <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
-                  {{ topicTitle(topic) }}
-                </gl-badge>
-              </div>
-              <template v-if="popoverTopics.length">
-                <div
-                  :id="topicsPopoverTarget"
-                  class="gl-p-2 gl-text-sm gl-text-subtle"
-                  role="button"
-                  tabindex="0"
-                >
-                  <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
-                    <template #count>{{ popoverTopics.length }}</template>
-                  </gl-sprintf>
-                </div>
-                <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
-                  <div class="-gl-mx-2 -gl-my-2 gl-text-base gl-font-normal">
-                    <div v-for="topic in popoverTopics" :key="topic" class="gl-inline-block gl-p-2">
-                      <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
-                        {{ topicTitle(topic) }}
-                      </gl-badge>
-                    </div>
-                  </div>
-                </gl-popover>
-              </template>
+              <gl-sprintf :message="$options.i18n.topicsPopoverTargetText">
+                <template #count>{{ popoverTopics.length }}</template>
+              </gl-sprintf>
             </div>
-          </div>
-        </gl-avatar-labeled>
-      </div>
-      <div
-        class="gl-mt-3 gl-shrink-0 gl-flex-col gl-items-end md:gl-mt-0 md:gl-flex md:gl-pl-0"
-        :class="showProjectIcon ? 'gl-pl-12' : 'gl-pl-10'"
-      >
-        <div class="gl-flex gl-items-center gl-gap-x-3 md:gl-h-9">
-          <project-list-item-inactive-badge :project="project" />
-          <gl-link
-            v-gl-tooltip="$options.i18n.stars"
-            :href="starsHref"
-            :aria-label="$options.i18n.stars"
-            class="gl-text-subtle"
-            data-testid="stars-btn"
-          >
-            <gl-icon name="star-o" />
-            <span>{{ starCount }}</span>
-          </gl-link>
-          <gl-link
-            v-if="isForkingEnabled"
-            v-gl-tooltip="$options.i18n.forks"
-            :href="forksHref"
-            :aria-label="$options.i18n.forks"
-            class="gl-text-subtle"
-            data-testid="forks-btn"
-          >
-            <gl-icon name="fork" />
-            <span>{{ forksCount }}</span>
-          </gl-link>
-          <gl-link
-            v-if="isMergeRequestsEnabled"
-            v-gl-tooltip="$options.i18n.mergeRequests"
-            :href="mergeRequestsHref"
-            :aria-label="$options.i18n.mergeRequests"
-            class="gl-text-subtle"
-            data-testid="mrs-btn"
-          >
-            <gl-icon name="merge-request" />
-            <span>{{ openMergeRequestsCount }}</span>
-          </gl-link>
-          <gl-link
-            v-if="isIssuesEnabled"
-            v-gl-tooltip="$options.i18n.issues"
-            :href="issuesHref"
-            :aria-label="$options.i18n.issues"
-            class="gl-text-subtle"
-            data-testid="issues-btn"
-          >
-            <gl-icon name="issues" />
-            <span>{{ openIssuesCount }}</span>
-          </gl-link>
-        </div>
-        <div
-          v-if="timestamp"
-          class="gl-mt-3 gl-whitespace-nowrap gl-text-sm gl-text-subtle md:-gl-mt-2"
-        >
-          <span>{{ timestampText }}</span>
-          <time-ago-tooltip :time="timestamp" />
+            <gl-popover :target="topicsPopoverTarget" :title="$options.i18n.moreTopics">
+              <div class="-gl-mx-2 -gl-my-2 gl-text-base gl-font-normal">
+                <div v-for="topic in popoverTopics" :key="topic" class="gl-inline-block gl-p-2">
+                  <gl-badge v-gl-tooltip="topicTooltipTitle(topic)" :href="topicPath(topic)">
+                    {{ topicTitle(topic) }}
+                  </gl-badge>
+                </div>
+              </div>
+            </gl-popover>
+          </template>
         </div>
       </div>
-    </div>
-    <div v-if="hasActions" class="gl-ml-3 gl-flex gl-h-9 gl-items-center">
+    </template>
+
+    <template #stats>
+      <project-list-item-inactive-badge :project="project" />
+      <list-item-stat
+        :href="starsHref"
+        :tooltip-text="$options.i18n.stars"
+        icon-name="star-o"
+        :stat="starCount"
+        data-testid="stars-btn"
+      />
+      <list-item-stat
+        v-if="isForkingEnabled"
+        :href="forksHref"
+        :tooltip-text="$options.i18n.forks"
+        icon-name="fork"
+        :stat="forksCount"
+        data-testid="forks-btn"
+      />
+      <list-item-stat
+        v-if="isMergeRequestsEnabled"
+        :href="mergeRequestsHref"
+        :tooltip-text="$options.i18n.mergeRequests"
+        icon-name="merge-request"
+        :stat="openMergeRequestsCount"
+        data-testid="mrs-btn"
+      />
+      <list-item-stat
+        v-if="isIssuesEnabled"
+        :href="issuesHref"
+        :tooltip-text="$options.i18n.issues"
+        icon-name="issues"
+        :stat="openIssuesCount"
+        data-testid="issues-btn"
+      />
+    </template>
+
+    <template v-if="hasActions" #actions>
       <project-list-item-actions
         :project="project"
         @refetch="$emit('refetch')"
         @delete="onActionDelete"
       />
-    </div>
+    </template>
 
-    <delete-modal
-      v-if="hasActionDelete"
-      v-model="isDeleteModalVisible"
-      :confirm-phrase="project.name"
-      :is-fork="project.isForked"
-      :confirm-loading="isDeleteLoading"
-      :merge-requests-count="openMergeRequestsCount"
-      :issues-count="openIssuesCount"
-      :forks-count="forksCount"
-      :stars-count="starCount"
-      @primary="onDeleteModalPrimary"
-    >
-      <template #modal-footer
-        ><project-list-item-delayed-deletion-modal-footer :project="project"
-      /></template>
-    </delete-modal>
-  </li>
+    <template #footer>
+      <delete-modal
+        v-if="hasActionDelete"
+        v-model="isDeleteModalVisible"
+        :confirm-phrase="project.name"
+        :is-fork="project.isForked"
+        :confirm-loading="isDeleteLoading"
+        :merge-requests-count="openMergeRequestsCount"
+        :issues-count="openIssuesCount"
+        :forks-count="forksCount"
+        :stars-count="starCount"
+        @primary="onDeleteModalPrimary"
+      >
+        <template #modal-footer
+          ><project-list-item-delayed-deletion-modal-footer :project="project"
+        /></template>
+      </delete-modal>
+    </template>
+  </list-item>
 </template>
diff --git a/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue b/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue
index ebe372e6696da18b4e7c67befa878cc9252faf4e..4fb3164fa7a0474b42e8c112e9c6060335406542 100644
--- a/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/resource_lists/list_item.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui';
+import { GlAvatarLabeled, GlIcon, GlTooltipDirective } from '@gitlab/ui';
 
 import { __ } from '~/locale';
 import SafeHtml from '~/vue_shared/directives/safe_html';
@@ -9,21 +9,19 @@ import {
   TIMESTAMP_TYPE_CREATED_AT,
   TIMESTAMP_TYPE_UPDATED_AT,
 } from '~/vue_shared/components/resource_lists/constants';
+import ListItemDescription from './list_item_description.vue';
 
 export default {
   i18n: {
-    showMore: __('Show more'),
-    showLess: __('Show less'),
     [TIMESTAMP_TYPE_CREATED_AT]: __('Created'),
     [TIMESTAMP_TYPE_UPDATED_AT]: __('Updated'),
   },
-  truncateTextToggleButtonProps: { class: '!gl-text-sm' },
   components: {
     GlAvatarLabeled,
     GlIcon,
-    GlTruncateText,
     ListActions,
     TimeAgoTooltip,
+    ListItemDescription,
   },
   directives: {
     GlTooltip: GlTooltipDirective,
@@ -34,7 +32,7 @@ export default {
       type: Object,
       required: true,
       validator(resource) {
-        const requiredKeys = ['id', 'avatarUrl', 'avatarLabel', 'webUrl', 'availableActions'];
+        const requiredKeys = ['id', 'avatarUrl', 'avatarLabel', 'webUrl'];
 
         return requiredKeys.every((key) => Object.prototype.hasOwnProperty.call(resource, key));
       },
@@ -76,7 +74,10 @@ export default {
       return this.$options.i18n[this.timestampType];
     },
     hasActions() {
-      return Object.keys(this.actions).length && this.resource.availableActions?.length;
+      return (
+        this.$scopedSlots.actions ||
+        (Object.keys(this.actions).length && this.resource.availableActions?.length)
+      );
     },
   },
 };
@@ -105,21 +106,12 @@ export default {
               </div>
             </div>
           </template>
-          <gl-truncate-text
-            v-if="resource.descriptionHtml"
-            :lines="2"
-            :mobile-lines="2"
-            :show-more-text="$options.i18n.showMore"
-            :show-less-text="$options.i18n.showLess"
-            :toggle-button-props="$options.truncateTextToggleButtonProps"
-            class="gl-mt-2 gl-max-w-88"
-          >
-            <div
-              v-safe-html="resource.descriptionHtml"
-              class="md md-child-content-text-subtle gl-text-sm"
-              data-testid="description"
-            ></div>
-          </gl-truncate-text>
+          <slot name="avatar-default">
+            <list-item-description
+              v-if="resource.descriptionHtml"
+              :description-html="resource.descriptionHtml"
+            />
+          </slot>
         </gl-avatar-labeled>
       </div>
       <div
@@ -138,12 +130,10 @@ export default {
         </div>
       </div>
     </div>
-    <div class="-gl-mt-3 gl-ml-3 gl-flex gl-items-center">
-      <list-actions
-        v-if="hasActions"
-        :actions="actions"
-        :available-actions="resource.availableActions"
-      />
+    <div v-if="hasActions" class="-gl-mt-3 gl-ml-3 gl-flex gl-items-center">
+      <slot name="actions">
+        <list-actions :actions="actions" :available-actions="resource.availableActions" />
+      </slot>
     </div>
 
     <slot name="footer"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/resource_lists/list_item_description.vue b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_description.vue
new file mode 100644
index 0000000000000000000000000000000000000000..be332a5b539f2e83b4fbed6b7d0706b17b3f8904
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_description.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlTruncateText } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+
+export default {
+  i18n: {
+    showMore: __('Show more'),
+    showLess: __('Show less'),
+  },
+  truncateTextToggleButtonProps: { class: '!gl-text-sm' },
+  components: {
+    GlTruncateText,
+  },
+  directives: {
+    SafeHtml,
+  },
+  props: {
+    descriptionHtml: {
+      type: String,
+      required: true,
+    },
+  },
+};
+</script>
+
+<template>
+  <gl-truncate-text
+    :lines="2"
+    :mobile-lines="2"
+    :show-more-text="$options.i18n.showMore"
+    :show-less-text="$options.i18n.showLess"
+    :toggle-button-props="$options.truncateTextToggleButtonProps"
+    class="gl-mt-2 gl-max-w-88"
+  >
+    <div
+      v-safe-html="descriptionHtml"
+      class="md md-child-content-text-subtle gl-text-sm"
+      data-testid="description"
+    ></div>
+  </gl-truncate-text>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue
index 4cd82dd13b6860641d363f1b324743310efc66ba..ea6197916372cbc07509e50160ace6a536a5b1f5 100644
--- a/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue
+++ b/app/assets/javascripts/vue_shared/components/resource_lists/list_item_stat.vue
@@ -1,5 +1,5 @@
 <script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlLink } from '@gitlab/ui';
 
 export default {
   components: { GlIcon },
@@ -20,17 +20,29 @@ export default {
       type: [String, Number],
       required: true,
     },
+    href: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
+  computed: {
+    component() {
+      return this.href ? GlLink : 'div';
+    },
   },
 };
 </script>
 
 <template>
-  <div
+  <component
+    :is="component"
     v-gl-tooltip="tooltipText"
     :aria-label="tooltipText"
+    :href="href"
     class="gl-flex gl-items-center gl-gap-x-2 gl-text-subtle"
   >
     <gl-icon :name="iconName" />
     <span class="gl-leading-1">{{ stat }}</span>
-  </div>
+  </component>
 </template>
diff --git a/app/assets/stylesheets/page_bundles/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss
index 9f210376402a5ca21656952f66f39b24bd239551..131b30042856716c914da6fee0af79755c6da469 100644
--- a/app/assets/stylesheets/page_bundles/projects.scss
+++ b/app/assets/stylesheets/page_bundles/projects.scss
@@ -553,20 +553,6 @@
   }
 }
 
-.projects-list-item {
-  .description {
-    max-height: $gl-spacing-scale-8;
-
-    p {
-      -webkit-line-clamp: 2;
-      -webkit-box-orient: vertical;
-      text-overflow: ellipsis;
-      /* stylelint-disable-next-line value-no-vendor-prefix */
-      display: -webkit-box;
-    }
-  }
-}
-
 .projects-list .description p {
   @apply gl-line-clamp-2 gl-whitespace-normal;
   margin-bottom: 0;
diff --git a/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
index bbd65a199e836951d127aac589c4651524219d11..b8ab120284739c99df5144d66577378b9e1d96af 100644
--- a/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
+++ b/ee/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
@@ -12,7 +12,8 @@ import DeleteModal from '~/projects/components/shared/delete_modal.vue';
 describe('ProjectsListItemEE', () => {
   let wrapper;
 
-  const [project] = convertObjectPropsToCamelCase(projects, { deep: true });
+  const [mockProject] = convertObjectPropsToCamelCase(projects, { deep: true });
+  const project = { ...mockProject, avatarLabel: mockProject.nameWithNamespace, isForked: false };
 
   const defaultProps = { project };
 
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
index dad6916b742c4e941d475211cd72f89a750c29d4..1c19fbb57059db39387f67ff3faea4996a31e4fc 100644
--- 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
@@ -76,7 +76,7 @@ describe('GroupsListItem', () => {
   it('renders subgroup count', () => {
     createComponent();
 
-    expect(wrapper.findByTestId('subgroups-count').props()).toEqual({
+    expect(wrapper.findByTestId('subgroups-count').props()).toMatchObject({
       tooltipText: 'Subgroups',
       iconName: 'subgroup',
       stat: group.descendantGroupsCount.toString(),
@@ -86,7 +86,7 @@ describe('GroupsListItem', () => {
   it('renders projects count', () => {
     createComponent();
 
-    expect(wrapper.findByTestId('projects-count').props()).toEqual({
+    expect(wrapper.findByTestId('projects-count').props()).toMatchObject({
       tooltipText: 'Projects',
       iconName: 'project',
       stat: group.projectsCount.toString(),
@@ -96,7 +96,7 @@ describe('GroupsListItem', () => {
   it('renders members count', () => {
     createComponent();
 
-    expect(wrapper.findByTestId('members-count').props()).toEqual({
+    expect(wrapper.findByTestId('members-count').props()).toMatchObject({
       tooltipText: 'Direct members',
       iconName: 'users',
       stat: group.groupMembersCount.toString(),
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
index 1960e29a5e21d846c20cbc0077cf19af2949971d..faf7fcf8658958c973134a57b90acaf9fe2e818f 100644
--- a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
@@ -47,16 +47,20 @@ jest.mock('~/api/projects_api');
 describe('ProjectsListItem', () => {
   let wrapper;
 
-  const [{ permissions, ...project }] = convertObjectPropsToCamelCase(projects, { deep: true });
+  const [{ permissions, ...mockProject }] = convertObjectPropsToCamelCase(projects, { deep: true });
 
-  const defaultPropsData = {
-    project: {
-      ...project,
-      accessLevel: {
-        integerValue: permissions.projectAccess.accessLevel,
-      },
-      avatarUrl: 'avatar.jpg',
+  const project = {
+    ...mockProject,
+    accessLevel: {
+      integerValue: permissions.projectAccess.accessLevel,
     },
+    avatarUrl: 'avatar.jpg',
+    avatarLabel: mockProject.nameWithNamespace,
+    isForked: false,
+  };
+
+  const defaultPropsData = {
+    project,
   };
 
   const createComponent = ({ propsData = {} } = {}) => {
@@ -69,9 +73,9 @@ describe('ProjectsListItem', () => {
   };
 
   const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled);
-  const findMergeRequestsLink = () => wrapper.findByTestId('mrs-btn');
-  const findIssuesLink = () => wrapper.findByTestId('issues-btn');
-  const findForksLink = () => wrapper.findByTestId('forks-btn');
+  const findMergeRequestsStat = () => wrapper.findByTestId('mrs-btn');
+  const findIssuesStat = () => wrapper.findByTestId('issues-btn');
+  const findForksStat = () => wrapper.findByTestId('forks-btn');
   const findProjectTopics = () => wrapper.findByTestId('project-topics');
   const findPopover = () => findProjectTopics().findComponent(GlPopover);
   const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon);
@@ -97,13 +101,13 @@ describe('ProjectsListItem', () => {
     const avatarLabeled = findAvatarLabeled();
 
     expect(avatarLabeled.props()).toMatchObject({
-      label: project.name,
+      label: project.nameWithNamespace,
       labelLink: project.webUrl,
     });
 
     expect(avatarLabeled.attributes()).toMatchObject({
       'entity-id': project.id.toString(),
-      'entity-name': project.name,
+      'entity-name': project.nameWithNamespace,
       src: defaultPropsData.project.avatarUrl,
       shape: 'rect',
     });
@@ -143,7 +147,7 @@ describe('ProjectsListItem', () => {
   describe('when access level is not available', () => {
     beforeEach(() => {
       createComponent({
-        propsData: { project },
+        propsData: { project: { ...project, accessLevel: null } },
       });
     });
 
@@ -175,13 +179,12 @@ describe('ProjectsListItem', () => {
   it('renders stars count', () => {
     createComponent();
 
-    const starsLink = wrapper.findByTestId('stars-btn');
-    const tooltip = getBinding(starsLink.element, 'gl-tooltip');
-
-    expect(tooltip.value).toBe(ProjectsListItem.i18n.stars);
-    expect(starsLink.attributes('href')).toBe(`${project.webUrl}/-/starrers`);
-    expect(starsLink.text()).toBe(project.starCount.toString());
-    expect(starsLink.findComponent(GlIcon).props('name')).toBe('star-o');
+    expect(wrapper.findByTestId('stars-btn').props()).toEqual({
+      href: `${project.webUrl}/-/starrers`,
+      tooltipText: 'Stars',
+      iconName: 'star-o',
+      stat: project.starCount.toString(),
+    });
   });
 
   describe.each`
@@ -233,13 +236,12 @@ describe('ProjectsListItem', () => {
         },
       });
 
-      const mergeRequestsLink = findMergeRequestsLink();
-      const tooltip = getBinding(mergeRequestsLink.element, 'gl-tooltip');
-
-      expect(tooltip.value).toBe(ProjectsListItem.i18n.mergeRequests);
-      expect(mergeRequestsLink.attributes('href')).toBe(`${project.webUrl}/-/merge_requests`);
-      expect(mergeRequestsLink.text()).toBe('5');
-      expect(mergeRequestsLink.findComponent(GlIcon).props('name')).toBe('merge-request');
+      expect(findMergeRequestsStat().props()).toEqual({
+        href: `${project.webUrl}/-/merge_requests`,
+        tooltipText: 'Merge requests',
+        iconName: 'merge-request',
+        stat: '5',
+      });
     });
   });
 
@@ -254,7 +256,7 @@ describe('ProjectsListItem', () => {
         },
       });
 
-      expect(findMergeRequestsLink().exists()).toBe(false);
+      expect(findMergeRequestsStat().exists()).toBe(false);
     });
   });
 
@@ -262,13 +264,12 @@ describe('ProjectsListItem', () => {
     it('renders issues count', () => {
       createComponent();
 
-      const issuesLink = findIssuesLink();
-      const tooltip = getBinding(issuesLink.element, 'gl-tooltip');
-
-      expect(tooltip.value).toBe(ProjectsListItem.i18n.issues);
-      expect(issuesLink.attributes('href')).toBe(`${project.webUrl}/-/issues`);
-      expect(issuesLink.text()).toBe(project.openIssuesCount.toString());
-      expect(issuesLink.findComponent(GlIcon).props('name')).toBe('issues');
+      expect(findIssuesStat().props()).toEqual({
+        href: `${project.webUrl}/-/issues`,
+        tooltipText: 'Issues',
+        iconName: 'issues',
+        stat: project.openIssuesCount.toString(),
+      });
     });
   });
 
@@ -283,7 +284,7 @@ describe('ProjectsListItem', () => {
         },
       });
 
-      expect(findIssuesLink().exists()).toBe(false);
+      expect(findIssuesStat().exists()).toBe(false);
     });
   });
 
@@ -291,13 +292,12 @@ describe('ProjectsListItem', () => {
     it('renders forks count', () => {
       createComponent();
 
-      const forksLink = findForksLink();
-      const tooltip = getBinding(forksLink.element, 'gl-tooltip');
-
-      expect(tooltip.value).toBe(ProjectsListItem.i18n.forks);
-      expect(forksLink.attributes('href')).toBe(`${project.webUrl}/-/forks`);
-      expect(forksLink.text()).toBe(project.openIssuesCount.toString());
-      expect(forksLink.findComponent(GlIcon).props('name')).toBe('fork');
+      expect(findForksStat().props()).toEqual({
+        href: `${project.webUrl}/-/forks`,
+        tooltipText: 'Forks',
+        iconName: 'fork',
+        stat: project.forksCount.toString(),
+      });
     });
   });
 
@@ -320,7 +320,7 @@ describe('ProjectsListItem', () => {
         },
       });
 
-      expect(findForksLink().exists()).toBe(false);
+      expect(findForksStat().exists()).toBe(false);
     });
   });
 
diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_description_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_description_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..96b2ea35aa7c905ab94d6e12a61f449db90bd882
--- /dev/null
+++ b/spec/frontend/vue_shared/components/resource_lists/list_item_description_spec.js
@@ -0,0 +1,25 @@
+import { GlTruncateText } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue';
+
+describe('ListItemDescription', () => {
+  let wrapper;
+
+  const defaultPropsData = {
+    descriptionHtml: '<p>Dolorem dolorem omnis impedit cupiditate pariatur officia velit.</p>',
+  };
+
+  const createComponent = ({ propsData = {} } = {}) => {
+    wrapper = shallowMountExtended(ListItemDescription, {
+      propsData: { ...defaultPropsData, ...propsData },
+    });
+  };
+
+  it('renders description', () => {
+    createComponent();
+
+    expect(wrapper.findComponent(GlTruncateText).element.firstChild.innerHTML).toBe(
+      defaultPropsData.descriptionHtml,
+    );
+  });
+});
diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js
index 559e777360cdf2eab365c53f5b051940c217ee20..257de50d53559eed2a770ec3d5ef6e8fe58e7ab9 100644
--- a/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js
@@ -1,6 +1,7 @@
 import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import ListItem from '~/vue_shared/components/resource_lists/list_item.vue';
+import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue';
 import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
 import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
 import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
@@ -27,20 +28,21 @@ describe('ListItem', () => {
     resource,
   };
 
-  const createComponent = ({ propsData = {}, stubs = {} } = {}) => {
+  const createComponent = ({ propsData = {}, stubs = {}, scopedSlots = {} } = {}) => {
     wrapper = shallowMountExtended(ListItem, {
       propsData: { ...defaultPropsData, ...propsData },
       scopedSlots: {
         'avatar-meta': '<div data-testid="avatar-meta"></div>',
         stats: '<div data-testid="stats"></div>',
         footer: '<div data-testid="footer"></div>',
+        ...scopedSlots,
       },
       stubs,
     });
   };
 
   const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled);
-  const findGroupDescription = () => wrapper.findByTestId('description');
+  const findDescription = () => wrapper.findComponent(ListItemDescription);
   const findListActions = () => wrapper.findComponent(ListActions);
   const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
 
@@ -80,35 +82,47 @@ describe('ListItem', () => {
     expect(wrapper.findByTestId('footer').exists()).toBe(true);
   });
 
-  describe('when resource has a description', () => {
-    it('renders description', () => {
-      const descriptionHtml = '<p>Foo bar</p>';
-
+  describe('when avatar-default slot is provided', () => {
+    beforeEach(() => {
       createComponent({
-        propsData: {
-          resource: {
-            ...resource,
-            descriptionHtml,
-          },
-        },
+        scopedSlots: { 'avatar-default': '<div data-testid="avatar-default"></div>' },
       });
+    });
 
-      expect(findGroupDescription().element.innerHTML).toBe(descriptionHtml);
+    it('renders slot instead of description', () => {
+      expect(wrapper.findByTestId('avatar-default').exists()).toBe(true);
+      expect(findDescription().exists()).toBe(false);
     });
   });
 
-  describe('when resource does not have a description', () => {
-    it('does not render description', () => {
-      createComponent({
-        propsData: {
-          resource: {
-            ...resource,
-            descriptionHtml: null,
+  describe('when avatar-default slot is not provided', () => {
+    describe('when resource has a description', () => {
+      beforeEach(() => {
+        createComponent();
+      });
+
+      it('renders description', () => {
+        expect(findDescription().props('descriptionHtml')).toBe(
+          defaultPropsData.resource.descriptionHtml,
+        );
+      });
+    });
+
+    describe('when resource does not have a description', () => {
+      beforeEach(() => {
+        createComponent({
+          propsData: {
+            resource: {
+              ...resource,
+              descriptionHtml: null,
+            },
           },
-        },
+        });
       });
 
-      expect(findGroupDescription().exists()).toBe(false);
+      it('does not render description', () => {
+        expect(findDescription().exists()).toBe(false);
+      });
     });
   });
 
@@ -130,17 +144,37 @@ describe('ListItem', () => {
     });
   });
 
-  describe('when resource has available actions', () => {
-    it('displays actions dropdown', () => {
-      createComponent({
-        propsData: {
+  describe('when actions prop is passed', () => {
+    describe('when resource has available actions', () => {
+      it('displays actions dropdown', () => {
+        createComponent({
+          propsData: {
+            actions,
+          },
+        });
+
+        expect(findListActions().props()).toMatchObject({
           actions,
-        },
+          availableActions: resource.availableActions,
+        });
+      });
+    });
+
+    describe('when resource does not have available actions', () => {
+      beforeEach(() => {
+        createComponent({
+          propsData: {
+            actions,
+            resource: {
+              ...resource,
+              availableActions: [],
+            },
+          },
+        });
       });
 
-      expect(findListActions().props()).toMatchObject({
-        actions,
-        availableActions: resource.availableActions,
+      it('does not display actions dropdown', () => {
+        expect(findListActions().exists()).toBe(false);
       });
     });
   });
@@ -155,12 +189,20 @@ describe('ListItem', () => {
     });
   });
 
-  describe('when resource does not have available actions', () => {
+  describe('when actions slot is provided', () => {
     beforeEach(() => {
-      createComponent();
+      createComponent({
+        propsData: {
+          actions,
+        },
+        scopedSlots: {
+          actions: '<div data-testid="actions"></div>',
+        },
+      });
     });
 
-    it('does not display actions dropdown', () => {
+    it('renders slot instead of list actions component', () => {
+      expect(wrapper.findByTestId('actions').exists()).toBe(true);
       expect(findListActions().exists()).toBe(false);
     });
   });
diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js
index 4d3f0d713cd61c1de3efa130e405ea48543ab47f..6b833e2339c2ce724586cb1296921d731c39fc36 100644
--- a/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js
+++ b/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlLink } from '@gitlab/ui';
 import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue';
@@ -21,13 +21,26 @@ describe('ListItemStat', () => {
     });
   };
 
-  it('renders stat with icon and tooltip', () => {
+  it('renders stat in div with icon and tooltip', () => {
     createComponent();
 
     const tooltip = getBinding(wrapper.element, 'gl-tooltip');
 
+    expect(wrapper.element.tagName).toBe('DIV');
     expect(wrapper.text()).toBe(defaultPropsData.stat);
     expect(tooltip.value).toBe(defaultPropsData.tooltipText);
     expect(wrapper.findComponent(GlIcon).props('name')).toBe(defaultPropsData.iconName);
   });
+
+  describe('when href prop is passed', () => {
+    const href = 'http://gdk.test:3000/foo/bar/-/forks`';
+
+    beforeEach(() => {
+      createComponent({ propsData: { href } });
+    });
+
+    it('renders `GlLink` component', () => {
+      expect(wrapper.findComponent(GlLink).attributes('href')).toBe(href);
+    });
+  });
 });