diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 56030cb..8f80ba7 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -213,6 +213,14 @@ drupal.machine-name:
     - core/drupal
     - core/drupalSettings
 
+drupal.message:
+  version: VERSION
+  js:
+    misc/message.js: {}
+  dependencies:
+    - core/drupal
+    - core/drupal.debounce
+
 drupal.progress:
   version: VERSION
   js:
diff --git a/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
index febf018..00f6430 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -104,6 +104,7 @@ public static function renderMessages(array $element, array $context) {
 
     // Render the messages.
     $messages = [
+      '#attached' => ['library' => ['core/drupal.message']],
       '#theme' => 'status_messages',
       // @todo Improve when https://www.drupal.org/node/2278383 lands.
       '#message_list' => drupal_get_messages($context['display']),
diff --git a/core/misc/announce.js b/core/misc/announce.js
index 9aafbbc..4a7f970 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,39 +42,45 @@
   /**
    * 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.
-    var il = announcements.length;
-    for (var i = 0; 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.
+      var il = announcements.length;
+      for (var i = 0; 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.
    *
@@ -86,24 +92,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/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig
index e479b87..b57a85b 100644
--- a/core/modules/system/templates/status-messages.html.twig
+++ b/core/modules/system/templates/status-messages.html.twig
@@ -25,25 +25,27 @@
  * @ingroup themeable
  */
 #}
-{% for type, messages in message_list %}
-  <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}>
-    {% if type == 'error' %}
-      <div role="alert">
-    {% endif %}
-      {% if status_headings[type] %}
-        <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
+<div data-drupal-messages>
+  {% for type, messages in message_list %}
+    <div role="contentinfo" aria-label="{{ status_headings[type] }}"{{ attributes|without('role', 'aria-label') }}>
+      {% if type == 'error' %}
+        <div role="alert">
       {% endif %}
-      {% if messages|length > 1 %}
-        <ul>
-          {% for message in messages %}
-            <li>{{ message }}</li>
-          {% endfor %}
-        </ul>
-      {% else %}
-        {{ messages|first }}
+        {% if status_headings[type] %}
+          <h2 class="visually-hidden">{{ status_headings[type] }}</h2>
+        {% endif %}
+        {% if messages|length > 1 %}
+          <ul>
+            {% for message in messages %}
+              <li>{{ message }}</li>
+            {% endfor %}
+          </ul>
+        {% else %}
+          {{ messages|first }}
+        {% endif %}
+      {% if type == 'error' %}
+        </div>
       {% endif %}
-    {% if type == 'error' %}
-      </div>
-    {% endif %}
-  </div>
-{% endfor %}
+    </div>
+  {% endfor %}
+</div>
\ No newline at end of file
diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig
index a58ed8f..136757c 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -23,6 +23,7 @@
  * @see template_preprocess_status_messages()
  */
 #}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -55,3 +56,4 @@
   {{ attributes.removeClass(classes) }}
 {% endfor %}
 {% endblock messages %}
+</div>
