diff --git a/addon/modifiers/mutation-observer.js b/addon/modifiers/mutation-observer.js
new file mode 100644
index 000000000..e05e17eef
--- /dev/null
+++ b/addon/modifiers/mutation-observer.js
@@ -0,0 +1,56 @@
+import Modifier from 'ember-modifier';
+import { assert } from '@ember/debug';
+import { registerDestructor } from '@ember/destroyable';
+
+/**
+ * @modifier mutation-observer
+ *
+ * This Ember modifier uses the MutationObserver API to observe changes in the
+ * DOM of a given element. It initializes a MutationObserver, attaches it to
+ * the provided DOM element, and invokes a callback whenever a mutation is detected.
+ * The modifier also automatically cleans up the observer when the element is destroyed.
+ *
+ *
+ * @param {Element} element - The DOM element to observe.
+ * @param {Function} callback - The callback function to be called when mutations are observed.
+ * @param {Object} config - Configuration options for MutationObserver, such as `{ childList: true, subtree: true }`.
+ *
+ * This modifier allows you to specify the DOM element you want to observe, a callback
+ * function that gets executed whenever a mutation occurs on that element, and a configuration
+ * object that defines what types of mutations to observe.
+ *
+ * The `config` parameter should be a JSON object that matches the options for
+ * `MutationObserver.observe`, such as `{ childList: true, attributes: true, subtree: true }`.
+ *
+ * @example
+ * ```hbs
+ *
+ *
+ *
+ * ```
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
+ */
+export default class MutationObserverModifier extends Modifier {
+ observer;
+
+ constructor(owner, args) {
+ super(owner, args);
+ // Register cleanup logic to disconnect the observer when destroyed
+ registerDestructor(this, cleanup);
+ }
+
+ modify(element, [callback], { config = { childList: true } }) {
+ assert(
+ '{{mutation-observer}} requires a callback as the first parameter',
+ typeof callback === 'function'
+ );
+
+ this.observer = new MutationObserver(callback);
+ this.observer.observe(element, config);
+ }
+}
+
+function cleanup(instance) {
+ instance.observer?.disconnect();
+}
diff --git a/app/modifiers/mutation-observer.js b/app/modifiers/mutation-observer.js
new file mode 100644
index 000000000..b6b597d4f
--- /dev/null
+++ b/app/modifiers/mutation-observer.js
@@ -0,0 +1 @@
+export { default } from 'ember-paper/modifiers/mutation-observer';
diff --git a/tests/integration/modifiers/mutation-observer-test.js b/tests/integration/modifiers/mutation-observer-test.js
new file mode 100644
index 000000000..24fa89c2d
--- /dev/null
+++ b/tests/integration/modifiers/mutation-observer-test.js
@@ -0,0 +1,117 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, settled } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { A } from '@ember/array';
+
+function items() {
+ return A(['ONE', 'TWO', 'THREE']);
+}
+
+module('Integration | Modifier | mutation-observer', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it does not respond if DOM elements do not change', async function (assert) {
+ assert.expect(1);
+
+ const callMeMaybe = () => {
+ assert.ok(false, 'No DOM change, callback should not be called');
+ };
+ this.set('callMeMaybe', callMeMaybe);
+
+ await render(hbs`
+
+ `);
+
+ assert.ok(true);
+ });
+
+ test('it responds by default when DOM elements are removed', async function (assert) {
+ assert.expect(4);
+
+ const callMeMaybe = () => {
+ assert.ok(true, 'Callback has been fired due to DOM removal');
+ };
+ this.set('callMeMaybe', callMeMaybe);
+ this.set('items', items());
+
+ await render(hbs`
+
+ {{#each items as |item|}}
+
{{item}}
+ {{/each}}
+
+ `);
+
+ this.items.removeAt(1);
+ await settled();
+
+ assert.dom('.ONE').exists();
+ assert.dom('.TWO').doesNotExist();
+ assert.dom('.THREE').exists();
+ });
+
+ test('it responds by default when DOM elements are added', async function (assert) {
+ assert.expect(9);
+
+ const callMeMaybe = () => {
+ assert.ok(true, 'Callback has been fired due to DOM addition');
+ };
+ this.set('callMeMaybe', callMeMaybe);
+ this.set('items', items());
+
+ await render(hbs`
+
+ {{#each this.items as |item|}}
+
{{item}}
+ {{/each}}
+
+ `);
+
+ assert.dom('.ONE').exists();
+ assert.dom('.TWO').exists();
+ assert.dom('.THREE').exists();
+ assert.dom('.FOUR').doesNotExist();
+
+ this.items.addObject('FOUR');
+ await settled();
+
+ assert.dom('.ONE').exists();
+ assert.dom('.TWO').exists();
+ assert.dom('.THREE').exists();
+ assert.dom('.FOUR').exists();
+ });
+
+ test('it responds by default when DOM elements are reordered', async function (assert) {
+ assert.expect(8);
+
+ const callMeMaybe = () => {
+ assert.ok(true, 'Callback has been fired due to DOM mutation');
+ };
+ this.set('callMeMaybe', callMeMaybe);
+ this.set('items', items());
+
+ await render(hbs`
+
+ {{#each this.items as |item|}}
+
{{item}}
+ {{/each}}
+
+ `);
+
+ assert.dom('.ONE').exists();
+ assert.dom('.TWO').exists();
+ assert.dom('.THREE').exists();
+
+ this.items.reverseObjects();
+ await settled();
+
+ assert.dom('.ONE').exists();
+ assert.dom('.TWO').exists();
+ assert.dom('.THREE').exists();
+ assert.equal(
+ this.element.textContent.trim(),
+ 'THREE\n TWO\n ONE'
+ );
+ });
+});