diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor
new file mode 100644
index 0000000000..41f3a8560a
--- /dev/null
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/Examples/OverlayHtmlElement.razor
@@ -0,0 +1,57 @@
+
+
+
+
+ Manual
+ All
+ Inside only
+ Outside only
+
+
+
+
Container
+
+
+
+
+
+
+
+
+
+
+
+@code
+{
+ bool FullScreen = false;
+ bool Interactive = false;
+ string CloseMode = "all";
+}
+
+
+
+
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md
new file mode 100644
index 0000000000..07b833be40
--- /dev/null
+++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overlay/FluentOverlay.md
@@ -0,0 +1,8 @@
+---
+title: Overlay
+route: /Overlay
+---
+
+# Overlay HTMLElement
+
+{{ OverlayHtmlElement }}
diff --git a/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts
new file mode 100644
index 0000000000..1748bd7e47
--- /dev/null
+++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay-Styles.ts
@@ -0,0 +1,63 @@
+export const fluentOverlayStyles: string = `
+ :host {
+ --overlayZIndex: 99999;
+ --overlayBackground: '';
+ }
+
+ :host dialog {
+ border: none;
+ background: none;
+ z-index: var(--overlayZIndex);
+ }
+
+ :focus-visible {
+ outline: none;
+ }
+
+ :host([fullscreen]) dialog {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ margin: 0;
+ transform: translate(-50%, -50%);
+ }
+
+ :host([fullscreen]) dialog::backdrop {
+ background-color: var(--overlayBackground);
+ }
+
+ :host(:not([fullscreen])):has(dialog[open]) {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--overlayBackground);
+ }
+
+ :host(:not([fullscreen])) dialog {
+ position: fixed;
+ margin: 0;
+ transform: translate(-50%, -50%);
+ }
+
+ :host(:not([fullscreen])) dialog::backdrop {
+ background-color: transparent;
+ width: 0;
+ height: 0;
+ }
+
+ :host([interactive][fullscreen]):has(dialog[open]) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: calc(var(--overlayZIndex) - 1);
+ background-color: var(--overlayBackground);
+ }
+
+ :host([interactive]):has(dialog[open]) {
+ pointer-events: none;
+ }
+`;
diff --git a/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts b/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts
new file mode 100644
index 0000000000..8a24c97256
--- /dev/null
+++ b/src/Core.Scripts/src/Components/Overlay/FluentOverlay.ts
@@ -0,0 +1,344 @@
+import { StartedMode } from "../../d-ts/StartedMode";
+import { fluentOverlayStyles } from "./FluentOverlay-Styles";
+
+export namespace Microsoft.FluentUI.Blazor.Components.Overlay {
+
+ /**
+ * CloseMode type
+ * - manual: Do not close automatically
+ * - all: Close on any click
+ * - inside: Close on click inside the dialog
+ * - outside: Close on click outside the dialog
+ */
+ export type CloseMode = 'manual' | 'all' | 'inside' | 'outside' | null;
+
+ /**
+ * FluentOverlay web component
+ */
+ class FluentOverlay extends HTMLElement {
+
+ private readonly DEFAULT_BACKGROUND: string = 'color-mix(in srgb, var(--colorBackgroundOverlay) 40%, transparent)';
+ private container: HTMLElement | null = null;
+ private dialog: HTMLDialogElement | null = null;
+ private resizeObserver: ResizeObserver | null = null;
+ private clickHandler: ((ev: MouseEvent) => any) | null = null;
+
+ private _opened: boolean = false;
+
+ /************************
+ Initialization
+ ************************/
+ constructor() {
+ super();
+ }
+
+ // Delay initialization to ensure child content is parsed
+ connectedCallback() {
+ setTimeout(() => {
+ this.initialize();
+ }, 0);
+ }
+
+ private initialize() {
+ this.container = this.parentElement;
+ const shadow = this.attachShadow({ mode: 'open' });
+
+ // Create the dialog element
+ this.dialog = document.createElement('dialog') as HTMLDialogElement;
+ this.dialog.setAttribute('fuib', '');
+ this.dialog.setAttribute('part', 'dialog'); // To allow styling using `fluent-overlay::part(dialog)`
+
+ this.dialog.addEventListener('toggle', (e) => {
+ // Dispatch event when closed
+ this.dispatchOpenedEvent(e.newState === 'open');
+ });
+
+ // Set initial styles for the dialog
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(fluentOverlayStyles);
+ this.shadowRoot!.adoptedStyleSheets = [
+ ...(this.shadowRoot!.adoptedStyleSheets || []),
+ sheet
+ ];
+
+ // Slot for user content
+ const contentSlot = document.createElement('slot');
+ while (this.firstChild) {
+ contentSlot.appendChild(this.firstChild);
+ }
+ this.dialog.appendChild(contentSlot);
+ shadow.appendChild(this.dialog);
+ }
+
+ /************************
+ Public Properties
+ ************************/
+
+ // Property getter/setter for fullscreen
+ public get fullscreen(): boolean {
+ return this.hasAttribute('fullscreen');
+ }
+
+ public set fullscreen(value: boolean) {
+ if (value) {
+ this.setAttribute('fullscreen', '');
+ } else {
+ this.removeAttribute('fullscreen');
+ }
+ }
+
+ // Property getter/setter for interactive
+ public get interactive(): boolean {
+ return this.hasAttribute('interactive');
+ }
+
+ public set interactive(value: boolean) {
+ if (value) {
+ this.setAttribute('interactive', '');
+ } else {
+ this.removeAttribute('interactive');
+ }
+ }
+
+ // Property getter/setter for interactive-outside
+ public get closeMode(): CloseMode {
+ return this.getAttribute('close-mode') as CloseMode;
+ }
+
+ public set closeMode(value: CloseMode) {
+ if (value) {
+ this.setAttribute('close-mode', value);
+ } else {
+ this.removeAttribute('close-mode');
+ }
+ }
+
+ // Property getter/setter for background-color
+ public get background(): string {
+ return this.getAttribute('background') ?? this.DEFAULT_BACKGROUND;
+ }
+
+ public set background(value: string) {
+ if (value) {
+ this.setAttribute('background', '');
+ } else {
+ this.removeAttribute('background');
+ }
+ }
+
+ // Property getter/setter for dialog-style
+ public get dialogStyle(): string {
+ return this.getAttribute('dialog-style') ?? '';
+ }
+
+ public set dialogStyle(value: string) {
+ if (value) {
+ this.setAttribute('dialog-style', value);
+ } else {
+ this.removeAttribute('dialog-style');
+ }
+ }
+
+ // Property getter/setter for dialog-class
+ public get dialogClass(): string {
+ return this.getAttribute('dialog-class') ?? '';
+ }
+
+ public set dialogClass(value: string) {
+ if (value) {
+ this.setAttribute('dialog-class', value);
+ } else {
+ this.removeAttribute('dialog-class');
+ }
+ }
+
+ /************************
+ Public methods
+ ************************/
+
+ // Public method to open the dialog
+ public show() {
+ if (this.dialog && !this.dialog.open) {
+
+ this.dialog.setAttribute('style', this.dialogStyle);
+ this.dialog.setAttribute('class', this.dialogClass);
+
+ // Prevent to use ESC key to close the dialog
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy#browser_compatibility
+ if (this.closeMode === 'manual') {
+ this.dialog.setAttribute('closedBy', 'none');
+ }
+
+ if (this.fullscreen === false) {
+ this.ensureParentPositioning();
+ this.createResizeObserver();
+ this.positionDialogInContainer();
+ }
+ else {
+ this.positionDialogClear();
+ }
+
+ if (this.background) {
+ this.style.setProperty('--overlayBackground', this.background);
+ }
+
+ if (this.interactive || !this.fullscreen) {
+ this.dialog.show();
+ } else {
+ this.dialog.showModal();
+ }
+
+ if (!this.clickHandler) {
+ this.clickHandler = (e) => this.onClick(e);
+
+ // Use capture phase and delay listener registration to avoid capturing the current click
+ setTimeout(() => {
+ document.addEventListener('click', this.clickHandler!);
+ }, 0);
+ }
+ }
+ }
+
+ // Public method to close the dialog
+ public close() {
+ if (this.dialog && this.dialog.open) {
+ this.dialog.close();
+
+ // Remove the click event listener if it exists
+ if (this.clickHandler) {
+ document.removeEventListener('click', this.clickHandler);
+ this.clickHandler = null;
+ }
+ }
+ }
+
+ /************************
+ Private methods
+ ************************/
+
+ // Private method to handle click events
+ private onClick(event: MouseEvent): void {
+ if (this.dialog && this.dialog.open) {
+ const insideDialog = this.isClickInsideDialog(event);
+ event.stopPropagation();
+
+ if (this.closeMode === `all` || this.closeMode === null) {
+ this.close();
+ return;
+ }
+ if (this.closeMode === `inside` && insideDialog) {
+ this.close();
+ return;
+ }
+ if (this.closeMode === `outside` && !insideDialog) {
+ this.close();
+ return;
+ }
+ }
+ }
+
+ // Private method to check if a click event is inside the dialog
+ private isClickInsideDialog(event: MouseEvent): boolean {
+
+ if (!this.dialog) {
+ return false;
+ }
+
+ const dialogRect = this.dialog.getBoundingClientRect();
+ const clickX = event.clientX;
+ const clickY = event.clientY;
+
+ return clickX >= dialogRect.left &&
+ clickX <= dialogRect.right &&
+ clickY >= dialogRect.top &&
+ clickY <= dialogRect.bottom;
+ }
+
+ // Private method to ensure parent has proper positioning
+ private ensureParentPositioning(): void {
+ const parent = this.parentElement;
+ if (parent) {
+ const computedStyle = window.getComputedStyle(parent);
+ const position = computedStyle.position;
+
+ // Check if position is one of the required values
+ if (position !== 'relative' && position !== 'absolute' && position !== 'fixed' && position !== 'sticky') {
+ parent.style.position = 'relative';
+ }
+ }
+ }
+
+ // Private method to position the dialog in the container
+ private positionDialogInContainer(): void {
+ if (this.container && this.dialog && this.fullscreen === false) {
+ const containerRect = this.container.getBoundingClientRect();
+ this.dialog.style.top = `${containerRect.top + containerRect.height / 2}px`;
+ this.dialog.style.left = `${containerRect.left + containerRect.width / 2}px`;
+ }
+ }
+
+ // Private method to clear the dialog position
+ private positionDialogClear(): void {
+ if (this.dialog) {
+ this.dialog.style.top = '';
+ this.dialog.style.left = '';
+ }
+ }
+
+ // Subscribe to container size changes
+ private createResizeObserver(): void {
+ if (!this.resizeObserver && this.container) {
+ this.resizeObserver = new ResizeObserver(() => {
+ this.positionDialogInContainer();
+ });
+ this.resizeObserver.observe(this.container);
+ }
+ }
+
+ // Private method to clean up the resize observer
+ private cleanResizeObserver(): void {
+ if (this.dialog) {
+ this.dialog.style.top = '';
+ this.dialog.style.left = '';
+ }
+
+ this.resizeObserver?.disconnect();
+ this.resizeObserver = null;
+ }
+
+ // Cleanup when element is removed from DOM
+ disconnectedCallback() {
+ this.cleanResizeObserver();
+
+ // Remove the click event listener if it exists
+ if (this.clickHandler) {
+ document.removeEventListener('click', this.clickHandler);
+ this.clickHandler = null;
+ }
+ }
+
+ // Dispatch event when opened or closed
+ private dispatchOpenedEvent(opened: boolean) {
+ this.dispatchEvent(new CustomEvent('toggle', {
+ detail: {
+ oldState: opened ? 'closed' : 'open',
+ newState: opened ? 'open' : 'closed',
+ },
+ bubbles: true,
+ composed: true
+ }));
+ }
+
+ }
+
+ /**
+ * Register the FluentOverlay component
+ * @param blazor
+ * @param mode
+ */
+ export const registerComponent = (blazor: Blazor, mode: StartedMode): void => {
+ if (typeof blazor.addEventListener === 'function' && mode === StartedMode.Web) {
+ customElements.define('fluent-overlay', FluentOverlay);
+ }
+ };
+}
diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts
index 78eb30f65e..f14ea31af8 100644
--- a/src/Core.Scripts/src/Startup.ts
+++ b/src/Core.Scripts/src/Startup.ts
@@ -3,6 +3,7 @@ import { Microsoft as ThemeFile } from './Utilities/Theme';
import { Microsoft as FluentUIComponentsFile } from './FluentUIWebComponents';
import { Microsoft as FluentPageScriptFile } from './Components/PageScript/FluentPageScript';
import { Microsoft as FluentPopoverFile } from './Components/Popover/FluentPopover';
+import { Microsoft as FluentOverlayFile } from './Components/Overlay/FluentOverlay';
import { Microsoft as FluentUIStylesFile } from './FluentUIStyles';
import { Microsoft as FluentUICustomEventsFile } from './FluentUICustomEvents';
import { StartedMode } from './d-ts/StartedMode';
@@ -14,6 +15,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup {
import FluentUIComponents = FluentUIComponentsFile.FluentUI.Blazor.FluentUIWebComponents;
import FluentPageScript = FluentPageScriptFile.FluentUI.Blazor.Components.PageScript;
import FluentPopover = FluentPopoverFile.FluentUI.Blazor.Components.Popover;
+ import FluentOverlay = FluentOverlayFile.FluentUI.Blazor.Components.Overlay;
import FluentUIStyles = FluentUIStylesFile.FluentUI.Blazor.FluentUIStyles;
import FluentUICustomEvents = FluentUICustomEventsFile.FluentUI.Blazor.FluentUICustomEvents;
@@ -52,6 +54,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup {
// Initialize all custom components
FluentPageScript.registerComponent(blazor, mode);
FluentPopover.registerComponent(blazor, mode);
+ FluentOverlay.registerComponent(blazor, mode);
// [^^^ Add your other custom components before this line ^^^]
// Register all custom events