diff --git a/reviewboard/static/rb/css/defs.less b/reviewboard/static/rb/css/defs.less
index b40e9b5aa5446dd83f3af9a9b9bd862f3b141259..18aeec5abe2c51b92b149d005a29988f68b02e20 100644
--- a/reviewboard/static/rb/css/defs.less
+++ b/reviewboard/static/rb/css/defs.less
@@ -95,6 +95,9 @@
 @draft-bg-color: #CDFF9C;
 @draft-border-color: darken(@draft-bg-color, 50%);
 
+@unified-banner-bg-color: #FEF5D8;
+@unified-banner-border-color: greyscale(darken(@unified-banner-bg-color, 40%));
+
 @review-request-bg: #FEFADF;
 @review-request-bg-gradient-start: lighten(@review-request-bg, 4%);
 @review-request-bg-gradient-end: @review-request-bg;
diff --git a/reviewboard/static/rb/css/ui/banners.less b/reviewboard/static/rb/css/ui/banners.less
index 60f39ee9c6e42b27a74af93adac7c4e283107592..921265d433f43509330b820ad812591a7180378d 100644
--- a/reviewboard/static/rb/css/ui/banners.less
+++ b/reviewboard/static/rb/css/ui/banners.less
@@ -1,6 +1,5 @@
 @import (reference) "../defs.less";
 
-
 .banner {
   background: @draft-bg-color;
   border-width: 1px;
@@ -33,3 +32,58 @@
     });
   }
 }
+
+#unified-banner {
+  z-index: @z-index-banner;
+  margin: -@page-container-padding -@page-container-padding @page-container-padding;
+}
+
+
+#unified-banner-docked {
+  display: block;
+
+  &.hidden {
+    display: none;
+  }
+}
+
+.unified-banner {
+  background: @unified-banner-bg-color;
+  border: @unified-banner-border-color 1px solid;
+  border-top: 0;
+  border-left: 0;
+  border-right: 0;
+  box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.15);
+  padding: @page-container-padding;
+
+  &.draft {
+    background: @draft-bg-color;
+    border-color: @draft-border-color;
+  }
+
+  #unified-banner-main, #unified-banner-page, #unified-banner-stats {
+    display: inline-block;
+
+    &.hidden {
+      display: none;
+    }
+
+    div {
+      display: inline-block;
+      margin-right: 0.5em;
+    }
+  }
+
+  #unified-banner-bottom {
+    display: block;
+
+    &.hidden {
+      display: none;
+    }
+
+    div {
+      display: inline-block;
+      margin-right: 0.5em;
+    }
+  }
+}
diff --git a/reviewboard/static/rb/js/models/tests/unifiedReviewBannerStateTests.es6.js b/reviewboard/static/rb/js/models/tests/unifiedReviewBannerStateTests.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..56755ffa75ab1ae63d374cbe9ca0f2ef74bc8c69
--- /dev/null
+++ b/reviewboard/static/rb/js/models/tests/unifiedReviewBannerStateTests.es6.js
@@ -0,0 +1,218 @@
+suite('rb/models/UnifiedReviewBannerState', function() {
+    const unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+    let bannerState,
+        pendingReview,
+        reviewRequest;
+
+    beforeEach(function () {
+        RB.EnabledFeatures.unifiedBanner = true;
+
+        reviewRequest = new RB.ReviewRequest();
+        pendingReview = reviewRequest.createReview();
+
+        spyOn(reviewRequest.draft, 'ready').and.callFake(
+            (options, context) => options.ready.call(context));
+
+        spyOn(pendingReview, 'ready').and.callFake(
+            (options, context) => options.ready.call(context));
+
+        bannerState = new RB.UnifiedReviewBannerState({
+            reviewRequest: reviewRequest,
+            pendingReview: pendingReview,
+        });
+    });
+
+    afterEach(function() {
+        RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
+    });
+
+    describe('Actions', function() {
+        describe('_updateHasDraft', function() {
+            it('has no drafts', function() {
+                expect(pendingReview.id).toBe(undefined);
+                expect(reviewRequest.draft.id).toBe(undefined);
+                expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+
+                bannerState._updateHasDraft();
+
+                expect(bannerState.get('hasDraft')).toBe(false);
+            });
+
+            it('has pendingReview', function() {
+                expect(bannerState.get('hasDraft')).toBe(false);
+
+                pendingReview.set('id', 48);
+                bannerState._updateHasDraft();
+
+                expect(bannerState.get('hasDraft')).toBe(true);
+            });
+
+            it('has reviewRequest draft', function() {
+                expect(bannerState.get('hasDraft')).toBe(false);
+
+                reviewRequest.draft.set('id', 48);
+                bannerState._updateHasDraft();
+
+                expect(bannerState.get('hasDraft')).toBe(true);
+            });
+
+            it('has reviewReplies drafts', function() {
+                const reviewReplies = bannerState.get('reviewReplies');
+
+                expect(bannerState.get('hasDraft')).toBe(false);
+
+                reviewReplies.push(new RB.ReviewReply({
+                    parentObject: pendingReview,
+                }));
+                bannerState.set('reviewReplyDrafts', reviewReplies);
+                bannerState._updateHasDraft();
+
+                expect(bannerState.get('hasDraft')).toBe(true);
+            });
+        });
+
+        describe('addReviewReply', function() {
+            let reviewReply;
+
+            beforeEach(function() {
+                reviewReply = new RB.ReviewReply();
+            });
+
+            describe('method', function() {
+                it('with hasReviewReplyDraft false', function() {
+                    expect(bannerState.get('reviewReplies').length).toBe(0);
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+
+                    bannerState.addReviewReply(reviewReply, false);
+
+                    expect(bannerState.get('reviewReplies').length).toBe(1);
+                    expect(bannerState.get('reviewReplies')[0]).toBe(reviewReply);
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+                });
+
+                it('with hasReviewReplyDraft true', function() {
+                    expect(bannerState.get('reviewReplies').length).toBe(0);
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+
+                    bannerState.addReviewReply(reviewReply, true);
+
+                    expect(bannerState.get('reviewReplies').length).toBe(1);
+                    expect(bannerState.get('reviewReplies')[0]).toBe(reviewReply);
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(1);
+                    expect(bannerState.get('reviewReplyDrafts')[0]).toBe(reviewReply);
+                });
+            });
+
+            describe('event listeners', function() {
+                it('saved', function() {
+                    spyOn(bannerState, '_updateHasDraft');
+
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+
+                    bannerState.addReviewReply(reviewReply, false);
+                    reviewReply.trigger('saved');
+
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(1);
+                    expect(bannerState.get('reviewReplyDrafts')[0]).toBe(reviewReply);
+                    expect(bannerState._updateHasDraft).toHaveBeenCalled();
+                });
+
+                it('destroying', function() {
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+
+                    bannerState.addReviewReply(reviewReply, true);
+
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(1);
+
+                    reviewReply.trigger('destroying');
+
+                    expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+                });
+
+                it('destroyed', function() {
+                    spyOn(bannerState, '_updateHasDraft');
+
+                    bannerState.addReviewReply(reviewReply, true);
+                    reviewReply.trigger('destroyed');
+
+                    expect(bannerState._updateHasDraft).toHaveBeenCalled();
+                });
+            });
+        });
+
+        it('_checkDraftState', function() {
+            spyOn(bannerState, '_updateHasDraft');
+
+            bannerState._checkDraftState();
+
+            expect(bannerState._updateHasDraft).toHaveBeenCalled();
+        });
+
+        it('destroyAllAndReset', function() {
+            const reviewReply = new RB.ReviewReply({
+                parentObject: new RB.Review({
+                    links: {
+                        replies: {
+                            href: "#",
+                        }
+                    }
+                }),
+            });
+            const reviewReplyDrafts = bannerState.get('reviewReplyDrafts');
+            reviewRequest.set('links', {draft: { href: "#", }});
+
+            spyOn(pendingReview, 'destroy').and.callThrough();
+            spyOn(reviewRequest.draft, 'destroy').and.callThrough();
+            spyOn(reviewReply, 'destroy').and.callThrough();
+
+            reviewReplyDrafts.push(reviewReply);
+            bannerState.set('reviewReplyDrafts', reviewReplyDrafts);
+            bannerState._updateHasDraft();
+
+            expect(bannerState.get('hasDraft')).toBe(true);
+            expect(bannerState.get('reviewReplyDrafts').length).not.toBe(0);
+
+            bannerState.destroyAllAndReset(() => {
+                expect(pendingReview.destroy).toHaveBeenCalled();
+                expect(reviewRequest.draft.destroy).toHaveBeenCalled();
+                expect(reviewReply.destroy).toHaveBeenCalled();
+                expect(bannerState.get('hasDraft')).toBe(false);
+                expect(bannerState.get('reviewReplyDrafts').length).toBe(0);
+            });
+        });
+    });
+
+    describe('Event Listeners', function() {
+        describe('pendingReview', function() {
+            it('saved', function() {
+                spyOn(pendingReview, 'isNew').and.returnValue(false);
+                pendingReview.trigger('saved');
+
+                expect(bannerState.get('hasDraft')).toBe(true);
+            });
+
+            it('destroy', function() {
+                spyOn(pendingReview, 'isNew').and.returnValue(true);
+                pendingReview.trigger('destroy');
+
+                expect(bannerState.get('hasDraft')).toBe(false);
+            });
+        });
+
+        describe('reviewRequest', function() {
+            it('saved', function() {
+                spyOn(reviewRequest.draft, 'isNew').and.returnValue(false);
+                reviewRequest.draft.trigger('saved');
+
+                expect(bannerState.get('hasDraft')).toBe(true);
+            });
+
+            it('destroy', function() {
+                spyOn(reviewRequest.draft, 'isNew').and.returnValue(true);
+                reviewRequest.draft.trigger('destroy');
+
+                expect(bannerState.get('hasDraft')).toBe(false);
+            });
+        });
+    });
+});
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/models/unifiedReviewBannerState.es6.js b/reviewboard/static/rb/js/models/unifiedReviewBannerState.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..088fa9940a4c06595f37bd1e6945fb8b0d2e8c2d
--- /dev/null
+++ b/reviewboard/static/rb/js/models/unifiedReviewBannerState.es6.js
@@ -0,0 +1,200 @@
+/**
+ * Manages the state for the Unified Review Banner.
+ *
+ * Manages drafts for the review, review request, and review replies and sets
+ * the draft state through hasDraft if at least one draft exists.
+ *
+ * Model Attributes:
+ *     hasDraft (boolean):
+ *         Whether there is currently a draft in any of the review, review
+ *         request, or review replies that the model manages.
+ *
+ *     pendingReview (RB.Review):
+ *         The pending review used for any new review content.
+ *
+ *     reviewReplies (array of RB.ReviewReply):
+ *         All the review replies on the review.
+ *
+ *     reviewReplyDrafts (array of RB.ReviewReply):
+ *         All the review replies on the review that contain a draft.
+ *
+ *     reviewRequest (RB.ReviewRequest):
+ *          The review request that the reviewable page the banner is on is
+ *          for.
+ */
+RB.UnifiedReviewBannerState = Backbone.Model.extend({
+    /**
+     * Return default values for the model attributes.
+     *
+     * Returns:
+     *     object:
+     *         The attribute defaults.
+     */
+    defaults() {
+        return {
+            hasDraft: false,
+            pendingReview: null,
+            reviewReplies: [],
+            reviewReplyDrafts: [],
+            reviewRequest: null,
+        };
+    },
+
+    /**
+     * Initialize the Unified Review Banner State.
+     *
+     * Sets listeners on the saved and destroy events for the review request and
+     * review to re-check if the state has at least one draft. At the end of
+     * initialization, checks if at least one draft exists already.
+     */
+    initialize() {
+        const reviewRequest = this.get('reviewRequest');
+        const pendingReview = this.get('pendingReview');
+
+        console.assert(reviewRequest, 'reviewRequest must be provided');
+        console.assert(pendingReview, 'pendingReview must be provided');
+
+        _.bindAll(this, '_updateHasDraft');
+        this.listenTo(reviewRequest.draft, 'saved destroy', this._updateHasDraft);
+        this.listenTo(pendingReview, 'saved destroy', this._updateHasDraft);
+
+        // Check if there is already a pendingReview or reviewRequest draft.
+        this._checkDraftState();
+    },
+
+    /**
+     * Update the hasDraft attribute.
+     *
+     * hasDraft should be true if there is at least one reviewRequest draft,
+     * pendingReview, or reviewReplyDraft.
+     */
+    _updateHasDraft() {
+        this.set('hasDraft',
+            !this.get('reviewRequest').draft.isNew() ||
+            !this.get('pendingReview').isNew() ||
+            this.get('reviewReplyDrafts').length > 0);
+    },
+
+    /**
+     * Check if a draft review request or review already exists.
+     *
+     * Once reviewRequest and pendingReview are ready, update the hasDraft
+     * attribute to check if there is already a draft.
+     */
+    _checkDraftState() {
+        this.get('reviewRequest').draft.ready({
+            ready: () => this.get('pendingReview').ready({
+                ready: this._updateHasDraft,
+            }),
+        });
+    },
+
+    /**
+     * Add a review reply to the banner state with necessary listeners.
+     *
+     * Add the given review reply to the reviewReplies array if it does not
+     * already exist in the array. If hasReviewReplyDraft is passed in as true,
+     * also add the review reply to the reviewReplyDrafts array. Add listeners
+     * on the saved and destroyed events of the reviewReplies to add/remove the
+     * reviewReply from the reviewReplyDrafts array as necessary.
+     *
+     * Args:
+     *     reviewReply (RB.ReviewReply):
+     *         The review reply to add to the reviewReplies array in the model.
+     *
+     *     hasReviewReplyDraft (boolean):
+     *          Whether the reviewReply passed in is already a draft.
+     *          If true and the reviewReply is not already in the
+     *          reviewReplyDrafts array on the model, add the reviewReply to
+     *          the reviewReplyDrafts array.
+     */
+    addReviewReply(reviewReply, hasReviewReplyDraft) {
+        const reviewReplies = this.get('reviewReplies');
+
+        // Only add unique review replies (i.e. don't add duplicates).
+        if (!reviewReplies.includes(reviewReply)) {
+            reviewReplies.push(reviewReply);
+
+            // Set saved and destroy listeners on the reviewReply draft.
+            this.listenTo(reviewReply, 'saved', () => {
+                const reviewReplyDrafts = this.get('reviewReplyDrafts');
+
+                if (!reviewReplyDrafts.includes(reviewReply)) {
+                    reviewReplyDrafts.push(reviewReply);
+                    this.set('reviewReplyDrafts', reviewReplyDrafts);
+                }
+                this._updateHasDraft();
+            });
+
+            this.listenTo(reviewReply, 'destroying', () => {
+                this.set('reviewReplyDrafts', _.reject(
+                    this.get('reviewReplyDrafts'),
+                    draft => draft.id === reviewReply.id));
+            });
+
+            this.listenTo(reviewReply, 'destroyed', this._updateHasDraft);
+        }
+
+        if (hasReviewReplyDraft) {
+            const reviewReplyDrafts = this.get('reviewReplyDrafts');
+
+            if (!reviewReplyDrafts.includes(reviewReply)) {
+                reviewReplyDrafts.push(reviewReply);
+                this.set('reviewReplyDrafts', reviewReplyDrafts);
+            }
+            this._updateHasDraft();
+        }
+
+        this.set('reviewReplies', reviewReplies);
+    },
+
+    /**
+     * Destroy all model attributes and reset banner state to defaults.
+     *
+     * Destroy models for pendingReview, reviewRequest, and reviewReplies
+     * drafts. Reset all attributes to defaults.
+     *
+     * Args:
+     *     onDone (function, optional):
+     *         If passed in, this function will be called last after
+     *         everything has been destroyed and reset.
+     *
+     */
+    destroyAllAndReset(onDone) {
+        $.funcQueue('banner-discard-all').clear();
+
+        $.funcQueue('banner-discard-all').add(() => {
+            this.get('pendingReview').destroy({
+                success: () => $.funcQueue('banner-discard-all').next(),
+            });
+        });
+
+        $.funcQueue('banner-discard-all').add(() => {
+            this.get('reviewRequest').draft.destroy({
+                success: () => $.funcQueue('banner-discard-all').next(),
+            });
+        });
+
+        this.get('reviewReplyDrafts').forEach(reviewReply => {
+            $.funcQueue('banner-discard-all').add(() => {
+                reviewReply.destroy({
+                    success: () => $.funcQueue('banner-discard-all').next(),
+                });
+            });
+        });
+
+        $.funcQueue('banner-discard-all').add(() => {
+            this.set('reviewReplies', []);
+            this.set('reviewReplyDrafts', []);
+            this.set('hasDraft', false);
+        });
+
+        $.funcQueue('banner-discard-all').add(() => {
+            if (_.isFunction(onDone)) {
+                onDone();
+            }
+        });
+
+        $.funcQueue('banner-discard-all').start();
+    }
+});
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js b/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js
index 6fbca7c84c3db6a1c0793814678c9904b8b57c8e..b3626c015cf32bdef567dfbe7ca00e4dc13676a9 100644
--- a/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js
+++ b/reviewboard/static/rb/js/pages/views/diffViewerPageView.es6.js
@@ -517,8 +517,12 @@ RB.DiffViewerPageView = RB.ReviewablePageView.extend({
 
             let scrollAmount = this.DIFF_SCROLLDOWN_AMOUNT;
 
-            if (RB.DraftReviewBannerView.instance) {
-                scrollAmount += RB.DraftReviewBannerView.instance.getHeight();
+            if (RB.EnabledFeatures.unifiedBanner) {
+                scrollAmount += RB.UnifiedReviewBannerView.getInstance().getHeight();
+            } else {
+                if (RB.DraftReviewBannerView.instance) {
+                    scrollAmount += RB.DraftReviewBannerView.instance.getHeight();
+                }
             }
 
             this._$window.scrollTop($anchor.offset().top - scrollAmount);
diff --git a/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js b/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
index e9fa554fd76977f491c8793ee21741f077e3f908..8774835a49c6eedc61bd1476bcf456e782dce4f7 100644
--- a/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
+++ b/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
@@ -212,15 +212,85 @@ RB.ReviewablePageView = RB.PageView.extend({
         this._logoNotificationsURL = STATIC_URLS['rb/images/logo.png'];
 
         const pendingReview = this.model.get('pendingReview');
+        const reviewRequest = this.model.get('reviewRequest');
 
-        this.draftReviewBanner = RB.DraftReviewBannerView.create({
-            el: $('#review-banner'),
-            model: pendingReview,
-            reviewRequestEditor: this.model.reviewRequestEditor,
-        });
+        if (RB.EnabledFeatures.unifiedBanner) {
+            const unifiedReviewBannerState = new RB.UnifiedReviewBannerState({
+                pendingReview: pendingReview,
+                reviewRequest: reviewRequest,
+            });
 
-        this.listenTo(pendingReview, 'destroy published',
-                      () => this.draftReviewBanner.hideAndReload());
+            const discardButton = new RB.ButtonView({
+                buttonText: 'Discard',
+                buttonAction: () => {
+                    $('<p/>')
+                        .text(gettext('If you discard your draft, all related comments will be permanently deleted.'))
+                        .modalBox({
+                            title: gettext('Are you sure you want to discard this review?'),
+                            buttons: [
+                                $('<input type="button">')
+                                    .val(gettext('Cancel')),
+                                $('<input type="button">')
+                                    .val(gettext('Discard'))
+                                    .click(() => {
+                                        unifiedReviewBannerState.destroyAllAndReset();
+                                    }),
+                            ],
+                        });
+
+                    return false;
+                },
+                unifiedReviewBannerState: unifiedReviewBannerState,
+            });
+
+            const registries = [
+                new RB.Registry({
+                    bannerLocation: RB.BANNER_LOCATIONS.MAIN,
+                    defaultItems: [
+                        '<span>MAIN SECTION</span>',
+                        discardButton,
+                    ],
+                }),
+                new RB.Registry({
+                    bannerLocation: RB.BANNER_LOCATIONS.PAGE,
+                    defaultItems: [
+                        '<span>PAGE SECTION</span>',
+                    ],
+                }),
+                new RB.Registry({
+                    bannerLocation: RB.BANNER_LOCATIONS.STATS,
+                    defaultItems: [
+                        '<span>STATS SECTION</span>',
+                    ],
+                }),
+                new RB.Registry({
+                    bannerLocation: RB.BANNER_LOCATIONS.BOTTOM,
+                    defaultItems: [
+                        '<span>BOTTOM SECTION</span>',
+                    ],
+                }),
+                new RB.Registry({
+                    bannerLocation: RB.BANNER_LOCATIONS.DOCKED,
+                    defaultItems: [
+                        '<span>DOCKED SECTION</span>',
+                    ],
+                }),
+            ];
+
+            RB.UnifiedReviewBannerView.create({
+                model: unifiedReviewBannerState,
+                registries: registries,
+            });
+        } else {
+            this.draftReviewBanner = RB.DraftReviewBannerView.create({
+                el: $('#review-banner'),
+                model: pendingReview,
+                reviewRequestEditor: this.model.reviewRequestEditor,
+            });
+
+            this.listenTo(pendingReview, 'destroy published',
+                () => this.draftReviewBanner.hideAndReload());
+        }
 
         this.reviewRequestEditorView.render();
 
@@ -231,7 +301,10 @@ RB.ReviewablePageView = RB.PageView.extend({
      * Remove this view from the page.
      */
     remove() {
-        this.draftReviewBanner.remove();
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.draftReviewBanner.remove();
+        }
+
         _super(this).remove.call(this);
     },
 
@@ -364,8 +437,10 @@ RB.ReviewablePageView = RB.PageView.extend({
             undefined,
             RB.UserSession.instance.get('commentsOpenAnIssue'));
 
-        this.listenTo(comment, 'saved',
-                      () => RB.DraftReviewBannerView.instance.show());
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.listenTo(comment, 'saved',
+                          () => RB.DraftReviewBannerView.instance.show());
+        }
 
         RB.CommentDialogView.create({
             comment: comment,
@@ -412,4 +487,4 @@ RB.ReviewablePageView = RB.PageView.extend({
 });
 
 
-})();
+})();
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js b/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js
index 960bd513380c7d1d4c35201f3f5dbee0961117f0..952081961405120de10aa09c05ede6b4a9658ddf 100644
--- a/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js
+++ b/reviewboard/static/rb/js/pages/views/tests/diffViewerPageViewTests.es6.js
@@ -72,9 +72,13 @@ suite('rb/pages/views/DiffViewerPageView', function() {
 
     let page;
     let pageView;
+    let unifiedBannerFeatureFlag;
     let $diffs;
 
     beforeEach(function() {
+        unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+        RB.EnabledFeatures.unifiedBanner = false;
+
         /*
          * Disable the router so that the page doesn't change the URL on the
          * page while tests run.
@@ -86,6 +90,8 @@ suite('rb/pages/views/DiffViewerPageView', function() {
     afterEach(function() {
         RB.DnDUploader.instance = null;
         Backbone.history.stop();
+
+        RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
     });
 
     describe('Without commits', function() {
diff --git a/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js b/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js
index 2ffc88f1b2753a8edb76b9462507c915aee0060b..6f3484692f306454b525894fd686881d6235fca1 100644
--- a/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js
+++ b/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.es6.js
@@ -9,12 +9,15 @@ suite('rb/pages/views/ReviewablePageView', function() {
     let $shipIt;
     let page;
     let pageView;
+    let unifiedBannerFeatureFlag;
 
     beforeEach(function() {
         const $container = $('<div/>')
             .html(pageTemplate)
             .appendTo($testsScratch);
 
+        unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+        RB.EnabledFeatures.unifiedBanner = false;
         RB.DnDUploader.instance = null;
 
         $editReview = $container.find('#review-action');
@@ -50,6 +53,7 @@ suite('rb/pages/views/ReviewablePageView', function() {
 
     afterEach(function() {
         RB.DnDUploader.instance = null;
+        RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
 
         pageView.remove();
     });
@@ -207,7 +211,7 @@ suite('rb/pages/views/ReviewablePageView', function() {
                 RB.NotificationManager.instance._canNotify = true;
                 spyOn(RB.NotificationManager.instance, 'notify');
                 spyOn(RB.NotificationManager.instance,
-                      '_haveNotificationPermissions').and.returnValue(true);
+                    '_haveNotificationPermissions').and.returnValue(true);
                 spyOn(pageView, '_showUpdatesBubble');
 
                 pageView._onReviewRequestUpdated(info);
diff --git a/reviewboard/static/rb/js/reviewRequestPage/models/tests/reviewReplyEditorModelTests.js b/reviewboard/static/rb/js/reviewRequestPage/models/tests/reviewReplyEditorModelTests.js
index d32528a519482cc47ed77bde8cd13ce66c0fa7c3..82631e59899faade060da3b4cccbe729d6e29a15 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/models/tests/reviewReplyEditorModelTests.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/models/tests/reviewReplyEditorModelTests.js
@@ -372,7 +372,7 @@ suite('rb/reviewRequestPage/models/ReviewReplyEditor', function() {
                         replyObject.set('id', 123);
 
                         spyOn(editor, '_resetState');
-                        spyOn(reviewReply, 'discardIfEmpty')
+                        spyOn(reviewReply, 'discardIfEmpty');
                     });
 
                     it('body_top', function() {
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js b/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js
index bd2e4fe75dd92d723129d71ed3f13edc02ff415a..0458ea055b5ea37f90a6956253610b2b12d54fb8 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/reviewView.es6.js
@@ -55,13 +55,15 @@ RB.ReviewRequestPage.ReviewView = Backbone.View.extend({
 
         this._replyDraftsCount = 0;
 
-        this.on('hasDraftChanged', hasDraft => {
-            if (hasDraft) {
-                this._showReplyDraftBanner();
-            } else {
-                this._hideReplyDraftBanner();
-            }
-        });
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.on('hasDraftChanged', hasDraft => {
+                if (hasDraft) {
+                    this._showReplyDraftBanner();
+                } else {
+                    this._hideReplyDraftBanner();
+                }
+            });
+        }
 
         _.each(this._$reviewComments.find('.issue-indicator'), el => {
             const $issueState = $('.issue-state', el);
@@ -123,18 +125,20 @@ RB.ReviewRequestPage.ReviewView = Backbone.View.extend({
             });
             view.render();
 
-            this.listenTo(editor, 'change:hasDraft', (model, hasDraft) => {
-                if (hasDraft) {
-                    this._replyDraftsCount++;
-                    this.trigger('hasDraftChanged', true);
-                } else {
-                    this._replyDraftsCount--;
+            if (!RB.EnabledFeatures.unifiedBanner) {
+                this.listenTo(editor, 'change:hasDraft', (model, hasDraft) => {
+                    if (hasDraft) {
+                        this._replyDraftsCount++;
+                        this.trigger('hasDraftChanged', true);
+                    } else {
+                        this._replyDraftsCount--;
 
-                    if (this._replyDraftsCount === 0) {
-                        this.trigger('hasDraftChanged', false);
+                        if (this._replyDraftsCount === 0) {
+                            this.trigger('hasDraftChanged', false);
+                        }
                     }
-                }
-            });
+                });
+            }
 
             this._replyEditors.push(editor);
             this._replyEditorViews.push(view);
@@ -142,10 +146,17 @@ RB.ReviewRequestPage.ReviewView = Backbone.View.extend({
             if (editor.get('hasDraft')) {
                 this._replyDraftsCount++;
             }
+
+            if (RB.EnabledFeatures.unifiedBanner) {
+                RB.UnifiedReviewBannerView.getInstance().model.addReviewReply(
+                    this._reviewReply, editor.get('hasDraft'));
+            }
         });
 
-        if (this._replyDraftsCount > 0) {
-            this.trigger('hasDraftChanged', true);
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            if (this._replyDraftsCount > 0) {
+                this.trigger('hasDraftChanged', true);
+            }
         }
 
         /*
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewEntryViewTests.js b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewEntryViewTests.js
index d1b5504297bdeda04de41f1b87a10a7cfffa9b70..6b74fac3dd86d4e0976ce9ff82fe5d34ff4439aa 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewEntryViewTests.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewEntryViewTests.js
@@ -29,9 +29,13 @@ suite('rb/reviewRequestPage/views/ReviewEntryView', function() {
             '  </div>',
             ' </div>',
             '</div>'
-        ].join(''));
+        ].join('')),
+        unifiedBannerFeatureFlag;
 
     beforeEach(function() {
+        unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+        RB.EnabledFeatures.unifiedBanner = false;
+
         var reviewRequest = new RB.ReviewRequest(),
             editor = new RB.ReviewRequestEditor({
                 reviewRequest: reviewRequest
@@ -70,6 +74,10 @@ suite('rb/reviewRequestPage/views/ReviewEntryView', function() {
         reviewView._setupNewReply(reviewReply);
     });
 
+    afterEach(function() {
+        RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
+    });
+
     describe('Actions', function() {
         it('Toggle collapse', function() {
             var $box = view.$('.review-request-page-entry-contents'),
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js
index e4562aafc4bc1b93aaa8ef3418f58569e6b1a550..c6f2989c1ff925f00ca2c061070b64c40c70dc66 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewRequestPageViewTests.es6.js
@@ -35,6 +35,7 @@ suite('rb/reviewRequestPage/views/ReviewRequestPageView', function() {
 
     let page;
     let pageView;
+    let unifiedBannerFeatureFlag;
     let entry1;
     let entry2;
 
@@ -43,6 +44,8 @@ suite('rb/reviewRequestPage/views/ReviewRequestPageView', function() {
             .html(template)
             .appendTo($testsScratch);
 
+        unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+        RB.EnabledFeatures.unifiedBanner = false;
         RB.DnDUploader.instance = null;
 
         page = new RB.ReviewRequestPage.ReviewRequestPage({
@@ -103,6 +106,7 @@ suite('rb/reviewRequestPage/views/ReviewRequestPageView', function() {
 
     afterEach(function() {
         RB.DnDUploader.instance = null;
+        RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
     });
 
     describe('Actions', function() {
diff --git a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewViewTests.es6.js b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewViewTests.es6.js
index 629382d6834c46013834f78725162ae8a8280d13..24908b8c9c7e2c4113d0ea2aec0137e28f7ace68 100644
--- a/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewViewTests.es6.js
+++ b/reviewboard/static/rb/js/reviewRequestPage/views/tests/reviewViewTests.es6.js
@@ -56,8 +56,12 @@ suite('rb/views/ReviewView', function() {
     let view;
     let review;
     let reviewReply;
+    let unifiedBannerFeatureFlag;
 
     beforeEach(function() {
+        unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+        RB.EnabledFeatures.unifiedBanner = false;
+
         const reviewRequest = new RB.ReviewRequest();
         const editor = new RB.ReviewRequestEditor({
             reviewRequest: reviewRequest,
@@ -93,6 +97,10 @@ suite('rb/views/ReviewView', function() {
         view.render();
     });
 
+    afterEach(function() {
+        RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
+    });
+
     describe('Model events', () => {
         it('bodyTop changed', () => {
             review.set({
diff --git a/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js b/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js
index 143f0b6fde2aeb4b51dfa48b32f73ea45a62e288..0e978f88191d7d8eb7e6c4412f7ec464b8f0f5a4 100644
--- a/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js
+++ b/reviewboard/static/rb/js/views/abstractCommentBlockView.es6.js
@@ -208,7 +208,9 @@ RB.AbstractCommentBlockView = Backbone.View.extend({
                 this.notify(gettext('Comment Saved'));
             }
 
-            RB.DraftReviewBannerView.instance.show();
+            if (!RB.EnabledFeatures.unifiedBanner) {
+                RB.DraftReviewBannerView.instance.show();
+            }
         });
 
         this.$el.addClass('draft');
diff --git a/reviewboard/static/rb/js/views/floatingBannerView.es6.js b/reviewboard/static/rb/js/views/floatingBannerView.es6.js
index 09a27002e7c05ee34912b96132b46f8203667c66..5ea3b4f3de9d2cbe069aa4b4569184a0c93386e6 100644
--- a/reviewboard/static/rb/js/views/floatingBannerView.es6.js
+++ b/reviewboard/static/rb/js/views/floatingBannerView.es6.js
@@ -87,6 +87,8 @@ RB.FloatingBannerView = Backbone.View.extend({
      * container.
      */
     _updateFloatPosition() {
+        let $container;
+
         if (this.$el.parent().length === 0) {
             return;
         }
@@ -96,14 +98,18 @@ RB.FloatingBannerView = Backbone.View.extend({
             this._updateSize();
         }
 
-        const $container = this.options.$floatContainer;
+        if (this.options.floatContainerSelector) {
+            $container = $(this.options.floatContainerSelector);
+        } else {
+            $container = this.options.$floatContainer;
+        }
+
         const containerTop = $container.offset().top;
         const containerHeight = $container.outerHeight();
         const containerBottom = containerTop + containerHeight;
         const windowTop = $(window).scrollTop();
         const topOffset = this._$floatSpacer.offset().top - windowTop;
         const outerHeight = this.$el.outerHeight(true);
-
         const wasFloating = this.$el.hasClass('floating');
 
         if (!$container.hasClass(this.options.noFloatContainerClass) &&
@@ -133,7 +139,10 @@ RB.FloatingBannerView = Backbone.View.extend({
 
                 this.$el
                     .addClass('floating')
-                    .css('position', 'fixed');
+                    .css({
+                        'position': 'fixed',
+                        'margin-top': 0
+                    });
             }
 
             this.$el.css('top',
@@ -153,6 +162,7 @@ RB.FloatingBannerView = Backbone.View.extend({
                 .css({
                     top: '',
                     position: '',
+                    'margin-top': '',
                 });
             this._$floatSpacer
                 .height('auto')
diff --git a/reviewboard/static/rb/js/views/reviewDialogView.es6.js b/reviewboard/static/rb/js/views/reviewDialogView.es6.js
index f5fcce951c5652c2d123f4f828c360ef470dafe5..68f357522b652b0a853d0a106287e59e9e252729 100644
--- a/reviewboard/static/rb/js/views/reviewDialogView.es6.js
+++ b/reviewboard/static/rb/js/views/reviewDialogView.es6.js
@@ -1275,10 +1275,15 @@ RB.ReviewDialogView = Backbone.View.extend({
             .val(gettext('Discard'))
             .click(() => {
                 this.close();
-                this.model.destroy({
-                    success: () => RB.DraftReviewBannerView.instance
-                        .hideAndReload(),
-                });
+                if (RB.EnabledFeatures.unifiedBanner) {
+                    RB.UnifiedReviewBannerView.getInstance().model.destroyAllAndReset();
+                } else {
+                    this.model.destroy({
+                        success: () => {
+                            RB.DraftReviewBannerView.instance.hideAndReload();
+                        }
+                    });
+                }
             });
 
         $('<p/>')
@@ -1367,17 +1372,32 @@ RB.ReviewDialogView = Backbone.View.extend({
         });
 
         $.funcQueue('reviewForm').add(() => {
-            const reviewBanner = RB.DraftReviewBannerView.instance;
 
             this.close();
 
-            if (reviewBanner) {
-                if (publish) {
-                    reviewBanner.hideAndReload();
-                } else if (this.model.isNew() && !madeChanges) {
-                    reviewBanner.hide();
-                } else {
-                    reviewBanner.show();
+            /*
+             * TODO: We will need to revisit this when combining the new Unified
+             * Review Banner and the Review Composer.
+             */
+            if (RB.EnabledFeatures.unifiedBanner) {
+                const reviewBanner = RB.UnifiedReviewBannerView.getInstance();
+
+                if (reviewBanner) {
+                    if (publish) {
+                        reviewBanner.reload();
+                    }
+                }
+            } else {
+                const reviewBanner = RB.DraftReviewBannerView.instance;
+
+                if (reviewBanner) {
+                    if (publish) {
+                        reviewBanner.hideAndReload();
+                    } else if (this.model.isNew() && !madeChanges) {
+                        reviewBanner.hide();
+                    } else {
+                        reviewBanner.show();
+                    }
                 }
             }
         });
diff --git a/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js b/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js
index b38bda0ebfc5394787abce7ad9f4fd03ddb231c4..7a4cb0cf65b635012f774f2a73648520796480ba 100644
--- a/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js
+++ b/reviewboard/static/rb/js/views/reviewRequestEditorView.es6.js
@@ -474,7 +474,11 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
                 .html(err.errorText)
                 .show();
         });
-        this.listenTo(view, 'fieldSaved', this.showBanner);
+
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.listenTo(view, 'fieldSaved', this.showBanner);
+        }
+
 
         if (this.rendered) {
             view.render();
@@ -528,7 +532,9 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
          * We need to show any banners before we render the fields, since the
          * banners can add their own fields.
          */
-        this.showBanner();
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.showBanner();
+        }
 
         if (this.model.get('editable')) {
             RB.DnDUploader.instance.registerDropTarget(
@@ -586,10 +592,15 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
         });
 
         this.model.on('closeError', errorText => alert(errorText));
-        this.model.on('saved', this.showBanner, this);
+
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            this.listenTo(this.model, 'saved', this.showBanner);
+        }
         this.model.on('published', this._refreshPage, this);
         reviewRequest.on('closed reopened', this._refreshPage, this);
-        draft.on('destroyed', this._refreshPage, this);
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            draft.on('destroyed', this._refreshPage, this);
+        }
 
         window.onbeforeunload = this._onBeforeUnload.bind(this);
 
@@ -808,7 +819,12 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
 
         view.on('beginEdit', () => this.model.incr('editCount'));
         view.on('endEdit', () => this.model.decr('editCount'));
-        view.on('commentSaved', () => RB.DraftReviewBannerView.instance.show());
+
+        if (!RB.EnabledFeatures.unifiedBanner) {
+            view.on('commentSaved', () => {
+                RB.DraftReviewBannerView.instance.show();
+            });
+        }
     },
 
     /**
diff --git a/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.js b/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.js
index b27eec74a913d58cc200a9fbf73840f1da296d1c..a762e1232721cf8cfd84bb8a3828921c6d5adc4b 100644
--- a/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.js
+++ b/reviewboard/static/rb/js/views/tests/reviewRequestEditorViewTests.js
@@ -83,12 +83,15 @@ suite('rb/views/ReviewRequestEditorView', function() {
             ' <a class="delete">X</a>',
             '</div>'
         ].join('')),
+        unifiedBannerFeatureFlag,
         $filesContainer,
         $screenshotsContainer;
 
     beforeEach(function() {
         var $el = $(template()).appendTo($testsScratch);
 
+        unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+        RB.EnabledFeatures.unifiedBanner = false;
         RB.DnDUploader.create();
 
         reviewRequest = new RB.ReviewRequest({
@@ -217,6 +220,7 @@ suite('rb/views/ReviewRequestEditorView', function() {
 
     afterEach(function() {
         RB.DnDUploader.instance = null;
+        RB.EnabledFeatures.unifiedBanner =  unifiedBannerFeatureFlag;
     });
 
     describe('Actions bar', function() {
diff --git a/reviewboard/static/rb/js/views/tests/unifiedReviewBannerViewTests.es6.js b/reviewboard/static/rb/js/views/tests/unifiedReviewBannerViewTests.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..94d314d7ce6c0d1979e72a68a94f7e00d86428a3
--- /dev/null
+++ b/reviewboard/static/rb/js/views/tests/unifiedReviewBannerViewTests.es6.js
@@ -0,0 +1,270 @@
+suite('rb/views/UnifiedReviewBannerView', function() {
+    describe('UnifiedReviewBannerView', function() {
+        const template = _.template(dedent`
+                <div id="unified-banner">
+                  <div class="unified-banner">
+                   <div id="ub-main" class="hidden"></div>
+                   <div id="ub-page" class="hidden"></div>
+                   <div id="ub-stats" class="hidden"></div>
+                   <div id="ub-bottom" class="hidden"></div>
+                  </div>
+                  <div id="ub-docked" class="unified-banner hidden"></div>
+                </div>
+            `);
+
+        let model,
+            pendingReview,
+            reviewRequest,
+            banner,
+            unifiedBannerFeatureFlag;
+
+        beforeEach(function () {
+            const $el = $(template()).appendTo($testsScratch);
+
+            unifiedBannerFeatureFlag = RB.EnabledFeatures.unifiedBanner;
+            RB.EnabledFeatures.unifiedBanner = true;
+
+            reviewRequest = new RB.ReviewRequest({
+                id: 1
+            });
+            pendingReview = reviewRequest.createReview(1);
+            model = new RB.UnifiedReviewBannerState({
+                reviewRequest: reviewRequest,
+                pendingReview: pendingReview,
+            });
+            banner = new RB.UnifiedReviewBannerView({
+                model: model,
+                el: $el,
+            });
+
+            banner.render();
+        });
+
+        afterEach(function() {
+            RB.EnabledFeatures.unifiedBanner = unifiedBannerFeatureFlag;
+        });
+
+        describe('Actions', function() {
+            it('_setDraft call method', function() {
+                let unifiedBanner = banner.$el.find('.unified-banner');
+
+                expect(unifiedBanner.hasClass('draft')).toBe(false);
+
+                banner._setDraft(true);
+
+                expect(unifiedBanner.hasClass('draft')).toBe(true);
+
+                banner._setDraft(false);
+
+                expect(unifiedBanner.hasClass('draft')).toBe(false);
+            });
+
+            it('_setDraft through model change', function() {
+                let unifiedBanner = banner.$el.find('.unified-banner');
+
+                expect(unifiedBanner.hasClass('draft')).toBe(false);
+
+                banner.model.set('hasDraft', true);
+
+                expect(unifiedBanner.hasClass('draft')).toBe(true);
+
+                banner.model.set('hasDraft', false);
+
+                expect(unifiedBanner.hasClass('draft')).toBe(false);
+            });
+        });
+    });
+
+    describe('RegistryItem', function() {
+        describe('Construction', function() {
+            it('With HTML element view', function() {
+                const view = '<span>TEST</span>';
+                const registryItem = new RB.RegistryItem({
+                    view: view,
+                });
+
+                expect(registryItem.get('view')).toBe(view);
+                expect(registryItem.get('$view')).toEqual($(view));
+            });
+
+            it('With Backbone view', function() {
+                const defaults = {
+                    buttonId: "test",
+                    buttonText: "test",
+                    buttonAction: () => {},
+                };
+                const view = new RB.ButtonView({
+                    buttonId: defaults.buttonId,
+                    buttonText: defaults.buttonText,
+                    buttonAction: defaults.buttonAction,
+                });
+                const registryItem = new RB.RegistryItem({
+                    view: view,
+                });
+
+                expect(registryItem.get('view')).toBe(view);
+                expect(registryItem.get('$view')).toEqual($(view.el));
+            });
+        });
+    });
+
+    describe('Registry', function() {
+        describe('Construction', function() {
+            it('With no default items', function() {
+                const bannerLocation = RB.BANNER_LOCATIONS.MAIN;
+                const registry = new RB.Registry({
+                    bannerLocation: bannerLocation,
+                });
+
+                expect(registry.get('populated')).toBe(false);
+                expect(registry.get('defaultItems').length).toBe(0);
+                expect(registry.get('bannerLocation')).toBe(bannerLocation);
+                expect(registry.get('registryCollection')).toBeTruthy();
+                expect(registry.get('registryCollection').length).toBe(0);
+            });
+
+            it('With default items', function() {
+                const bannerLocation = RB.BANNER_LOCATIONS.MAIN;
+                const defaultItems = [
+                    '<span>TEST</span>'
+                ];
+                const registry = new RB.Registry({
+                    bannerLocation: bannerLocation,
+                    defaultItems: defaultItems,
+                });
+
+                expect(registry.get('populated')).toBe(true);
+                expect(registry.get('defaultItems')).toBe(defaultItems);
+                expect(registry.get('bannerLocation')).toBe(bannerLocation);
+                expect(registry.get('registryCollection')).toBeTruthy();
+                expect(registry.get('registryCollection').length).toBe(defaultItems.length);
+            });
+        });
+
+        describe('Actions', function() {
+            let defaultItems,
+                registry;
+
+            beforeEach(function() {
+                const bannerLocation = RB.BANNER_LOCATIONS.MAIN;
+                defaultItems = [
+                    '<span>TEST 1</span>',
+                    '<span>TEST 2</span>',
+                    '<span>TEST 3</span>',
+                ];
+                registry = new RB.Registry({
+                    bannerLocation: bannerLocation,
+                    defaultItems: defaultItems,
+                });
+            });
+
+            it('getItem with positive index', function() {
+                const expected = registry.get('registryCollection').at(0);
+                const observed = registry.getItem(0);
+
+                expect(observed).toBe(expected);
+            });
+
+            it('getItem with negative index', function() {
+                const expected = registry.get('registryCollection').at(2);
+                const observed = registry.getItem(-1);
+
+                expect(observed).toBe(expected);
+            });
+
+            it('register', function() {
+                const newItem = '<span>NEW ITEM</span>';
+
+                expect(registry.get('registryCollection').length).toBe(3);
+
+                registry.register({
+                    view: newItem,
+                });
+
+                expect(registry.get('registryCollection').length).toBe(4);
+                expect(registry.get('registryCollection').at(3).get('view')).toBe(newItem);
+            });
+
+            it('unregister', function() {
+                const itemToRemove = registry.get('registryCollection').at(0);
+                expect(registry.get('registryCollection').length).toBe(3);
+
+                registry.unregister(itemToRemove);
+
+                expect(registry.get('registryCollection').length).toBe(2);
+                expect(registry.get('registryCollection').at(0)).not.toBe(itemToRemove);
+            });
+
+            it('unregisterAtIndex', function() {
+                const itemToRemove = registry.get('registryCollection').at(0);
+                expect(registry.get('registryCollection').length).toBe(3);
+
+                registry.unregisterAtIndex(0);
+
+                expect(registry.get('registryCollection').length).toBe(2);
+                expect(registry.get('registryCollection').at(0)).not.toBe(itemToRemove);
+            });
+
+            it('unregisterAll', function() {
+                expect(registry.get('registryCollection').length).toBe(3);
+
+                registry.unregisterAll();
+
+                expect(registry.get('registryCollection').length).toBe(0);
+            });
+
+            it('populate from populated', function() {
+                expect(registry.get('populated')).toBe(true);
+                expect(registry.get('registryCollection').length).toBe(defaultItems.length);
+
+                registry.populate(); // should do nothing because already populated
+
+                expect(registry.get('populated')).toBe(true);
+                expect(registry.get('registryCollection').length).toBe(defaultItems.length);
+            });
+
+            it('populated from unpopulated', function() {
+                registry.get('registryCollection').reset();
+                registry.set('populated', false);
+
+                expect(registry.get('populated')).toBe(false);
+                expect(registry.get('registryCollection').length).toBe(0);
+
+                registry.populate();
+
+                expect(registry.get('populated')).toBe(true);
+                expect(registry.get('registryCollection').length).toBe(defaultItems.length);
+            });
+
+            it('isPopulated from populated', function() {
+                expect(registry.isPopulated()).toBe(true);
+                expect(registry.isPopulated()).toBe(registry.get('populated'));
+            });
+
+            it('isPopulated from unpopulated', function() {
+                registry.set('populated', false);
+
+                expect(registry.isPopulated()).toBe(false);
+                expect(registry.isPopulated()).toBe(registry.get('populated'));
+            });
+
+            it('getDefaults', function() {
+                const expected = [
+                    {
+                        view: defaultItems[0],
+                    },
+                    {
+                        view: defaultItems[1],
+                    },
+                    {
+                        view: defaultItems[2],
+                    }
+                ];
+
+                const observed = registry.getDefaults();
+
+                expect(expected).toEqual(observed);
+            });
+        });
+    });
+});
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/views/unifiedReviewBannerView.es6.js b/reviewboard/static/rb/js/views/unifiedReviewBannerView.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..954a800905ea47b30b9753f1252306e342fcb079
--- /dev/null
+++ b/reviewboard/static/rb/js/views/unifiedReviewBannerView.es6.js
@@ -0,0 +1,481 @@
+/**
+ * A unified, multi-mode banner that provides basic support for publishing,
+ * editing, and discarding reviews, review requests, and review replies.
+ *
+ * Views can be registered on the banner by creating a Registry with one of
+ * the banner locations in RB.BANNER_LOCATIONS.
+ *
+ * The banner displays at the top of the page under the topbar and floats to
+ * the top of the page when the user scrolls down.
+ *
+ * This banner is a lazy singleton, so there is only ever one at a time and
+ * it can be accessed easily from anywhere.
+ */
+RB.UnifiedReviewBannerView = RB.FloatingBannerView.extend({
+    el: '#unified-banner',
+
+    /**
+     * Initialize the view.
+     *
+     * Args:
+     *     options (object):
+     *         Options for the view.
+     *
+     * Option Args:
+     *     $floatContainer (jQuery, optional):
+     *         jQuery object for the container that the banner should float to
+     *         the top of. One of $floatContainer or floatContainerSelector must
+     *         be provided for the parent `RB.FloatingBannerView` class.
+     *
+     *     floatContainerSelector (string, optional):
+     *         String selector for the HTML element for the container that the
+     *         banner should float to the top of. One of $floatContainer or
+     *         floatContainerSelector must be provided for the parent
+     *         `RB.FloatingBannerView` class.
+     *
+     *     noFloatContainerClass (string):
+     *         If the $floatContainer has this class, we will not float the banner
+     *         to the top of it.
+     *
+     */
+    initialize(options) {
+        RB.FloatingBannerView.prototype.initialize.call(this, options);
+        this.options = options;
+    },
+
+    /**
+     * Renders the banner.
+     *
+     * Returns:
+     *     RB.UnifiedReviewBannerView:
+     *     This object.
+     */
+    render() {
+        _super(this).render.call(this);
+
+        this.listenTo(this.model, 'change:hasDraft', (model, hasDraft) => {
+           this._setDraft(hasDraft);
+        });
+
+        return this;
+    },
+
+    /**
+     * Return the height of the banner.
+     *
+     * Returns:
+     *     number:
+     *     The height of the banner.
+     */
+    getHeight() {
+        return this.$el.outerHeight();
+    },
+
+    /**
+     * Add or remove draft class from banner element.
+     *
+     * If isDraft is true, add the draft class to the unified banner element.
+     * If isDraft is false, remove the draft class from the unified banner
+     * element.
+     *
+     * Args:
+     *     isDraft (boolean):
+     *         Whether there the banner is in a draft state.
+     */
+    _setDraft(isDraft) {
+        if (isDraft) {
+            this.$el.find('.unified-banner').addClass('draft');
+        } else {
+            this.$el.find('.unified-banner').removeClass('draft');
+        }
+    },
+}, {
+    _instance: null,
+
+    _defaultOptions: {
+        floatContainerSelector: '#container',
+        noFloatContainerClass: 'collapsed',
+    },
+
+    /**
+     * Create a UnifiedReviewBannerView instance.
+     *
+     * If floatContainerSelector or noFloatContainerClass options are not
+     * specified, defaults are provided to the view.
+     *
+     * Args:
+     *     options (object, optional):
+     *         Options for the view.
+     *
+     * Option Args:
+     *     $floatContainer (jQuery, optional):
+     *         jQuery object for the container that the banner should float to
+     *         the top of. One of $floatContainer or floatContainerSelector must
+     *         be provided for the parent `RB.FloatingBannerView` class.
+     *
+     *     floatContainerSelector (string, optional):
+     *         String selector for the HTML element for the container that the
+     *         banner should float to the top of. One of $floatContainer or
+     *         floatContainerSelector must be provided for the parent
+     *         `RB.FloatingBannerView` class.
+     *
+     *     noFloatContainerClass (string):
+     *         If the $floatContainer has this class, we will not float the banner
+     *         to the top of it.
+     *
+     * Returns:
+     *     RB.UnifiedReviewBannerView:
+     *     The unified banner instance.
+     */
+    create(options = {}) {
+        console.assert(!RB.UnifiedReviewBannerView._instance,
+                       'A UnifiedReviewBanner already exists');
+
+        if (!options) {
+            options = this._defaultOptions;
+        } else if (!options.$floatContainer ||
+                   !options.floatContainerSelector) {
+            options.floatContainerSelector =
+                this._defaultOptions.floatContainerSelector;
+        } else if (!options.noFloatContainerClass) {
+            options.noFloatContainerClass =
+                this._defaultOptions.noFloatContainerClass;
+        }
+
+        const unifiedReviewBanner = new RB.UnifiedReviewBannerView(options);
+
+        RB.UnifiedReviewBannerView._instance = unifiedReviewBanner;
+        unifiedReviewBanner.render();
+
+        return unifiedReviewBanner;
+    },
+
+    /**
+     * Get the unified review banner singleton.
+     *
+     * If the instance does not exist yet, create the instance, then return
+     * it.
+     *
+     * Returns:
+     *     RB.UnifiedReviewBannerView:
+     *     The banner view.
+     */
+    getInstance(options) {
+        if (!RB.UnifiedReviewBannerView._instance) {
+            return RB.UnifiedReviewBannerView.create(options);
+        }
+
+        return RB.UnifiedReviewBannerView._instance;
+    },
+});
+
+/**
+ * Enum for the banner locations on the unified banner (as specified in
+ * reviewable_base.html).
+ */
+RB.BANNER_LOCATIONS = {
+    MAIN: "unified-banner-main",
+    PAGE: "unified-banner-page",
+    STATS: "unified-banner-stats",
+    BOTTOM: "unified-banner-bottom",
+    DOCKED: "unified-banner-docked",
+};
+
+/**
+ * Item to be added to the Registry Collection.
+ *
+ * Model Attributes:
+ *     view (Backbone.View or string):
+ *         The view that will be registered on the banner.
+ *
+ *     $view (jQuery):
+ *         The jQuery object for the view for convenient appending to
+ *         the banner.
+ */
+RB.RegistryItem = Backbone.Model.extend({
+    defaults: {
+        view: null,
+        $view: null,
+    },
+
+    /**
+     * Initialize the Registry Item.
+     *
+     * Sets the $view attribute as a jQuery object by checking if it is a
+     * Backbone.View (view.$el) or HTML string.
+     */
+    initialize() {
+        const view = this.get('view');
+        console.assert(view, 'view must be provided');
+
+        if (view.$el) {
+            this.set('$view', view.$el);
+        } else {
+            this.set('$view', $(view));
+        }
+    },
+});
+
+/**
+ * A collection of Registry Items that represents the views registered on
+ * the banner.
+ */
+RB.RegistryCollection = Backbone.Collection.extend({
+    model: RB.RegistryItem,
+});
+
+/**
+ * A registry with a collection of views that are registered on a banner.
+ *
+ * Model Attributes:
+ *    bannerLocation (string from RB.BANNER_LOCATIONS):
+ *        The id of the banner location where the views should be registered.
+ *
+ *    defaultItems (array of objects):
+ *        The default items to be registered in the registry.
+ *
+ *    populated (boolean):
+ *        Whether the registry is currently populated, that is, whether the
+ *        registry currently has items registered to it.
+ *
+ *    registryCollection (Collection of RB.RegistryItem):
+ *        The collection of items registered in the registry.
+ */
+RB.Registry = Backbone.Model.extend({
+    defaults: {
+        bannerLocation: null,
+        defaultItems: [],
+        populated: false,
+        registryCollection: null,
+    },
+
+    /**
+     * Initialize the Registry.
+     *
+     * Creates a new Registry Collection in the Registry and populates the
+     * Registry.
+     */
+    initialize() {
+        console.assert(this.get('bannerLocation'),
+            'banner location must be provided');
+        this.set('registryCollection', new RB.RegistryCollection());
+        this.populate();
+    },
+
+    /**
+     * Return an item by its registered index.
+     *
+     * Args:
+     *     index (int):
+     *         The position at which the item was registered (Backbone
+     *         Collections are ordered). This is 0-based and negative indices
+     *         are supported.
+     *
+     * Returns:
+     *     RB.RegistryItem:
+     *     The item in the registry at the given index.
+     */
+    getItem(index) {
+        this.populate();
+
+        const collection = this.get('registryCollection');
+        const collectionLength = collection.length;
+
+        if (index < 0) {
+            index += collectionLength;
+        }
+
+        if (index > collectionLength - 1) {
+            console.error(`Index (${index}) is out of range.`);
+            return;
+        }
+
+        return collection.at(index);
+    },
+
+    /**
+     * Register an item.
+     *
+     * An item is registered when it is added to the
+     * collection of views and the element appended to the banner location
+     * in the DOM.
+     *
+     * Args:
+     *     item (object):
+     *         The item to register.
+     */
+    register(item) {
+        this.populate();
+
+        const $bannerLocation = $('#' + this.get('bannerLocation'));
+        const registryCollection = this.get('registryCollection');
+
+        // Add the view to the array of views
+        const registeredItem = registryCollection.add(item);
+
+        // Add element to the banner location
+        const $registeredItem = registeredItem.get('$view');
+
+        $registeredItem.appendTo($bannerLocation);
+        if (!$registeredItem.is('div')) {
+            registeredItem.get('$view').wrap('<div></div>');
+        }
+
+        if ($bannerLocation.hasClass('hidden')) {
+            $bannerLocation.removeClass('hidden');
+        }
+    },
+
+    /**
+     * Unregister an item from the registry.
+     *
+     * Args:
+     *   item (object):
+     *     The item to unregister.
+     */
+    unregister(item) {
+        this.populate();
+
+        const registryCollection = this.get('registryCollection');
+
+        item.get('$view').remove();
+        registryCollection.remove(item);
+        if (registryCollection.length === 0) {
+            $('#' + this.get('bannerLocation')).addClass('hidden');
+        }
+    },
+
+    /**
+     * Unregister an item from the registry by an index.
+     *
+     * Args:
+     *   index (int):
+     *     The index of the item to unregister.
+     */
+    unregisterAtIndex(index) {
+        this.populate();
+
+        const item = this.get('registryCollection').at(index);
+
+        this.unregister(item);
+    },
+
+    /**
+     * Unregister all items from the registry and mark the registry as
+     * unpopulated.
+     */
+    unregisterAll() {
+        const registryCollection = this.get('registryCollection');
+
+        while (registryCollection.length > 0) {
+            const item = registryCollection.pop();
+            item.get('$view').remove();
+        }
+        $('#' + this.get('bannerLocation')).addClass('hidden');
+
+        this.set('populated', false);
+    },
+
+    /**
+     * Ensure the registry is populated.
+     *
+     * Calling this method when the registry is populated will have no effect.
+     */
+    populate() {
+        const defaults = this.getDefaults();
+
+        if (this.isPopulated()) {
+            return;
+        }
+
+        if (defaults.length > 0) {
+            this.set('populated', true);
+
+            defaults.forEach(item => {
+                this.register(item);
+            });
+        }
+    },
+
+    /**
+     * Whether or not the registry is populated.
+     *
+     * Returns:
+     *    bool:
+     *    Whether or not the registry is populated.
+     */
+    isPopulated() {
+        return this.get('populated');
+    },
+
+    /**
+     * Return the default items for the registry.
+     *
+     * Returns:
+     *     list:
+     *     The default items for the registry.
+     */
+    getDefaults() {
+        let defaultRegistryItems = [];
+
+        _.each(this.get('defaultItems'), function(defaultItem) {
+            defaultRegistryItems.push({
+                view: defaultItem,
+            });
+        });
+        return defaultRegistryItems;
+    },
+});
+
+/**
+ * Generic Button Backbone View.
+ *
+ * This view will render a button that can have a value passed into it for the
+ * button text and an action for the click event.
+ */
+RB.ButtonView = Backbone.View.extend({
+    template: _.template(
+        '<input type="button" value="<%- buttonText %>">'
+    ),
+
+    events: {
+        'click input': '_onClicked'
+    },
+
+    /**
+     * Initialize the view.
+     *
+     * Args:
+     *     options (object):
+     *         Options for the view.
+     *
+     * Options Args:
+     *     buttonText (string):
+     *         The text to be shown on the button.
+     *
+     *     buttonAction (function, optional):
+     *         The function to be called on button click.
+     *
+     */
+    initialize(options) {
+        this.options = options;
+
+        console.assert(options.buttonText, 'button text must be provided');
+
+        this.$el.html(this.template({
+            buttonText: gettext(options.buttonText),
+        }));
+
+        this.render();
+    },
+
+    /**
+     * Click handler. Behaviour should be provided by options.buttonAction.
+     */
+    _onClicked() {
+        const buttonAction = this.options.buttonAction;
+        if (_.isFunction(buttonAction)) {
+            buttonAction();
+        }
+    },
+});
\ No newline at end of file
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index c4b30d6b2de0cbe66b41969491425f50a86d98ad..b62e7cd03dadfc04e3935050de5cd2b5f52c07c9 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -69,6 +69,7 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/models/tests/reviewRequestEditorModelTests.js',
             'rb/js/models/tests/uploadDiffModelTests.js',
             'rb/js/models/tests/userSessionModelTests.js',
+            'rb/js/models/tests/unifiedReviewBannerStateTests.es6.js',
             'rb/js/newReviewRequest/views/tests/branchesViewTests.js',
             'rb/js/newReviewRequest/views/tests/postCommitViewTests.js',
             'rb/js/newReviewRequest/views/tests/repositorySelectionViewTests.js',
@@ -134,6 +135,7 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/views/tests/reviewRequestEditorViewTests.js',
             'rb/js/views/tests/reviewRequestFieldViewsTests.es6.js',
             'rb/js/views/tests/screenshotThumbnailViewTests.js',
+            'rb/js/views/tests/unifiedReviewBannerViewTests.es6.js',
         ),
         'output_filename': 'rb/js/js-tests.min.js',
     },
@@ -258,6 +260,7 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/models/screenshotReviewableModel.es6.js',
             'rb/js/models/textBasedCommentBlockModel.es6.js',
             'rb/js/models/textBasedReviewableModel.es6.js',
+            'rb/js/models/unifiedReviewBannerState.es6.js',
             'rb/js/models/uploadDiffModel.js',
             'rb/js/pages/models/reviewablePageModel.es6.js',
             'rb/js/pages/models/diffViewerPageModel.es6.js',
@@ -290,8 +293,9 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/views/textBasedReviewableView.es6.js',
             'rb/js/views/textCommentRowSelector.es6.js',
             'rb/js/views/markdownReviewableView.es6.js',
-            'rb/js/views/uploadDiffView.es6.js',
-            'rb/js/views/updateDiffView.es6.js',
+            'rb/js/views/unifiedReviewBannerView.es6.js',
+            'rb/js/views/uploadDiffView.js',
+            'rb/js/views/updateDiffView.js',
             'rb/js/diffviewer/models/commitHistoryDiffEntry.es6.js',
             'rb/js/diffviewer/models/diffCommentBlockModel.es6.js',
             'rb/js/diffviewer/models/diffCommentsHintModel.es6.js',
diff --git a/reviewboard/templates/base.html b/reviewboard/templates/base.html
index 10311c48ed7b6264149e43441bb6c946c6b23eac..580b70ba89e98bcf2b888982868b024def5ebfa5 100644
--- a/reviewboard/templates/base.html
+++ b/reviewboard/templates/base.html
@@ -82,6 +82,7 @@
    </div>
 
    <div id="page-container">
+{% block unified_banner %}{% endblock %}
     <noscript>
 {%  box "important" %}
      <h1>{% trans "JavaScript is turned off" %}</h1>
diff --git a/reviewboard/templates/reviews/reviewable_base.html b/reviewboard/templates/reviews/reviewable_base.html
index fb3c3a5f10c3688bf40f0907e749cf2e2e66cfa8..1e5c9cb81ab1eabc06d192333e387c72c9a49d2f 100644
--- a/reviewboard/templates/reviews/reviewable_base.html
+++ b/reviewboard/templates/reviews/reviewable_base.html
@@ -1,5 +1,5 @@
 {% extends "base.html" %}
-{% load i18n pipeline %}
+{% load i18n pipeline features %}
 
 {% block extrahead %}
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
@@ -42,11 +42,27 @@
 
 {% block page_class %}reviewable-page{% endblock %}
 
+{% block unified_banner %}
+{% if_feature_enabled 'reviews.unified_banner' %}
+<div id="unified-banner">
+ <div class="unified-banner">
+  <div id="unified-banner-main" class="hidden"></div>
+  <div id="unified-banner-page" class="hidden"></div>
+  <div id="unified-banner-stats" class="hidden"></div>
+  <div id="unified-banner-bottom" class="hidden"></div>
+ </div>
+ <div id="unified-banner-docked" class="unified-banner hidden"></div>
+</div>
+{% endif_feature_enabled %}
+{{block.super}}
+{% endblock %}
+
 {% block bodytag %}
 {{block.super}}
 
+{% if_feature_disabled 'reviews.unified_banner' %}
 {%  block review_banner %}
-<div id="review-banner"{% if not review %} class="hidden"{% endif %}>
+<div id="review-banner" {% if not review %} class="hidden"{% endif %}>
  <div class="banner">
   <h1>{% trans "You have a pending review." %}</h1>
   <input id="review-banner-edit" type="button" value="{% trans "Edit Review" %}" />
@@ -60,4 +76,5 @@
  </div>
 </div>
 {%  endblock %}
+{% endif_feature_disabled %}
 {% endblock %}
