diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 8cb7b5b..6b4761f 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -225,6 +225,15 @@ drupal.machine-name:
     - core/drupalSettings
     - core/drupal.form
 
+drupal.message:
+  version: VERSION
+  js:
+    misc/message.js: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - core/drupal.debounce
+
 drupal.progress:
   version: VERSION
   js:
diff --git a/core/misc/announce.js b/core/misc/announce.js
index 235c597..88ad661 100644
--- a/core/misc/announce.js
+++ b/core/misc/announce.js
@@ -31,7 +31,7 @@
    * @type {Drupal~behavior}
    */
   Drupal.behaviors.drupalAnnounce = {
-    attach: function (context) {
+    attach: function () {
       // Create only one aria-live element.
       if (!liveElement) {
         liveElement = document.createElement('div');
@@ -47,39 +47,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.
    *
@@ -91,9 +97,9 @@
    * messages. 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='polite']
+   * @param {String} priority
    *   A string to indicate the priority of the message. Can be either
    *   'polite' or 'assertive'.
    *
@@ -102,15 +108,14 @@
    * @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..47e6335
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,104 @@
+/**
+ * @file
+ * Message API.
+ */
+(function ($, Drupal, debounce) {
+
+  "use strict";
+
+  var messages = [],
+      messagesElement,
+      debouncedProcessMessages;
+
+  /**
+   * 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 = [],
+        message, n, nl;
+
+    if (messages.length) {
+      for (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.
+  debouncedProcessMessages = debounce(processMessages, 100);
+
+  Drupal.message = {};
+
+  /**
+   * 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'
+   * @param {String} context
+   *   The context of the message, used for removing messages again.
+   */
+  Drupal.message.add = function (message, type, context) {
+    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({
+        context: context || 'default',
+        text: message,
+        type: type || 'status'
+      });
+      debouncedProcessMessages(messages);
+    }
+  };
+
+  /**
+   * Removes messages from the page.
+   *
+   * @param {String} context
+   *   The message context to remove
+   * @param {String} type
+   *   Message type, can be either 'status', 'error' or 'warning'.
+   *   Default to 'status'
+   */
+  Drupal.message.remove = function (context, type) {
+    context = context || 'default';
+    type = type || 'status';
+    $(".messages--" + message.type + ".js-messages-context--" + message.context).remove();
+  };
+
+  /**
+   * 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, context) {
+    return '<div class="messages messages--' + message.type + ' js-messages js-messages-context--' + message.context + '">' + message.text + '</div>';
+  };
+
+}(jQuery, Drupal, Drupal.debounce));
diff --git a/core/modules/system/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig
index 73bd9b7..536a6de 100644
--- a/core/modules/system/templates/status-messages.html.twig
+++ b/core/modules/system/templates/status-messages.html.twig
@@ -23,25 +23,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 683a111..6042687 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -21,7 +21,7 @@
  *   - class: HTML classes.
  */
 #}
-{{ attach_library('classy/messages') }}
+<div data-drupal-messages>
 {% block messages %}
 {% for type, messages in message_list %}
   {%
@@ -54,3 +54,4 @@
   {{ attributes.removeClass(classes) }}
 {% endfor %}
 {% endblock messages %}
+</div>
