diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index 710207db0c7416934af32fef28402e551d9d6bf8..4699ef5a51c7b6d2539878d16406d4318750747f 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -1,4 +1,5 @@
 import Vue from 'vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
 import eventHub from '../eventhub';
 
 const Store = gl.issueBoards.BoardsStore;
@@ -38,6 +39,9 @@ gl.issueBoards.IssueCardInner = Vue.extend({
       maxCounter: 99,
     };
   },
+  components: {
+    userAvatarLink,
+  },
   computed: {
     numberOverLimit() {
       return this.issue.assignees.length - this.limitBeforeCounter;
@@ -146,23 +150,16 @@ gl.issueBoards.IssueCardInner = Vue.extend({
           </span>
         </h4>
         <div class="card-assignee">
-          <a
-            class="has-tooltip js-no-trigger"
-            :href="assigneeUrl(assignee)"
-            :title="assigneeUrlTitle(assignee)"
+          <user-avatar-link
             v-for="(assignee, index) in issue.assignees"
             v-if="shouldRenderAssignee(index)"
-            data-container="body"
-            data-placement="bottom"
-          >
-            <img
-              class="avatar avatar-inline s20"
-              :src="assignee.avatar"
-              width="20"
-              height="20"
-              :alt="avatarUrlTitle(assignee)"
-            />
-          </a>
+            class="js-no-trigger"
+            :link-href="assigneeUrl(assignee)"
+            :img-alt="avatarUrlTitle(assignee)"
+            :img-src="assignee.avatar"
+            :tooltip-text="assigneeUrlTitle(assignee)"
+            tooltip-placement="bottom"
+          />
           <span
             class="avatar-counter has-tooltip"
             :title="assigneeCounterTooltip"
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
index 0d9ad197abf18df85f21b52104ee6f8ba7734378..eeb61826ace1970f448393fd00cc9dc0f9ca1a1e 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js
@@ -1,6 +1,7 @@
 /* eslint-disable no-param-reassign */
 
 import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 
 const global = window.gl || (window.gl = {});
 global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +11,9 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
     items: Array,
     stage: Object,
   },
+  components: {
+    userAvatarImage,
+  },
   template: `
     <div>
       <div class="events-description">
@@ -19,7 +23,8 @@ global.cycleAnalytics.StageCodeComponent = Vue.extend({
       <ul class="stage-event-list">
         <li v-for="mergeRequest in items" class="stage-event-item">
           <div class="item-details">
-            <img class="avatar" :src="mergeRequest.author.avatarUrl">
+            <!-- FIXME: Pass an alt attribute here for accessibility -->
+            <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
             <h5 class="item-title merge-merquest-title">
               <a :href="mergeRequest.url">
                 {{ mergeRequest.title }}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
index ad285874643fd879ed4715bfb287067b81301b68..09fb390787d572903dc2aa63ae0f64f81efb4130 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js
@@ -1,6 +1,6 @@
 /* eslint-disable no-param-reassign */
-
 import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 
 const global = window.gl || (window.gl = {});
 global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
     items: Array,
     stage: Object,
   },
+  components: {
+    userAvatarImage,
+  },
   template: `
     <div>
       <div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageIssueComponent = Vue.extend({
       <ul class="stage-event-list">
         <li v-for="issue in items" class="stage-event-item">
           <div class="item-details">
-            <img class="avatar" :src="issue.author.avatarUrl">
+            <!-- FIXME: Pass an alt attribute here for accessibility -->
+            <user-avatar-image :img-src="issue.author.avatarUrl"/>
             <h5 class="item-title issue-title">
               <a class="issue-title" :href="issue.url">
                 {{ issue.title }}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
index dec1704395e268d870cd3cd98f129dc658649c40..cd7a94b67c14a7ca51802bfdc3ef1fc0308a290c 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js
@@ -1,5 +1,6 @@
 /* eslint-disable no-param-reassign */
 import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 import iconCommit from '../svg/icon_commit.svg';
 
 const global = window.gl || (window.gl = {});
@@ -10,11 +11,12 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
     items: Array,
     stage: Object,
   },
-
+  components: {
+    userAvatarImage,
+  },
   data() {
     return { iconCommit };
   },
-
   template: `
     <div>
       <div class="events-description">
@@ -24,7 +26,8 @@ global.cycleAnalytics.StagePlanComponent = Vue.extend({
       <ul class="stage-event-list">
         <li v-for="commit in items" class="stage-event-item">
           <div class="item-details item-conmmit-component">
-            <img class="avatar" :src="commit.author.avatarUrl">
+            <!-- FIXME: Pass an alt attribute here for accessibility -->
+            <user-avatar-image :img-src="commit.author.avatarUrl"/>
             <h5 class="item-title commit-title">
               <a :href="commit.commitUrl">
                 {{ commit.title }}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
index a14ebc3ece9429bdb76a6371ae711f68bc94797a..bdf86b4ff3ca6d6119892b2bc702f236756acf42 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js
@@ -1,6 +1,6 @@
 /* eslint-disable no-param-reassign */
-
 import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 
 const global = window.gl || (window.gl = {});
 global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
     items: Array,
     stage: Object,
   },
+  components: {
+    userAvatarImage,
+  },
   template: `
     <div>
       <div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageProductionComponent = Vue.extend({
       <ul class="stage-event-list">
         <li v-for="issue in items" class="stage-event-item">
           <div class="item-details">
-            <img class="avatar" :src="issue.author.avatarUrl">
+            <!-- FIXME: Pass an alt attribute here for accessibility -->
+            <user-avatar-image :img-src="issue.author.avatarUrl"/>
             <h5 class="item-title issue-title">
               <a class="issue-title" :href="issue.url">
                 {{ issue.title }}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
index 1a5bf9bc0b5118955dce4cc33c9f06e1995f556d..cfb7a4ab5760b123f8a62cb74c37a1f35c99be99 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js
@@ -1,6 +1,6 @@
 /* eslint-disable no-param-reassign */
-
 import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 
 const global = window.gl || (window.gl = {});
 global.cycleAnalytics = global.cycleAnalytics || {};
@@ -10,6 +10,9 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
     items: Array,
     stage: Object,
   },
+  components: {
+    userAvatarImage,
+  },
   template: `
     <div>
       <div class="events-description">
@@ -19,7 +22,8 @@ global.cycleAnalytics.StageReviewComponent = Vue.extend({
       <ul class="stage-event-list">
         <li v-for="mergeRequest in items" class="stage-event-item">
           <div class="item-details">
-            <img class="avatar" :src="mergeRequest.author.avatarUrl">
+            <!-- FIXME: Pass an alt attribute here for accessibility -->
+            <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/>
             <h5 class="item-title merge-merquest-title">
               <a :href="mergeRequest.url">
                 {{ mergeRequest.title }}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
index 1f7c673b1d45197a748f44923fb8fd963831bf5c..97a849c4febe6ca9e99d0cedd4c48edb7ce61fc4 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
+++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js
@@ -1,5 +1,6 @@
 /* eslint-disable no-param-reassign */
 import Vue from 'vue';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 import iconBranch from '../svg/icon_branch.svg';
 
 const global = window.gl || (window.gl = {});
@@ -13,6 +14,9 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
   data() {
     return { iconBranch };
   },
+  components: {
+    userAvatarImage,
+  },
   template: `
     <div>
       <div class="events-description">
@@ -22,7 +26,8 @@ global.cycleAnalytics.StageStagingComponent = Vue.extend({
       <ul class="stage-event-list">
         <li v-for="build in items" class="stage-event-item item-build-component">
           <div class="item-details">
-            <img class="avatar" :src="build.author.avatarUrl">
+            <!-- FIXME: Pass an alt attribute here for accessibility -->
+            <user-avatar-image :img-src="build.author.avatarUrl"/>
             <h5 class="item-title">
               <a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
               <i class="fa fa-code-fork"></i>
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index 5f533b5761c073ac1c9deff591b0f8fee058d0a3..517bdb6be092a9acdc20dfe8878d7877748ca490 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -3,6 +3,7 @@
 
 import Vue from 'vue';
 import collapseIcon from '../icons/collapse_icon.svg';
+import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
 
 const DiffNoteAvatars = Vue.extend({
   props: ['discussionId'],
@@ -15,22 +16,24 @@ const DiffNoteAvatars = Vue.extend({
       collapseIcon,
     };
   },
+  components: {
+    userAvatarImage,
+  },
   template: `
     <div class="diff-comment-avatar-holders"
       v-show="notesCount !== 0">
       <div v-if="!isVisible">
-        <img v-for="note in notesSubset"
-          class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar"
-          width="19"
-          height="19"
-          role="button"
-          data-container="body"
-          data-placement="top"
-          data-html="true"
+        <!-- FIXME: Pass an alt attribute here for accessibility -->
+        <user-avatar-image
+          v-for="note in notesSubset"
+          class="diff-comment-avatar js-diff-comment-avatar"
+          @click.native="clickedAvatar($event)"
+          :img-src="note.authorAvatar"
+          :tooltip-text="getTooltipText(note)"
           :data-line-type="lineType"
-          :title="note.authorName + ': ' + note.noteTruncated"
-          :src="note.authorAvatar"
-          @click="clickedAvatar($event)" />
+          :size="19"
+          data-html="true"
+        />
         <span v-if="notesCount > shownAvatars"
           class="diff-comments-more-count has-tooltip js-diff-comment-avatar"
           data-container="body"
@@ -150,6 +153,9 @@ const DiffNoteAvatars = Vue.extend({
     setDiscussionVisible() {
       this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible');
     },
+    getTooltipText(note) {
+      return `${note.authorName}: ${note.noteTruncated}`;
+    },
   },
 });
 
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 1f01629aa1b2a95f2656bed10d6cdd53262ab797..012ff1f975b28f205a2f0a2bd078333aacf1bc58 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,6 +1,7 @@
 <script>
 import Timeago from 'timeago.js';
 import _ from 'underscore';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
 import '../../lib/utils/text_utility';
 import ActionsComponent from './environment_actions.vue';
 import ExternalUrlComponent from './environment_external_url.vue';
@@ -20,6 +21,7 @@ const timeagoInstance = new Timeago();
 
 export default {
   components: {
+    userAvatarLink,
     'commit-component': CommitComponent,
     'actions-component': ActionsComponent,
     'external-url-component': ExternalUrlComponent,
@@ -468,15 +470,13 @@ export default {
 
       <span v-if="!model.isFolder && deploymentHasUser">
         by
-        <a
-          :href="deploymentUser.web_url"
-          class="js-deploy-user-container">
-          <img
-            class="avatar has-tooltip s20"
-            :src="deploymentUser.avatar_url"
-            :alt="userImageAltDescription"
-            :title="deploymentUser.username" />
-        </a>
+        <user-avatar-link
+          class="js-deploy-user-container"
+          :link-href="deploymentUser.web_url"
+          :img-src="deploymentUser.avatar_url"
+          :img-alt="userImageAltDescription"
+          :tooltip-text="deploymentUser.username"
+        />
       </span>
     </td>
 
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js
index ea8aaca6c9c3a487377a59cfa6a233f5c351179a..7cd2e0f936602951f48e704a96fc6d3a498c9bcc 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.js
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.js
@@ -1,3 +1,5 @@
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+
 export default {
   props: [
     'pipeline',
@@ -7,6 +9,9 @@ export default {
       return !!this.pipeline.user;
     },
   },
+  components: {
+    userAvatarLink,
+  },
   template: `
     <td>
       <a
@@ -15,18 +20,13 @@ export default {
         <span class="pipeline-id">#{{pipeline.id}}</span>
       </a>
       <span>by</span>
-      <a
-        class="js-pipeline-url-user"
+      <user-avatar-link
         v-if="user"
-        :href="pipeline.user.web_url">
-        <img
-          v-if="user"
-          class="avatar has-tooltip s20 "
-          :title="pipeline.user.name"
-          data-container="body"
-          :src="pipeline.user.avatar_url"
-        >
-      </a>
+        class="js-pipeline-url-user"
+        :link-href="pipeline.user.web_url"
+        :img-src="pipeline.user.avatar_url"
+        :tooltip-text="pipeline.user.name"
+      />
       <span
         v-if="!user"
         class="js-pipeline-url-api api">
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
index 9b060a0a35fac66e09d1267df96d5220edcf0694..23bc5fbc03430d59b364856e757707639da661c8 100644
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ b/app/assets/javascripts/vue_shared/components/commit.js
@@ -1,4 +1,5 @@
 import commitIconSvg from 'icons/_icon_commit.svg';
+import userAvatarLink from './user_avatar/user_avatar_link.vue';
 
 export default {
   props: {
@@ -110,6 +111,9 @@ export default {
     return { commitIconSvg };
   },
 
+  components: {
+    userAvatarLink,
+  },
   template: `
     <div class="branch-commit">
 
@@ -133,16 +137,14 @@ export default {
 
       <p class="commit-title">
         <span v-if="title">
-          <a v-if="hasAuthor"
+          <user-avatar-link
+            v-if="hasAuthor"
             class="avatar-image-container"
-            :href="author.web_url">
-            <img
-              class="avatar has-tooltip s20"
-              :src="author.avatar_url"
-              :alt="userImageAltDescription"
-              :title="author.username" />
-          </a>
-
+            :link-href="author.web_url"
+            :img-src="author.avatar_url"
+            :img-alt="userImageAltDescription"
+            :tooltip-text="author.username"
+          />
           <a class="commit-row-message"
             :href="commitUrl">
             {{title}}
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b8db6afda12a806c4ff9f454808690a14a2aae59
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -0,0 +1,80 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar that
+  does not need to link to the user's profile. The image and an optional
+  tooltip can be configured by props passed to this component.
+
+  Sample configuration:
+
+  <user-avatar-image
+    :img-src="userAvatarSrc"
+    :img-alt="tooltipText"
+    :tooltip-text="tooltipText"
+    tooltip-placement="top"
+  />
+
+*/
+
+import defaultAvatarUrl from 'images/no_avatar.png';
+import TooltipMixin from '../../mixins/tooltip';
+
+export default {
+  name: 'UserAvatarImage',
+  mixins: [TooltipMixin],
+  props: {
+    imgSrc: {
+      type: String,
+      required: false,
+      default: defaultAvatarUrl,
+    },
+    cssClasses: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    imgAlt: {
+      type: String,
+      required: false,
+      default: 'user avatar',
+    },
+    size: {
+      type: Number,
+      required: false,
+      default: 20,
+    },
+    tooltipText: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    tooltipPlacement: {
+      type: String,
+      required: false,
+      default: 'top',
+    },
+  },
+  computed: {
+    tooltipContainer() {
+      return this.tooltipText ? 'body' : null;
+    },
+    avatarSizeClass() {
+      return `s${this.size}`;
+    },
+  },
+};
+</script>
+
+<template>
+  <img
+    class="avatar"
+    :class="[avatarSizeClass, cssClasses]"
+    :src="imgSrc"
+    :width="size"
+    :height="size"
+    :alt="imgAlt"
+    :data-container="tooltipContainer"
+    :data-placement="tooltipPlacement"
+    :title="tooltipText"
+    ref="tooltip"
+  />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
new file mode 100644
index 0000000000000000000000000000000000000000..95898d54cf7bdc50904fd8bf6c001b9bc9b11c0f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -0,0 +1,80 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+  a clickable link (likely to the user's profile). The link, image, and
+  tooltip can be configured by props passed to this component.
+
+  Sample configuration:
+
+  <user-avatar-link
+    :link-href="userProfileUrl"
+    :img-src="userAvatarSrc"
+    :img-alt="tooltipText"
+    :img-size="20"
+    :tooltip-text="tooltipText"
+    tooltip-placement="top"
+  />
+
+*/
+
+import userAvatarImage from './user_avatar_image.vue';
+
+export default {
+  name: 'UserAvatarLink',
+  components: {
+    userAvatarImage,
+  },
+  props: {
+    linkHref: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    imgSrc: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    imgAlt: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    imgCssClasses: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    imgSize: {
+      type: Number,
+      required: false,
+      default: 20,
+    },
+    tooltipText: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    tooltipPlacement: {
+      type: String,
+      required: false,
+      default: 'top',
+    },
+  },
+};
+</script>
+
+<template>
+  <a
+    class="user-avatar-link"
+    :href="linkHref">
+    <user-avatar-image
+      :img-src="imgSrc"
+      :img-alt="imgAlt"
+      :css-classes="imgCssClasses"
+      :size="imgSize"
+      :tooltip-text="tooltipText"
+      :tooltip-placement="tooltipPlacement"
+    />
+  </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d2ff2ac006e3f33edfd90d73bf43bb6f109cbcfa
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
@@ -0,0 +1,45 @@
+<script>
+
+/* This is a re-usable vue component for rendering a user avatar svg (typically
+  for a blank state). It will receive styles comparable to the user avatar,
+  but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
+  The svg and avatar size can be configured by props passed to this component.
+
+  Sample configuration:
+
+  <user-avatar-svg
+    :svg="potentialApproverSvg"
+    :size="20"
+  />
+
+*/
+
+export default {
+  props: {
+    svg: {
+      type: String,
+      required: true,
+    },
+    size: {
+      type: Number,
+      required: false,
+      default: 20,
+    },
+  },
+  computed: {
+    avatarSizeClass() {
+      return `s${this.size}`;
+    },
+  },
+};
+</script>
+
+<template>
+  <svg
+    :class="avatarSizeClass"
+    :height="size"
+    :width="size"
+    v-html="svg">
+  </svg>
+</template>
+
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
index 91c1ebd5a7de3a70a43f6b10c60f74ed8736d700..4ae2b164d2ebfc1372edb2f22790582cac3323fc 100644
--- a/app/assets/stylesheets/framework/avatar.scss
+++ b/app/assets/stylesheets/framework/avatar.scss
@@ -10,6 +10,8 @@
   border-radius: $avatar_radius;
   border: 1px solid $avatar-border;
   &.s16 { @include avatar-size(16px, 6px); }
+  &.s18 { @include avatar-size(18px, 6px); }
+  &.s19 { @include avatar-size(19px, 6px); }
   &.s20 { @include avatar-size(20px, 7px); }
   &.s24 { @include avatar-size(24px, 8px); }
   &.s26 { @include avatar-size(26px, 8px); }
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 1b7d4e4225813a89dc07272791233cbefbeadebe..ef864e8f6a9da32666484b032f1078912272e9cd 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -65,3 +65,7 @@
     text-decoration: none;
   }
 }
+
+.user-avatar-link {
+  text-decoration: none;
+}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index a42ae7e55a544f32a895294edb675626cc81f304..48d3b7b1d07e6ada1e1c8b893811d58329d4d248 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -68,10 +68,6 @@
       margin: 0;
     }
 
-    .avatar-image-container {
-      text-decoration: none;
-    }
-
     .icon-play {
       height: 13px;
       width: 12px;
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 0781017c89ff1891905ba058a95e8d0e07afc2b5..7bc225968dea561c45b47d51f12ab952dbeb5aa8 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -90,9 +90,9 @@ var config = {
         loader: 'raw-loader',
       },
       {
-        test: /\.gif$/,
+        test: /\.(gif|png)$/,
         loader: 'url-loader',
-        query: { mimetype: 'image/gif' },
+        options: { limit: 2048 },
       },
       {
         test: /\.(worker\.js|pdf|bmpr)$/,
@@ -190,6 +190,7 @@ var config = {
       'emojis':         path.join(ROOT_PATH, 'fixtures/emojis'),
       'empty_states':   path.join(ROOT_PATH, 'app/views/shared/empty_states'),
       'icons':          path.join(ROOT_PATH, 'app/views/shared/icons'),
+      'images':         path.join(ROOT_PATH, 'app/assets/images'),
       'vendor':         path.join(ROOT_PATH, 'vendor/assets/javascripts'),
       'vue$':           'vue/dist/vue.esm.js',
     }
diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb
index b2e170513c4a4b13b2154be79e810b606ce5062a..ccf047d3efa3cd5510b5650640e58db2ae0138be 100644
--- a/spec/features/merge_requests/diff_notes_avatars_spec.rb
+++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb
@@ -91,7 +91,7 @@ feature 'Diff note avatars', feature: true, js: true do
         page.within find("[id='#{position.line_code(project.repository)}']") do
           find('.diff-notes-collapse').click
 
-          expect(first('img.js-diff-comment-avatar')["title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
+          expect(first('img.js-diff-comment-avatar')["data-original-title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}")
         end
       end
 
diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js
index fddde799d01acfbf90fff0be7c4cbb8dbc92f1e6..bd9b4fbfdd3b79c6c3a568323932449c130083d2 100644
--- a/spec/javascripts/boards/issue_card_spec.js
+++ b/spec/javascripts/boards/issue_card_spec.js
@@ -129,7 +129,7 @@ describe('Issue card component', () => {
 
       it('sets title', () => {
         expect(
-          component.$el.querySelector('.card-assignee a').getAttribute('title'),
+          component.$el.querySelector('.card-assignee img').getAttribute('data-original-title'),
         ).toContain(`Assigned to ${user.name}`);
       });
 
diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js
index 53931d67ad78e5f68a3a15d625e693be23e13045..0bcc390570262d3c75e476823664642162832736 100644
--- a/spec/javascripts/pipelines/pipeline_url_spec.js
+++ b/spec/javascripts/pipelines/pipeline_url_spec.js
@@ -60,7 +60,7 @@ describe('Pipeline Url Component', () => {
     expect(
       component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'),
     ).toEqual(mockData.pipeline.user.web_url);
-    expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name);
+    expect(image.getAttribute('data-original-title')).toEqual(mockData.pipeline.user.name);
     expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url);
   });
 
diff --git a/spec/javascripts/vue_shared/components/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js
index 242010ba68817ea5ef53c32263b3ca0f186e3032..0638483e7aab8325577fc9f9e8019424478f007d 100644
--- a/spec/javascripts/vue_shared/components/commit_spec.js
+++ b/spec/javascripts/vue_shared/components/commit_spec.js
@@ -86,7 +86,7 @@ describe('Commit component', () => {
 
       it('Should render the author avatar with title and alt attributes', () => {
         expect(
-          component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('title'),
+          component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('data-original-title'),
         ).toContain(props.author.username);
         expect(
           component.$el.querySelector('.commit-title .avatar-image-container img').getAttribute('alt'),
diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
index 14280751053fb54bf945defa36758ea56fc727a6..286118917e88b3f2a903e0fd02b465f36af03b35 100644
--- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
+++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js
@@ -79,7 +79,7 @@ describe('Pipelines Table Row', () => {
         ).toEqual(pipeline.user.web_url);
 
         expect(
-          component.$el.querySelector('td:nth-child(2) img').getAttribute('title'),
+          component.$el.querySelector('td:nth-child(2) img').getAttribute('data-original-title'),
         ).toEqual(pipeline.user.name);
       });
     });
@@ -102,7 +102,7 @@ describe('Pipelines Table Row', () => {
       }
 
       const commitAuthorLink = commitAuthorElement.getAttribute('href');
-      const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('title');
+      const commitAuthorName = commitAuthorElement.querySelector('img.avatar').getAttribute('data-original-title');
 
       return { commitAuthorElement, commitAuthorLink, commitAuthorName };
     };
diff --git a/spec/javascripts/vue_shared/components/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8daa761027454fe4fa4ade9cddcb067c1427d972
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar_image_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+
+const UserAvatarImageComponent = Vue.extend(UserAvatarImage);
+
+describe('User Avatar Image Component', function () {
+  describe('Initialization', function () {
+    beforeEach(function () {
+      this.propsData = {
+        size: 99,
+        imgSrc: 'myavatarurl.com',
+        imgAlt: 'mydisplayname',
+        cssClasses: 'myextraavatarclass',
+        tooltipText: 'tooltip text',
+        tooltipPlacement: 'bottom',
+      };
+
+      this.userAvatarImage = new UserAvatarImageComponent({
+        propsData: this.propsData,
+      }).$mount();
+    });
+
+    it('should return a defined Vue component', function () {
+      expect(this.userAvatarImage).toBeDefined();
+    });
+
+    it('should have <img> as a child element', function () {
+      expect(this.userAvatarImage.$el.tagName).toBe('IMG');
+    });
+
+    it('should properly compute tooltipContainer', function () {
+      expect(this.userAvatarImage.tooltipContainer).toBe('body');
+    });
+
+    it('should properly render tooltipContainer', function () {
+      expect(this.userAvatarImage.$el.getAttribute('data-container')).toBe('body');
+    });
+
+    it('should properly compute avatarSizeClass', function () {
+      expect(this.userAvatarImage.avatarSizeClass).toBe('s99');
+    });
+
+    it('should properly render img css', function () {
+      const classList = this.userAvatarImage.$el.classList;
+      const containsAvatar = classList.contains('avatar');
+      const containsSizeClass = classList.contains('s99');
+      const containsCustomClass = classList.contains('myextraavatarclass');
+
+      expect(containsAvatar).toBe(true);
+      expect(containsSizeClass).toBe(true);
+      expect(containsCustomClass).toBe(true);
+    });
+  });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..52e450e9ba51962c44262b655b7ccbd532f73439
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar_link_spec.js
@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+describe('User Avatar Link Component', function () {
+  beforeEach(function () {
+    this.propsData = {
+      linkHref: 'myavatarurl.com',
+      imgSize: 99,
+      imgSrc: 'myavatarurl.com',
+      imgAlt: 'mydisplayname',
+      imgCssClasses: 'myextraavatarclass',
+      tooltipText: 'tooltip text',
+      tooltipPlacement: 'bottom',
+    };
+
+    const UserAvatarLinkComponent = Vue.extend(UserAvatarLink);
+
+    this.userAvatarLink = new UserAvatarLinkComponent({
+      propsData: this.propsData,
+    }).$mount();
+
+    this.userAvatarImage = this.userAvatarLink.$children[0];
+  });
+
+  it('should return a defined Vue component', function () {
+    expect(this.userAvatarLink).toBeDefined();
+  });
+
+  it('should have user-avatar-image registered as child component', function () {
+    expect(this.userAvatarLink.$options.components.userAvatarImage).toBeDefined();
+  });
+
+  it('user-avatar-link should have user-avatar-image as child component', function () {
+    expect(this.userAvatarImage).toBeDefined();
+  });
+
+  it('should render <a> as a child element', function () {
+    expect(this.userAvatarLink.$el.tagName).toBe('A');
+  });
+
+  it('should have <img> as a child element', function () {
+    expect(this.userAvatarLink.$el.querySelector('img')).not.toBeNull();
+  });
+
+  it('should return neccessary props as defined', function () {
+    _.each(this.propsData, (val, key) => {
+      expect(this.userAvatarLink[key]).toBeDefined();
+    });
+  });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..b8d639ffbec77c94d54f401c5f394767f61f4e87
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_avatar_svg_spec.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue';
+import avatarSvg from 'icons/_icon_random.svg';
+
+const UserAvatarSvgComponent = Vue.extend(UserAvatarSvg);
+
+describe('User Avatar Svg Component', function () {
+  describe('Initialization', function () {
+    beforeEach(function () {
+      this.propsData = {
+        size: 99,
+        svg: avatarSvg,
+      };
+
+      this.userAvatarSvg = new UserAvatarSvgComponent({
+        propsData: this.propsData,
+      }).$mount();
+    });
+
+    it('should return a defined Vue component', function () {
+      expect(this.userAvatarSvg).toBeDefined();
+    });
+
+    it('should have <svg> as a child element', function () {
+      expect(this.userAvatarSvg.$el.tagName).toEqual('svg');
+      expect(this.userAvatarSvg.$el.innerHTML).toContain('<path');
+    });
+  });
+});