diff --git a/app/components/pajamas/banner_component.html.haml b/app/components/pajamas/banner_component.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..4fa2ed09cd359685e5ad0a653568de1c13180fb1
--- /dev/null
+++ b/app/components/pajamas/banner_component.html.haml
@@ -0,0 +1,23 @@
+%section.gl-banner{ @banner_options, class: banner_class }
+  - if illustration?
+    .gl-banner-illustration
+      = illustration
+  - elsif @svg_path.present?
+    .gl-banner-illustration
+      = image_tag @svg_path, alt: ""
+
+  .gl-banner-content
+    %h1.gl-banner-title= title
+
+    = content
+
+    - if primary_action?
+      = primary_action
+    - else
+      = link_to @button_text, @button_link, { **@button_options, class: 'btn btn-md btn-confirm gl-button js-close-callout' }
+
+    - actions.each do |action|
+      = action
+
+  %button.gl-button.gl-banner-close.btn-sm.btn-icon.js-close{ @close_options, class: close_class, type: 'button' }
+    = sprite_icon('close', size: 16, css_class: 'dismiss-icon')
diff --git a/app/components/pajamas/banner_component.rb b/app/components/pajamas/banner_component.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9b6343b47c90b4d9838fcd95be774914a39d7607
--- /dev/null
+++ b/app/components/pajamas/banner_component.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Pajamas
+  class BannerComponent < Pajamas::Component
+    # @param [String] button_text
+    # @param [String] button_link
+    # @param [Boolean] embedded
+    # @param [Symbol] variant
+    # @param [String] svg_path
+    # @param [Hash] banner_options
+    # @param [Hash] button_options
+    # @param [Hash] close_options
+    def initialize(
+      button_text: 'OK',
+      button_link: '#',
+      embedded: false,
+      variant: :promotion,
+      svg_path: nil,
+      banner_options: {},
+      button_options: {},
+      close_options: {}
+    )
+      @button_text = button_text
+      @button_link = button_link
+      @embedded = embedded
+      @variant = variant.to_sym
+      @svg_path = svg_path.to_s
+      @banner_options = banner_options
+      @button_options = button_options
+      @close_options = close_options
+    end
+
+    private
+
+    def banner_class
+      classes = []
+      classes.push('gl-border-none') if @embedded
+      classes.push('gl-banner-introduction') if introduction?
+      classes.join(' ')
+    end
+
+    def close_class
+      if introduction?
+        'btn-confirm btn-confirm-tertiary'
+      else
+        'btn-default btn-default-tertiary'
+      end
+    end
+
+    delegate :sprite_icon, to: :helpers
+
+    renders_one :title
+    renders_one :illustration
+    renders_one :primary_action
+    renders_many :actions
+
+    def introduction?
+      @variant == :introduction
+    end
+  end
+end
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index d6d84b2181fccc4bf6b26c079745630a73413bb5..c2b941c6106d720a05a146eb3fb77437f8ce6538 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,15 +1,13 @@
-%section.js-autodevops-banner.gl-banner{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
-  .gl-banner-illustration
-    = image_tag('illustrations/autodevops.svg')
+= render Pajamas::BannerComponent.new(button_text: s_('AutoDevOps|Enable in settings'),
+  button_link: project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'),
+  svg_path: 'illustrations/autodevops.svg',
+  banner_options: { class: 'js-autodevops-banner', data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } },
+  close_options: { 'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box'), class: 'js-close-callout' }) do |c|
+  - c.title do
+    = s_('AutoDevOps|Auto DevOps')
 
-  .gl-banner-content
-    %h1.gl-banner-title= s_('AutoDevOps|Auto DevOps')
-    %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
-    %p
-      - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
-      = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
-    = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md btn-default gl-button js-close-callout'
+  %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
 
-  %button.gl-banner-close.close.js-close-callout{ type: 'button',
-  'aria-label' => s_('AutoDevOps|Dismiss Auto DevOps box') }
-    = sprite_icon('close', size: 16, css_class: 'dismiss-icon')
+  %p
+    - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
+    = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
diff --git a/ee/app/views/shared/promotions/_promote_burndown_charts.html.haml b/ee/app/views/shared/promotions/_promote_burndown_charts.html.haml
index 0cf10f20ad534a6d64a577f31cd153afc9ee6e12..9b7e67adf1b7b4965a68328ee4ee90ea05e88c89 100644
--- a/ee/app/views/shared/promotions/_promote_burndown_charts.html.haml
+++ b/ee/app/views/shared/promotions/_promote_burndown_charts.html.haml
@@ -1,20 +1,20 @@
 - callout_id = 'promote_burndown_charts_dismissed'
 
 - if show_burndown_charts_promotion?(milestone) && show_callout?(callout_id)
-  .user-callout.promotion-callout#promote_burndown_charts{ data: { uid: callout_id } }
-    .bordered-box.content-block.promotion-burndown-charts-content
-      %button.gl-button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => s_('Promotions|Dismiss burndown charts promotion') }
-        = sprite_icon('close', size: 16, css_class: 'dismiss-icon')
-      .svg-container
-        = custom_icon('icon_burndown_charts')
-      .user-callout-copy
-        %h4
-          - if Gitlab::CurrentSettings.should_check_namespace_plan?
-            = s_('Promotions|Upgrade your plan to improve milestones with Burndown Charts.')
-          - else
-            = s_('Promotions|Improve milestones with Burndown Charts.')
-        %p
-          = s_('Promotions|Burndown Charts are visual representations of the progress of completing a milestone. At a glance, you see the current state for the completion a given milestone. Without them, you would have to organize the data from the milestone and plot it yourself to have the same sense of progress.')
-          = link_to _('Read more'), help_page_path('user/project/milestones/burndown_and_burnup_charts.md'), target: '_blank', rel: 'noopener noreferrer'
+  = render Pajamas::BannerComponent.new(banner_options: { id: 'promote_burndown_charts', class: 'user-callout', data: { uid: callout_id } },
+    close_options: { 'aria-label' => s_('Promotions|Dismiss burndown charts promotion'), class: 'js-close-callout' }) do |c|
+    - c.title do
+      - if Gitlab::CurrentSettings.should_check_namespace_plan?
+        = s_('Promotions|Upgrade your plan to improve milestones with Burndown Charts.')
+      - else
+        = s_('Promotions|Improve milestones with Burndown Charts.')
 
-        = render 'shared/promotions/promotion_link_project', location: :burndown_charts
+    - c.illustration do
+      = custom_icon('icon_burndown_charts')
+
+    %p
+      = s_('Promotions|Burndown Charts are visual representations of the progress of completing a milestone. At a glance, you see the current state for the completion a given milestone. Without them, you would have to organize the data from the milestone and plot it yourself to have the same sense of progress.')
+      = link_to _('Read more'), help_page_path('user/project/milestones/burndown_and_burnup_charts.md'), target: '_blank', rel: 'noopener noreferrer'
+
+    - c.primary_action do
+      = render 'shared/promotions/promotion_link_project', location: :burndown_charts
diff --git a/ee/spec/features/promotion_spec.rb b/ee/spec/features/promotion_spec.rb
index eb37937e1a42427058e5bc7744c60411c81867e6..571f9f10cd606bfd66d14c697e0780731fe80495 100644
--- a/ee/spec/features/promotion_spec.rb
+++ b/ee/spec/features/promotion_spec.rb
@@ -89,7 +89,7 @@
       visit project_milestone_path(project, milestone)
 
       within('#promote_burndown_charts') do
-        find('.close').click
+        find('.js-close').click
       end
 
       visit project_milestone_path(project, milestone)
diff --git a/spec/components/pajamas/banner_component_spec.rb b/spec/components/pajamas/banner_component_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5969f06dbadad869fe4a6f4455a4a2833d5242fc
--- /dev/null
+++ b/spec/components/pajamas/banner_component_spec.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+require "spec_helper"
+
+RSpec.describe Pajamas::BannerComponent, type: :component do
+  subject do
+    described_class.new(**options)
+  end
+
+  let(:title) { "Banner title" }
+  let(:content) { "Banner content"}
+  let(:options) { {} }
+
+  describe 'basic usage' do
+    before do
+      render_inline(subject) do |c|
+        c.title { title }
+        content
+      end
+    end
+
+    it 'renders its content' do
+      expect(rendered_component).to have_text content
+    end
+
+    it 'renders its title' do
+      expect(rendered_component).to have_css "h1[class='gl-banner-title']", text: title
+    end
+
+    it 'renders a close button' do
+      expect(rendered_component).to have_css "button.gl-banner-close"
+    end
+
+    describe 'button_text and button_link' do
+      let(:options) { { button_text: 'Learn more', button_link: '/learn-more' } }
+
+      it 'define the primary action' do
+        expect(rendered_component).to have_css "a.btn-confirm.gl-button[href='/learn-more']", text: 'Learn more'
+      end
+    end
+
+    describe 'banner_options' do
+      let(:options) { { banner_options: { class: "baz", data: { foo: "bar" } } } }
+
+      it 'are on the banner' do
+        expect(rendered_component).to have_css ".gl-banner.baz[data-foo='bar']"
+      end
+
+      context 'with custom classes' do
+        let(:options) { { variant: :introduction, banner_options: { class: 'extra special' } } }
+
+        it 'don\'t conflict with internal banner_classes' do
+          expect(rendered_component).to have_css '.extra.special.gl-banner-introduction.gl-banner'
+        end
+      end
+    end
+
+    describe 'close_options' do
+      let(:options) { { close_options: { class: "js-foo", data: { uid: "123" } } } }
+
+      it 'are on the close button' do
+        expect(rendered_component).to have_css "button.gl-banner-close.js-foo[data-uid='123']"
+      end
+    end
+
+    describe 'embedded' do
+      context 'by default (false)' do
+        it 'keeps the banner\'s borders' do
+          expect(rendered_component).not_to have_css ".gl-banner.gl-border-none"
+        end
+      end
+
+      context 'when set to true' do
+        let(:options) { { embedded: true } }
+
+        it 'removes the banner\'s borders' do
+          expect(rendered_component).to have_css ".gl-banner.gl-border-none"
+        end
+      end
+    end
+
+    describe 'variant' do
+      context 'by default (promotion)' do
+        it 'applies no variant class' do
+          expect(rendered_component).to have_css "[class='gl-banner']"
+        end
+      end
+
+      context 'when set to introduction' do
+        let(:options) { { variant: :introduction } }
+
+        it "applies the introduction class to the banner" do
+          expect(rendered_component).to have_css ".gl-banner.gl-banner-introduction"
+        end
+
+        it "applies the confirm class to the close button" do
+          expect(rendered_component).to have_css ".gl-banner-close.btn-confirm.btn-confirm-tertiary"
+        end
+      end
+
+      context 'when set to unknown variant' do
+        let(:options) { { variant: :foobar } }
+
+        it 'ignores the unknown variant' do
+          expect(rendered_component).to have_css "[class='gl-banner']"
+        end
+      end
+    end
+
+    describe 'illustration' do
+      it 'has none by default' do
+        expect(rendered_component).not_to have_css ".gl-banner-illustration"
+      end
+
+      context 'with svg_path' do
+        let(:options) { { svg_path: 'logo.svg' } }
+
+        it 'renders an image as illustration' do
+          expect(rendered_component).to have_css ".gl-banner-illustration img"
+        end
+      end
+    end
+  end
+
+  context 'with illustration slot' do
+    before do
+      render_inline(subject) do |c|
+        c.title { title }
+        c.illustration { "<svg></svg>".html_safe }
+        content
+      end
+    end
+
+    it 'renders the slot content as illustration' do
+      expect(rendered_component).to have_css ".gl-banner-illustration svg"
+    end
+
+    context 'and conflicting svg_path' do
+      let(:options) { { svg_path: 'logo.svg' } }
+
+      it 'uses the slot content' do
+        expect(rendered_component).to have_css ".gl-banner-illustration svg"
+        expect(rendered_component).not_to have_css ".gl-banner-illustration img"
+      end
+    end
+  end
+
+  context 'with primary_action slot' do
+    before do
+      render_inline(subject) do |c|
+        c.title { title }
+        c.primary_action { "<a class='special' href='#'>Special</a>".html_safe }
+        content
+      end
+    end
+
+    it 'renders the slot content as the primary action' do
+      expect(rendered_component).to have_css "a.special", text: 'Special'
+    end
+
+    context 'and conflicting button_text and button_link' do
+      let(:options) { { button_text: 'Not special', button_link: '/' } }
+
+      it 'uses the slot content' do
+        expect(rendered_component).to have_css "a.special[href='#']", text: 'Special'
+        expect(rendered_component).not_to have_css "a.btn[href='/']"
+      end
+    end
+  end
+end