diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index f0ef55f73ebee192ccb0eed844b54aff9f276772..d9c2e55cffe2cbe3d37b2e6d3b9defcef692f7e8 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,5 +1,8 @@
 import * as Sentry from '@sentry/browser';
 import { escape } from 'lodash';
+import Vue from 'vue';
+import { GlAlert } from '@gitlab/ui';
+import { __ } from '~/locale';
 import { spriteIcon } from './lib/utils/common_utils';
 
 const FLASH_TYPES = {
@@ -9,6 +12,12 @@ const FLASH_TYPES = {
   WARNING: 'warning',
 };
 
+const VARIANT_SUCCESS = 'success';
+const VARIANT_WARNING = 'warning';
+const VARIANT_DANGER = 'danger';
+const VARIANT_INFO = 'info';
+const VARIANT_TIP = 'tip';
+
 const FLASH_CLOSED_EVENT = 'flashClosed';
 
 const getCloseEl = (flashEl) => {
@@ -68,6 +77,126 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => {
   getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
 };
 
+/**
+ * Render an alert at the top of the page, or, optionally an
+ * arbitrary existing container.
+ *
+ * This alert is always dismissible.
+ *
+ * Usage:
+ *
+ * 1. Render a new alert
+ *
+ * import { createAlert, ALERT_VARIANTS } from '~/flash';
+ *
+ * createAlert({ message: 'My error message' });
+ * createAlert({ message: 'My warning message', variant: ALERT_VARIANTS.WARNING });
+ *
+ * 2. Dismiss this alert programmatically
+ *
+ * const alert = createAlert({ message: 'Message' });
+ *
+ * // ...
+ *
+ * alert.dismiss();
+ *
+ * 3. Respond to the alert being dismissed
+ *
+ * createAlert({ message: 'Message', onDismiss: () => { ... }});
+ *
+ *  @param {Object} options                    Options to control the flash message
+ *  @param {String} options.message            Alert message text
+ *  @param {String?} options.variant           Which GlAlert variant to use, should be VARIANT_SUCCESS, VARIANT_WARNING, VARIANT_DANGER, VARIANT_INFO or VARIANT_TIP. Defaults to VARIANT_DANGER.
+ *  @param {Object?} options.parent            Reference to parent element under which alert needs to appear. Defaults to `document`.
+ *  @param {Function?} options.onDismiss       Handler to call when this alert is dismissed.
+ *  @param {Object?} options.containerSelector Selector for the container of the alert
+ *  @param {Object?} options.primaryButton     Object describing primary button of alert
+ *    @param {String?} link                    Href of primary button
+ *    @param {String?} text                    Text of primary button
+ *    @param {Function?} clickHandler          Handler to call when primary button is clicked on. The click event is sent as an argument.
+ *  @param {Object?} options.secondaryButton   Object describing secondary button of alert
+ *    @param {String?} link                    Href of secondary button
+ *    @param {String?} text                    Text of secondary button
+ *    @param {Function?} clickHandler          Handler to call when secondary button is clicked on. The click event is sent as an argument.
+ *  @param {Boolean?} options.captureError     Whether to send error to Sentry
+ *  @param {Object} options.error              Error to be captured in Sentry
+ * @returns
+ */
+const createAlert = function createAlert({
+  message,
+  variant = VARIANT_DANGER,
+  parent = document,
+  containerSelector = '.flash-container',
+  primaryButton = null,
+  secondaryButton = null,
+  onDismiss = null,
+  captureError = false,
+  error = null,
+}) {
+  if (captureError && error) Sentry.captureException(error);
+
+  const alertContainer = parent.querySelector(containerSelector);
+  if (!alertContainer) return null;
+
+  const el = document.createElement('div');
+  alertContainer.appendChild(el);
+
+  return new Vue({
+    el,
+    components: {
+      GlAlert,
+    },
+    methods: {
+      /**
+       * Public method to dismiss this alert and removes
+       * this Vue instance.
+       */
+      dismiss() {
+        if (onDismiss) {
+          onDismiss();
+        }
+        this.$destroy();
+        this.$el.parentNode.removeChild(this.$el);
+      },
+    },
+    render(h) {
+      const on = {};
+
+      on.dismiss = () => {
+        this.dismiss();
+      };
+
+      if (primaryButton?.clickHandler) {
+        on.primaryAction = (e) => {
+          primaryButton.clickHandler(e);
+        };
+      }
+      if (secondaryButton?.clickHandler) {
+        on.secondaryAction = (e) => {
+          secondaryButton.clickHandler(e);
+        };
+      }
+
+      return h(
+        GlAlert,
+        {
+          props: {
+            dismissible: true,
+            dismissLabel: __('Dismiss'),
+            variant,
+            primaryButtonLink: primaryButton?.link,
+            primaryButtonText: primaryButton?.text,
+            secondaryButtonLink: secondaryButton?.link,
+            secondaryButtonText: secondaryButton?.text,
+          },
+          on,
+        },
+        message,
+      );
+    },
+  });
+};
+
 /*
  *  Flash banner supports different types of Flash configurations
  *  along with ability to provide actionConfig which can be used to show
@@ -82,8 +211,8 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => {
  *    @param {String} title                   Title of action
  *    @param {Function} clickHandler          Method to call when action is clicked on
  *  @param {Boolean} options.fadeTransition   Boolean to determine whether to fade the alert out
- *  @param {Boolean} options.captureError     Boolean to determine whether to send error to sentry
- *  @param {Object} options.error              Error to be captured in sentry
+ *  @param {Boolean} options.captureError     Boolean to determine whether to send error to Sentry
+ *  @param {Object} options.error             Error to be captured in Sentry
  */
 const createFlash = function createFlash({
   message,
@@ -134,4 +263,10 @@ export {
   addDismissFlashClickListener,
   FLASH_TYPES,
   FLASH_CLOSED_EVENT,
+  createAlert,
+  VARIANT_SUCCESS,
+  VARIANT_WARNING,
+  VARIANT_DANGER,
+  VARIANT_INFO,
+  VARIANT_TIP,
 };
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index f8220553db60df35efb42d70a4d356da69e3b1e8..2bb4ae7d34ebbce0981c6485dd8b15b8e7bf507d 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,6 +1,6 @@
 <script>
 import { GlBadge, GlLink } from '@gitlab/ui';
-import createFlash from '~/flash';
+import { createAlert } from '~/flash';
 import { fetchPolicies } from '~/lib/graphql';
 import { updateHistory } from '~/lib/utils/url_utility';
 
@@ -95,7 +95,7 @@ export default {
         };
       },
       error(error) {
-        createFlash({ message: I18N_FETCH_ERROR });
+        createAlert({ message: I18N_FETCH_ERROR });
 
         this.reportToSentry(error);
       },
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index 30a1c8af4142aad2173567a921566a687f71bca9..b51daf0e4dc1742da6bdd48671ce81167a529b7b 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -75,6 +75,10 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
   .flash-action {
     display: inline-block;
   }
+
+  .gl-alert {
+    @include gl-my-4;
+  }
 }
 
 @include media-breakpoint-down(sm) {
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
index fc736f2d15505313ecdc282baf701c5f0f2177f5..d5451ec20648a9d93eda5202af8b25dd0003e7f1 100644
--- a/spec/frontend/flash_spec.js
+++ b/spec/frontend/flash_spec.js
@@ -1,9 +1,12 @@
 import * as Sentry from '@sentry/browser';
+import { setHTMLFixture } from 'helpers/fixtures';
 import createFlash, {
   hideFlash,
   addDismissFlashClickListener,
   FLASH_TYPES,
   FLASH_CLOSED_EVENT,
+  createAlert,
+  VARIANT_WARNING,
 } from '~/flash';
 
 jest.mock('@sentry/browser');
@@ -68,6 +71,236 @@ describe('Flash', () => {
     });
   });
 
+  describe('createAlert', () => {
+    const mockMessage = 'a message';
+    let alert;
+
+    describe('no flash-container', () => {
+      it('does not add to the DOM', () => {
+        alert = createAlert({ message: mockMessage });
+
+        expect(alert).toBeNull();
+        expect(document.querySelector('.gl-alert')).toBeNull();
+      });
+    });
+
+    describe('with flash-container', () => {
+      beforeEach(() => {
+        setHTMLFixture('<div class="flash-container"></div>');
+      });
+
+      afterEach(() => {
+        if (alert) {
+          alert.$destroy();
+        }
+        document.querySelector('.flash-container')?.remove();
+      });
+
+      it('adds alert element into the document by default', () => {
+        alert = createAlert({ message: mockMessage });
+
+        expect(document.querySelector('.flash-container').textContent.trim()).toBe(mockMessage);
+        expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull();
+      });
+
+      it('adds flash of a warning type', () => {
+        alert = createAlert({ message: mockMessage, variant: VARIANT_WARNING });
+
+        expect(
+          document.querySelector('.flash-container .gl-alert.gl-alert-warning'),
+        ).not.toBeNull();
+      });
+
+      it('escapes text', () => {
+        alert = createAlert({ message: '<script>alert("a");</script>' });
+
+        const html = document.querySelector('.flash-container').innerHTML;
+
+        expect(html).toContain('&lt;script&gt;alert("a");&lt;/script&gt;');
+        expect(html).not.toContain('<script>alert("a");</script>');
+      });
+
+      it('adds alert into specified container', () => {
+        setHTMLFixture(`
+            <div class="my-alert-container"></div>
+            <div class="my-other-container"></div>
+        `);
+
+        alert = createAlert({ message: mockMessage, containerSelector: '.my-alert-container' });
+
+        expect(document.querySelector('.my-alert-container .gl-alert')).not.toBeNull();
+        expect(document.querySelector('.my-alert-container').innerText.trim()).toBe(mockMessage);
+
+        expect(document.querySelector('.my-other-container .gl-alert')).toBeNull();
+        expect(document.querySelector('.my-other-container').innerText.trim()).toBe('');
+      });
+
+      it('adds alert into specified parent', () => {
+        setHTMLFixture(`
+            <div id="my-parent">
+              <div class="flash-container"></div>
+            </div>
+            <div id="my-other-parent">
+              <div class="flash-container"></div>
+            </div>
+        `);
+
+        alert = createAlert({ message: mockMessage, parent: document.getElementById('my-parent') });
+
+        expect(document.querySelector('#my-parent .flash-container .gl-alert')).not.toBeNull();
+        expect(document.querySelector('#my-parent .flash-container').innerText.trim()).toBe(
+          mockMessage,
+        );
+
+        expect(document.querySelector('#my-other-parent .flash-container .gl-alert')).toBeNull();
+        expect(document.querySelector('#my-other-parent .flash-container').innerText.trim()).toBe(
+          '',
+        );
+      });
+
+      it('removes element after clicking', () => {
+        alert = createAlert({ message: mockMessage });
+
+        expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull();
+
+        document.querySelector('.gl-dismiss-btn').click();
+
+        expect(document.querySelector('.flash-container .gl-alert')).toBeNull();
+      });
+
+      it('does not capture error using Sentry', () => {
+        alert = createAlert({
+          message: mockMessage,
+          captureError: false,
+          error: new Error('Error!'),
+        });
+
+        expect(Sentry.captureException).not.toHaveBeenCalled();
+      });
+
+      it('captures error using Sentry', () => {
+        alert = createAlert({
+          message: mockMessage,
+          captureError: true,
+          error: new Error('Error!'),
+        });
+
+        expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
+        expect(Sentry.captureException).toHaveBeenCalledWith(
+          expect.objectContaining({
+            message: 'Error!',
+          }),
+        );
+      });
+
+      describe('with buttons', () => {
+        const findAlertAction = () => document.querySelector('.flash-container .gl-alert-action');
+
+        it('adds primary button', () => {
+          alert = createAlert({
+            message: mockMessage,
+            primaryButton: {
+              text: 'Ok',
+            },
+          });
+
+          expect(findAlertAction().textContent.trim()).toBe('Ok');
+        });
+
+        it('creates link with href', () => {
+          alert = createAlert({
+            message: mockMessage,
+            primaryButton: {
+              link: '/url',
+              text: 'Ok',
+            },
+          });
+
+          const action = findAlertAction();
+
+          expect(action.textContent.trim()).toBe('Ok');
+          expect(action.nodeName).toBe('A');
+          expect(action.getAttribute('href')).toBe('/url');
+        });
+
+        it('create button as href when no href is present', () => {
+          alert = createAlert({
+            message: mockMessage,
+            primaryButton: {
+              text: 'Ok',
+            },
+          });
+
+          const action = findAlertAction();
+
+          expect(action.nodeName).toBe('BUTTON');
+          expect(action.getAttribute('href')).toBe(null);
+        });
+
+        it('escapes the title text', () => {
+          alert = createAlert({
+            message: mockMessage,
+            primaryButton: {
+              text: '<script>alert("a")</script>',
+            },
+          });
+
+          const html = findAlertAction().innerHTML;
+
+          expect(html).toContain('&lt;script&gt;alert("a")&lt;/script&gt;');
+          expect(html).not.toContain('<script>alert("a")</script>');
+        });
+
+        it('calls actionConfig clickHandler on click', () => {
+          const clickHandler = jest.fn();
+
+          alert = createAlert({
+            message: mockMessage,
+            primaryButton: {
+              text: 'Ok',
+              clickHandler,
+            },
+          });
+
+          expect(clickHandler).toHaveBeenCalledTimes(0);
+
+          findAlertAction().click();
+
+          expect(clickHandler).toHaveBeenCalledTimes(1);
+          expect(clickHandler).toHaveBeenCalledWith(expect.any(MouseEvent));
+        });
+      });
+
+      describe('Alert API', () => {
+        describe('dismiss', () => {
+          it('dismiss programmatically with .dismiss()', () => {
+            expect(document.querySelector('.gl-alert')).toBeNull();
+
+            alert = createAlert({ message: mockMessage });
+
+            expect(document.querySelector('.gl-alert')).not.toBeNull();
+
+            alert.dismiss();
+
+            expect(document.querySelector('.gl-alert')).toBeNull();
+          });
+
+          it('calls onDismiss when dismissed', () => {
+            const dismissHandler = jest.fn();
+
+            alert = createAlert({ message: mockMessage, onDismiss: dismissHandler });
+
+            expect(dismissHandler).toHaveBeenCalledTimes(0);
+
+            alert.dismiss();
+
+            expect(dismissHandler).toHaveBeenCalledTimes(1);
+          });
+        });
+      });
+    });
+  });
+
   describe('createFlash', () => {
     const message = 'test';
     const fadeTransition = false;
@@ -91,7 +324,7 @@ describe('Flash', () => {
 
     describe('with flash-container', () => {
       beforeEach(() => {
-        setFixtures(
+        setHTMLFixture(
           '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>',
         );
       });
@@ -115,11 +348,12 @@ describe('Flash', () => {
       });
 
       it('escapes text', () => {
-        createFlash({ ...defaultParams, message: '<script>alert("a");</script>' });
+        createFlash({ ...defaultParams, message: '<script>alert("a")</script>' });
 
-        expect(document.querySelector('.flash-text').textContent.trim()).toBe(
-          '<script>alert("a");</script>',
-        );
+        const html = document.querySelector('.flash-text').innerHTML;
+
+        expect(html).toContain('&lt;script&gt;alert("a")&lt;/script&gt;');
+        expect(html).not.toContain('<script>alert("a")</script>');
       });
 
       it('adds flash into specified parent', () => {
@@ -193,8 +427,10 @@ describe('Flash', () => {
             },
           });
 
-          expect(findFlashAction().href).toBe(`${window.location}testing`);
-          expect(findFlashAction().textContent.trim()).toBe('test');
+          const action = findFlashAction();
+
+          expect(action.href).toBe(`${window.location}testing`);
+          expect(action.textContent.trim()).toBe('test');
         });
 
         it('uses hash as href when no href is present', () => {
@@ -227,7 +463,10 @@ describe('Flash', () => {
             },
           });
 
-          expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>');
+          const html = findFlashAction().innerHTML;
+
+          expect(html).toContain('&lt;script&gt;alert("a")&lt;/script&gt;');
+          expect(html).not.toContain('<script>alert("a")</script>');
         });
 
         it('calls actionConfig clickHandler on click', () => {
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 7015fe809b048d0b0d742625285e9cd695e4bfea..19c3a4b8e3cc75ff7e729e71c570f1121cb7c5b2 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
 import setWindowLocation from 'helpers/set_window_location_helper';
 import { extendedWrapper } from 'helpers/vue_test_utils_helper';
 import waitForPromises from 'helpers/wait_for_promises';
-import createFlash from '~/flash';
+import { createAlert } from '~/flash';
 import { getIdFromGraphQLId } from '~/graphql_shared/utils';
 import { updateHistory } from '~/lib/utils/url_utility';
 
@@ -241,7 +241,7 @@ describe('AdminRunnersApp', () => {
     });
 
     it('error is shown to the user', async () => {
-      expect(createFlash).toHaveBeenCalledTimes(1);
+      expect(createAlert).toHaveBeenCalledTimes(1);
     });
 
     it('error is reported to sentry', async () => {