diff --git a/packages/eui/changelogs/upcoming/9251.md b/packages/eui/changelogs/upcoming/9251.md
new file mode 100644
index 00000000000..8ac89d57fbb
--- /dev/null
+++ b/packages/eui/changelogs/upcoming/9251.md
@@ -0,0 +1 @@
+- Added `useEuiContainerQuery` hook to observe container query changes in JavaScript
diff --git a/packages/eui/src/services/container_query/container_query_hook.spec.tsx b/packages/eui/src/services/container_query/container_query_hook.spec.tsx
new file mode 100644
index 00000000000..d8039b93c82
--- /dev/null
+++ b/packages/eui/src/services/container_query/container_query_hook.spec.tsx
@@ -0,0 +1,129 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+///
+///
+///
+
+import React from 'react';
+
+import { useEuiContainerQuery } from './container_query_hook';
+
+const TestComponent = ({
+ condition,
+ name,
+}: {
+ condition: string;
+ name?: string;
+}) => {
+ const { ref, matches } = useEuiContainerQuery(
+ condition,
+ name
+ );
+
+ return (
+
+ );
+
+ // targets the container named
+ cy.get('[data-test-subj="child"]')
+ .invoke('attr', 'data-matches')
+ .should('eq', 'true');
+
+ // resize back to larger width
+ cy.get('[data-test-subj="container"]')
+ .invoke('css', 'width', '250px')
+ .wait(100);
+
+ cy.get('[data-test-subj="child"]')
+ .invoke('attr', 'data-matches')
+ .should('eq', 'false');
+
+ cy.get('[data-test-subj="container"]').should(
+ 'have.css',
+ 'container',
+ 'my-container / inline-size'
+ );
+ });
+});
diff --git a/packages/eui/src/services/container_query/container_query_hook.ts b/packages/eui/src/services/container_query/container_query_hook.ts
new file mode 100644
index 00000000000..b74a37ca8e5
--- /dev/null
+++ b/packages/eui/src/services/container_query/container_query_hook.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { useEffect, useState, useRef } from 'react';
+import { matchContainer } from './match_container';
+
+/**
+ * React hook that subscribes to CSS container query changes.
+ *
+ * For this to work:
+ * - a proper container (an element with e.g. `container-type: inline-size`) is needed, and
+ * - the container MUST to be a parent of the element being observed
+ *
+ * @param containerCondition - A CSS `` string, e.g. `(width > 400px)` or `(min-width: 600px)`
+ * @param name - Optional container name, e.g. `sidebar`
+ * @returns An object containing:
+ * - `ref`: A ref to attach to the container element
+ * - `matches`: `true` if the container matches the condition, `false` otherwise
+ *
+ * @example
+ * ```tsx
+ * const { ref, matches } = useEuiContainerQuery('(width > 400px)');
+ * return
{matches ? 'Wide' : 'Narrow'}
;
+ * ```
+ *
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container | MDN: @container}
+ * @beta
+ */
+export function useEuiContainerQuery(
+ containerCondition: string,
+ name?: string
+) {
+ const containerQueryString = name
+ ? `${name} ${containerCondition}`
+ : containerCondition;
+ const ref = useRef(null);
+ const [matches, setMatches] = useState(false);
+
+ useEffect(() => {
+ if (!ref.current) return;
+
+ const queryList = matchContainer(ref.current, containerQueryString);
+ const handleChange = () => {
+ setMatches(queryList.matches);
+ };
+
+ setMatches(queryList.matches);
+ queryList.addEventListener('change', handleChange);
+
+ return () => {
+ queryList.removeEventListener('change', handleChange);
+ queryList.dispose();
+ };
+ }, [ref, containerQueryString]);
+
+ return { ref, matches };
+}
diff --git a/packages/eui/src/services/container_query/index.ts b/packages/eui/src/services/container_query/index.ts
new file mode 100644
index 00000000000..26bba8a4c4c
--- /dev/null
+++ b/packages/eui/src/services/container_query/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export * from './container_query_hook';
diff --git a/packages/eui/src/services/container_query/match_container.spec.tsx b/packages/eui/src/services/container_query/match_container.spec.tsx
new file mode 100644
index 00000000000..7d3b988d2da
--- /dev/null
+++ b/packages/eui/src/services/container_query/match_container.spec.tsx
@@ -0,0 +1,159 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+///
+///
+///
+
+import {
+ matchContainer,
+ ContainerQueryList,
+ ContainerQueryListChangeEvent,
+} from './match_container';
+
+describe('matchContainer', () => {
+ before(() => {
+ cy.document().then((doc) => {
+ // create container element
+ const parent = doc.createElement('div');
+ parent.setAttribute('data-test-subj', 'container');
+ parent.style.containerType = 'inline-size';
+
+ // create child
+ const child = doc.createElement('div');
+ child.setAttribute('data-test-subj', 'child');
+ child.textContent = 'Child element text';
+ child.style.outline = '1px solid cyan';
+ parent.appendChild(child);
+
+ doc.body.appendChild(parent);
+ });
+ });
+
+ beforeEach(() => {
+ cy.get('[data-test-subj="container"]')
+ .should('exist')
+ .as('container')
+ .invoke('css', 'width', '250px');
+ cy.get('[data-test-subj="child"]').should('exist').as('child');
+ });
+
+ it('returns a ContainerQueryList instance', () => {
+ const condition = '(width > 100px)';
+
+ cy.get('@child').then(($child) => {
+ const cql = matchContainer($child[0], condition);
+ expect(cql).to.be.instanceOf(ContainerQueryList);
+ expect(cql).to.have.property('matches');
+ expect(cql).to.have.property('container');
+ expect(cql.container).to.equal(condition);
+ cql.dispose();
+ });
+ });
+
+ it('matches is true when container query condition is met', () => {
+ cy.get('@child').then(($child) => {
+ const cql = matchContainer($child[0], '(width > 100px)');
+ expect(cql.matches).to.equal(true);
+ cql.dispose();
+ });
+ });
+
+ it('matches is false when container query condition is not met', () => {
+ cy.get('@child').then(($child) => {
+ const cql = matchContainer($child[0], '(width > 1000px)');
+ expect(cql.matches).to.equal(false);
+ cql.dispose();
+ });
+ });
+
+ it('matches is updated when DOM changes', () => {
+ cy.get('@child').then(($child) => {
+ const cql = matchContainer($child[0], '(width > 200px)');
+ expect(cql.matches).to.equal(true);
+
+ cy.get('@container')
+ .invoke('css', 'width', '100px')
+ .wait(100)
+ .then(() => {
+ expect(cql.matches).to.equal(false);
+ cql.dispose();
+ });
+ });
+ });
+
+ it('fires change event when matches changes', () => {
+ const changeSpy = cy.spy().as('changeSpy');
+ cy.get('@child').then(($child) => {
+ const cql = matchContainer($child[0], '(width <= 250px)');
+ cql.addEventListener('change', changeSpy);
+ expect(cql.matches).to.equal(true);
+
+ cy.get('[data-test-subj="container"]')
+ .invoke('css', 'width', '300px')
+ .wait(100)
+ .then(() => {
+ expect(changeSpy).to.have.been.calledOnce;
+ expect(changeSpy.firstCall.args[0]).to.be.instanceOf(
+ ContainerQueryListChangeEvent
+ );
+ expect(cql.matches).to.equal(false);
+ })
+ .invoke('css', 'width', '100px')
+ .wait(100)
+ .then(() => {
+ expect(changeSpy).to.have.been.calledTwice;
+ expect(cql.matches).to.equal(true);
+ cql.dispose();
+ })
+ .invoke('css', 'width', '400px')
+ .wait(100)
+ .then(() => {
+ // no more after cql.dispose() above
+ expect(changeSpy).to.have.been.calledTwice;
+ });
+ });
+ });
+
+ it('cleans up properly when calling dispose', () => {
+ cy.get('@child').then(($child) => {
+ const child = $child[0];
+ const cql = matchContainer(child, '(width > 100px)');
+ expect(cql.matches).to.equal(true);
+
+ // verify setup happened
+ const hasMarkerBefore = Array.from(child.attributes).some((attr) =>
+ attr.name.startsWith('data-container-query-observer-')
+ );
+ expect(hasMarkerBefore).to.equal(true);
+ expect(child.style.getPropertyValue('transition')).to.not.equal('');
+ expect(child.style.getPropertyValue('transition-behavior')).to.equal(
+ 'allow-discrete'
+ );
+
+ cy.document().then((doc) => {
+ expect(doc.adoptedStyleSheets.length).to.be.greaterThan(0);
+
+ // cleanup happens here
+ cql.dispose();
+
+ expect(doc.adoptedStyleSheets.length).to.equal(0);
+
+ const hasMarkerAfter = Array.from(child.attributes).some((attr) =>
+ attr.name.startsWith('data-container-query-observer-')
+ );
+ expect(hasMarkerAfter).to.equal(false);
+
+ expect(child.style.getPropertyValue('transition')).to.equal('');
+ expect(child.style.getPropertyValue('transition-behavior')).to.equal(
+ ''
+ );
+ });
+ });
+ });
+});
diff --git a/packages/eui/src/services/container_query/match_container.ts b/packages/eui/src/services/container_query/match_container.ts
new file mode 100644
index 00000000000..42348540b6a
--- /dev/null
+++ b/packages/eui/src/services/container_query/match_container.ts
@@ -0,0 +1,234 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+/**
+ * @license MIT License
+ *
+ * Copyright (c) 2025 Martin Winkler
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * @see {@link https://github.com/teetotum/match-container/blob/main/matchContainer.js}
+ */
+
+import { v1 as uuid } from 'uuid';
+
+/**
+ * Listen for changes on a container query.
+ * Just like `window.matchMedia`.
+ *
+ * @example
+ * ```js
+ * const cql = matchContainer(element, '(width > 42rem)');
+ * cql.addEventListener('change', ({ matches }) {
+ * // ..
+ * })
+ * ```
+ *
+ * @param element
+ * @param containerQueryString e.g. (width > 42rem)
+ * @returns ContainerQueryList
+ */
+export function matchContainer(
+ element: HTMLElement,
+ containerQueryString: string
+) {
+ return new ContainerQueryList(element, containerQueryString);
+}
+
+/**
+ * `change` event dispatched by instances of {@link ContainerQueryList}
+ * whenever the value of `matches` changes
+ */
+export class ContainerQueryListChangeEvent extends Event {
+ /** Whether the container query matches */
+ readonly matches: boolean;
+ /** A string representation of the container query list e.g. "(width > 1000px)" */
+ readonly container: string;
+
+ constructor(matches: boolean, container: string) {
+ super('change');
+ this.matches = matches;
+ this.container = container;
+ }
+}
+
+/**
+ * A hacky implementation of a possible native `ContainerQueryList`
+ * based on the teetotum/match-container polyfill:
+ * - based on a API proposal in W3C CSS WG {@link https://github.com/w3c/csswg-drafts/issues/6205})
+ * - mimicking MediaQueryList {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList}
+ *
+ * Not meant to be used directly, but rather call `matchContainer`.
+ *
+ * It works by listening on a `transitionrun` event on the element,
+ * that gets triggered by a container query changing a custom property.
+ * Setting `transition-behavior: allow-discrete` is what makes it possible
+ * to have a CSS `transition` for a custom property.
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-behavior}
+ */
+export class ContainerQueryList extends EventTarget {
+ private element: HTMLElement | null = null;
+ private styleSheet: CSSStyleSheet | null = null;
+ private markerAttributeName: string = '';
+ private sentinelPropertyName: string = '';
+ private computedStyle: CSSStyleDeclaration | null = null;
+ private transitionRunListener: ((event: TransitionEvent) => void) | null =
+ null;
+
+ #matches: boolean = false;
+
+ /** Whether the container query matches */
+ get matches() {
+ return this.#matches;
+ }
+
+ /**
+ * A string representation of the container query list e.g. "(width > 1000px)"
+ * (the name is weird but it is so for consistency with mediaQueryList.media)
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/media}
+ * */
+ readonly container: string;
+
+ constructor(element: HTMLElement, containerQueryString: string) {
+ super();
+
+ this.container = containerQueryString;
+ this.element = element;
+ // we call this only once to try to avoid any impact on performance
+ this.computedStyle = getComputedStyle(this.element);
+
+ const uniqueName = `container-query-observer-${uuid()}`;
+ this.markerAttributeName = `data-${uniqueName}`;
+ this.sentinelPropertyName = `--${uniqueName}`;
+
+ // order is important (as in life)
+ this.applyMarkerAttribute();
+ this.createStyleSheet();
+ this.#matches =
+ this.computedStyle.getPropertyValue(this.sentinelPropertyName) ===
+ '--true';
+ this.setupTransitionListener();
+ }
+
+ /**
+ * The marker attribute is `data-container-query-observer-{UUID}`,
+ * it will be used as a selector in the container query,
+ * in the global CSS that's being added below.
+ */
+ private applyMarkerAttribute() {
+ this.element!.setAttribute(this.markerAttributeName, '');
+ }
+
+ /**
+ * Create a CSS custom property with values either `--true` or `--false`,
+ * and add container query targetting the element.
+ * Whenever the container query matches, the custom property will be `--true`.
+ * This styles are added globaly via `document.adoptedStyleSheets`.
+ */
+ private createStyleSheet() {
+ const css = `
+ @property ${this.sentinelPropertyName} {
+ syntax: '';
+ inherits: false;
+ initial-value: --false;
+ }
+ @container ${this.container} {
+ [${this.markerAttributeName}] {
+ ${this.sentinelPropertyName}: --true;
+ }
+ }
+ `;
+ const styleSheet = new CSSStyleSheet();
+ styleSheet.replaceSync(css);
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
+ this.styleSheet = styleSheet;
+ }
+
+ /**
+ * This is the key to the hack:
+ * - a `transition` style is added for the custom property
+ * - the `transitionrun` event will fire whenever the custom property value changes
+ * because of the container query
+ * - we get the value from computed styles
+ * - the `matches` value is updated and
+ * - a ContainerQueryListChangeEvent event is dispatched
+ */
+ private setupTransitionListener() {
+ const { element, computedStyle, sentinelPropertyName } = this;
+
+ if (!element) return;
+
+ let currentValue = computedStyle!.getPropertyValue(sentinelPropertyName);
+
+ element.style.setProperty(
+ 'transition',
+ `${sentinelPropertyName} 0.001ms step-start`
+ );
+ element.style.setProperty('transition-behavior', 'allow-discrete');
+
+ this.transitionRunListener = (event: TransitionEvent) => {
+ if (event.target !== element) return;
+
+ const nextValue = computedStyle!.getPropertyValue(sentinelPropertyName);
+ if (nextValue === currentValue) return;
+
+ currentValue = nextValue;
+ this.#matches = nextValue === '--true';
+
+ this.dispatchEvent(
+ new ContainerQueryListChangeEvent(this.#matches, this.container)
+ );
+ };
+
+ element.addEventListener('transitionrun', this.transitionRunListener);
+ }
+
+ dispose() {
+ // remove the stylesheet from adoptedStyleSheets
+ if (this.styleSheet) {
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
+ (sheet) => sheet !== this.styleSheet
+ );
+ }
+
+ if (!this.element) return;
+
+ if (this.transitionRunListener) {
+ this.element.removeEventListener(
+ 'transitionrun',
+ this.transitionRunListener
+ );
+ }
+
+ this.element.removeAttribute(this.markerAttributeName);
+ this.element.style.removeProperty('transition');
+ this.element.style.removeProperty('transition-behavior');
+
+ this.element = null;
+ this.styleSheet = null;
+ this.computedStyle = null;
+ this.transitionRunListener = null;
+ }
+}