diff --git a/core/misc/announce.js b/core/misc/announce.js
index 00ce14b..3e579de 100644
--- a/core/misc/announce.js
+++ b/core/misc/announce.js
@@ -26,7 +26,7 @@
    * to the DOM.
    */
   Drupal.behaviors.drupalAnnounce = {
-    attach: function (context) {
+    attach: function () {
       // Create only one aria-live element.
       if (!liveElement) {
         liveElement = document.createElement('div');
@@ -42,38 +42,44 @@
   /**
    * Concatenates announcements to a single string; appends to the live region.
    */
-  function announce() {
+  function processAnnounce () {
     var text = [];
     var priority = 'polite';
     var announcement;
 
-    // Create an array of announcement strings to be joined and appended to the
-    // aria live region.
-    for (var i = 0, il = announcements.length; i < il; i++) {
-      announcement = announcements.pop();
-      text.unshift(announcement.text);
-      // If any of the announcements has a priority of assertive then the group
-      // of joined announcements will have this priority.
-      if (announcement.priority === 'assertive') {
-        priority = 'assertive';
+    if (announcements.length) {
+      // Create an array of announcement strings to be joined and appended to the
+      // aria live region.
+      for (var i = 0, il = announcements.length; i < il; i++) {
+        announcement = announcements.pop();
+        text.unshift(announcement.text);
+        // If any of the announcements has a priority of assertive then the group
+        // of joined announcements will have this priority.
+        if (announcement.priority === 'assertive') {
+          priority = 'assertive';
+        }
       }
-    }
 
-    if (text.length) {
-      // Clear the liveElement so that repeated strings will be read.
-      liveElement.innerHTML = '';
-      // Set the busy state to true until the node changes are complete.
-      liveElement.setAttribute('aria-busy', 'true');
-      // Set the priority to assertive, or default to polite.
-      liveElement.setAttribute('aria-live', priority);
-      // Print the text to the live region. Text should be run through
-      // Drupal.t() before being passed to Drupal.announce().
-      liveElement.innerHTML = text.join('\n');
-      // The live text area is updated. Allow the AT to announce the text.
-      liveElement.setAttribute('aria-busy', 'false');
+      if (text.length) {
+        // Clear the liveElement so that repeated strings will be read.
+        liveElement.innerHTML = '';
+        // Set the busy state to true until the node changes are complete.
+        liveElement.setAttribute('aria-busy', 'true');
+        // Set the priority to assertive, or default to polite.
+        liveElement.setAttribute('aria-live', priority);
+        // Print the text to the live region. Text should be run through
+        // Drupal.t() before being passed to Drupal.announce().
+        liveElement.innerHTML = text.join('\n');
+        // The live text area is updated. Allow the AT to announce the text.
+        liveElement.setAttribute('aria-busy', 'false');
+      }
     }
   }
 
+  // 200 ms is right at the cusp where humans notice a pause, so we will wait
+  // at most this much time before the set of queued announcements is read.
+  var debouncedProcessAnnounce = debounce(processAnnounce, 200);
+
   /**
    * Triggers audio UAs to read the supplied text.
    *
@@ -85,24 +91,23 @@
    * These messages are then joined and append to the aria-live region as one
    * text node.
    *
-   * @param String text
+   * @param {String} text
    *   A string to be read by the UA.
-   * @param String priority
+   * @param {String} priority
    *   A string to indicate the priority of the message. Can be either
    *   'polite' or 'assertive'. Polite is the default.
    *
    * @see http://www.w3.org/WAI/PF/aria-practices/#liveprops
    */
   Drupal.announce = function (text, priority) {
-    // Save the text and priority into a closure variable. Multiple simultaneous
-    // announcements will be concatenated and read in sequence.
-    announcements.push({
-      text: text,
-      priority: priority
-    });
-    // Immediately invoke the function that debounce returns. 200 ms is right at
-    // the cusp where humans notice a pause, so we will wait
-    // at most this much time before the set of queued announcements is read.
-    return (debounce(announce, 200)());
+    if (typeof text === 'string') {
+      // Save the text and priority into a closure variable. Multiple simultaneous
+      // announcements will be concatenated and read in sequence.
+      announcements.push({
+        text: text,
+        priority: priority
+      });
+      debouncedProcessAnnounce(announcements);
+    }
   };
 }(Drupal, Drupal.debounce));
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000..44fb4f2
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,82 @@
+/**
+ * @file
+ * Message API.
+ */
+(function (Drupal, debounce) {
+
+  "use strict";
+
+  var messages = [];
+  var messagesElement;
+
+  /**
+   * Builds a div element with the aria-live attribute and attaches it to the DOM.
+   */
+  Drupal.behaviors.drupalMessage = {
+    attach: function () {
+      if (!messagesElement) {
+        messagesElement = document.querySelector('[data-drupal-messages]');
+        if (!messagesElement) {
+          throw new Error(Drupal.t("There is no element with a [data-drupal-messages] attribute"));
+        }
+      }
+    }
+  };
+
+  /**
+   * Displays all queued messages in one pass instead of one after the other.
+   * @see debouncedProcessMessages
+   */
+  function processMessages () {
+    var text = [];
+    var message;
+    if (messages.length) {
+      for (var n = 0, nl = messages.length; n < nl; n++) {
+        message = messages.pop();
+        text.unshift(Drupal.theme('message', message));
+      }
+      if (text.length) {
+        messagesElement.innerHTML += text.join('');
+      }
+    }
+  }
+
+  // Debounce the function in case Drupal.message() is used in a loop.
+  var debouncedProcessMessages = debounce(processMessages, 100);
+
+  /**
+   * Displays a message on the page.
+   *
+   * @param {String} message
+   *   The message to display
+   * @param {String} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   *   Default to 'status'
+   */
+  Drupal.message = function (message, type) {
+    if (typeof message === 'string') {
+      // Save the text and priority into a closure variable. Multiple simultaneous
+      // announcements will be concatenated and read in sequence.
+      messages.push({
+        text: message,
+        type: type || 'status'
+      });
+      debouncedProcessMessages(messages);
+    }
+  };
+
+  /**
+   * Theme function for a message.
+   *
+   * @param {Object} message
+   *   An object with the following keys:
+   *   - {String} text: The message text
+   *   - {String} type: The message type
+   * @return {String}
+   *   A string representing a DOM fragment.
+   */
+  Drupal.theme.message = function (message) {
+    return '<div class="messages messages--' + message.type + '">' + message.text + '</div>';
+  };
+
+}(Drupal, Drupal.debounce));
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
index 5e23103..ef63f09 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -1122,6 +1122,7 @@ function system_library_info() {
       array('system', 'drupal'),
       array('system', 'drupalSettings'),
       array('system', 'drupal.progress'),
+      array('system', 'drupal.message'),
       array('system', 'jquery.once'),
     ),
   );
@@ -1186,6 +1187,19 @@ function system_library_info() {
     ),
   );
 
+  // Drupal's message utility.
+  $libraries['drupal.message'] = array(
+    'title' => 'Drupal message',
+    'version' => \Drupal::VERSION,
+    'js' => array(
+      'core/misc/message.js' => array('group' => JS_LIBRARY),
+    ),
+    'dependencies' => array(
+      array('system', 'drupal'),
+      array('system', 'drupal.debounce'),
+    ),
+  );
+
   // Drupal's dialog component.
   $libraries['drupal.dialog'] = array(
     'title' => 'Drupal Dialog',
diff --git a/core/modules/system/templates/page.html.twig b/core/modules/system/templates/page.html.twig
index 1f6f916..eff4d20 100644
--- a/core/modules/system/templates/page.html.twig
+++ b/core/modules/system/templates/page.html.twig
@@ -104,7 +104,7 @@
 
   {{ breadcrumb }}
 
-  {{ messages }}
+  <div data-drupal-messages>{{ messages }}</div>
 
   {{ page.help }}
 
diff --git a/core/themes/bartik/templates/page.html.twig b/core/themes/bartik/templates/page.html.twig
index 5602997..1545fb1 100644
--- a/core/themes/bartik/templates/page.html.twig
+++ b/core/themes/bartik/templates/page.html.twig
@@ -128,11 +128,9 @@
     {% endif %}
   </div></header> <!-- /.section, /#header-->
 
-  {% if messages %}
-    <div id="messages"><div class="section clearfix">
-      {{ messages }}
-    </div></div> <!-- /.section, /#messages -->
-  {% endif %}
+  <div id="messages"><div class="section clearfix" data-drupal-messages>
+    {{ messages }}
+  </div></div> <!-- /.section, /#messages -->
 
   {% if page.featured %}
     <aside id="featured"><div class="section clearfix">
diff --git a/core/themes/seven/templates/page.html.twig b/core/themes/seven/templates/page.html.twig
index 77dde87..bff3eee 100644
--- a/core/themes/seven/templates/page.html.twig
+++ b/core/themes/seven/templates/page.html.twig
@@ -87,9 +87,7 @@
 
     <main id="content" class="clearfix" role="main">
       <div class="visually-hidden"><a id="main-content"></a></div>
-      {% if messages %}
-        <div id="console" class="clearfix">{{ messages }}</div>
-      {% endif %}
+      <div id="console" class="clearfix" data-drupal-messages>{{ messages }}</div>
       {% if page.help %}
         <div id="help">
           {{ page.help }}
