How to Create a Modal Popup in SPFx (4 Methods with Full Examples)

If you’ve been building SharePoint Framework web parts for a while, you’ve probably hit a point where you need to show a modal popup — maybe to confirm a delete action, collect some input, or display detailed info without navigating away from the page.

The good news? SPFx gives you multiple ways to do this. In this tutorial, I’ll walk you through three practical methods with real code so you can pick the one that fits your scenario.

Here’s what we’ll cover:

  • Method 1 — Fluent UI <Modal> component (best for web parts)
  • Method 2 — Fluent UI <Dialog> component (great for confirmations)
  • Method 3 — BaseDialog from @microsoft/sp-dialog (best for extensions)
  • Method 4 — Using Native HTML <dialog> Element

Prerequisites

Before we start, make sure you have:

  • Node.js (v18.x recommended for SPFx 1.18+)
  • SPFx Yeoman generator installed (@microsoft/generator-sharepoint)
  • A React-based SPFx solution scaffolded and ready
  • Basic familiarity with TypeScript and React hooks

If you haven’t scaffolded a solution yet, run:

yo @microsoft/sharepoint

Choose WebPartReact framework, and give it a name like ModalDemo.

“Modal” vs “Dialog” in SPFx

People often use these terms interchangeably, and honestly, in everyday SPFx work, they mean the same thing — a pop-up that appears over the page content. Technically, a modal blocks interaction with the rest of the page, while a dialog may or may not. But for this tutorial, I’m using both terms to mean the same thing.

Create a Modal Popup in SharePoint Framework (SPFx)

Now, let us see different methods to create a modal popup in SharePoint Framework (SPFx).

Method 1: Fluent UI <Modal> Component

This is my go-to method for web parts. The <Modal> component from @fluentui/react gives you a lot of control over how your pop-up looks and behaves. It’s a proper overlay that blocks the rest of the page while it’s open.

When to use this

  • You need a fully custom layout inside the pop-up
  • You want to show a form, a table, or rich content
  • You’re building inside a web part (not an extension)

Step 1 — Install Fluent UI (if not already installed)

In most SPFx 1.16+ projects, @fluentui/react is already available. Just double-check your package.json. If it’s missing:

npm install @fluentui/react --save

Also, install the hooks package:

npm install @fluentui/react-hooks --save

Step 2 — Build the Modal Component

Create a new file: "\src\webparts\spFxModelPoPups\components\MyModal.tsx"

import * as React from 'react';
import { Modal } from '@fluentui/react/lib/Modal';
import { PrimaryButton, DefaultButton, IconButton } from '@fluentui/react/lib/Button';
import { mergeStyleSets, FontWeights } from '@fluentui/react/lib/Styling';

const contentStyles = mergeStyleSets({
container: {
display: 'flex',
flexFlow: 'column nowrap',
alignItems: 'stretch',
width: 500,
},
header: {
flex: '1 1 auto',
display: 'flex',
alignItems: 'center',
fontWeight: FontWeights.semibold,
padding: '12px 12px 14px 24px',
backgroundColor: '#0078d4',
color: '#fff',
},
body: {
flex: '4 4 auto',
padding: '0 24px 24px',
overflowY: 'hidden',
},
footer: {
padding: '0 24px 24px',
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
},
});

interface IMyModalProps {
isOpen: boolean;
onDismiss: () => void;
}

const MyModal: React.FC<IMyModalProps> = ({ isOpen, onDismiss }) => {
return (
<Modal
isOpen={isOpen}
onDismiss={onDismiss}
isBlocking={true}
containerClassName={contentStyles.container}
>
<div className={contentStyles.header}>
<span>Employee Details</span>
<IconButton
iconProps={{ iconName: 'Cancel' }}
ariaLabel="Close popup modal"
onClick={onDismiss}
styles={{ root: { marginLeft: 'auto', color: '#fff' } }}
/>
</div>
<div className={contentStyles.body}>
<p>Name: John Smith</p>
<p>Department: Engineering</p>
<p>Location: Bengaluru</p>
</div>
<div className={contentStyles.footer}>
<DefaultButton text="Close" onClick={onDismiss} />
<PrimaryButton text="Save" onClick={() => { alert('Saved!'); onDismiss(); }} />
</div>
</Modal>
);
};

export default MyModal;
fluent ui modal component in spfx

Step 3 — Use it in Your Main Web Part Component

Open "\src\webparts\spFxModelPoPups\components\SpFxModelPoPups.tsx" and update it:

Note: The web part name and file names in the path above may vary based on your project structure. Please adjust them accordingly to match your actual file names.

import * as React from 'react';
import { useState } from 'react';
import { PrimaryButton } from '@fluentui/react/lib/Button';
import MyModal from './MyModal';

const SpFxModelPoPups: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);

return (
<div style={{ padding: '20px' }}>
<h2>Employee Directory</h2>
<PrimaryButton text="View Details" onClick={() => setIsModalOpen(true)} />
<MyModal
isOpen={isModalOpen}
onDismiss={() => setIsModalOpen(false)}
/>
</div>
);
};

export default SpFxModelPoPups;
fluentui react components

That’s it for Method 1. Click the button, modal opens. Click the X or Close button, modal dismisses.

spfx popup dialog

Key <Modal> Props You Should Know

PropWhat it does
isOpenControls whether the modal is visible
isBlockingIf true, user can’t click outside to close
onDismissCalled when modal wants to close (Escape key, backdrop click)
containerClassNameLets you apply custom styles to the outer container
dragOptionsMakes the modal draggable

Check out Bind SharePoint List Items to SPFx Fluent UI React Dropdown

Method 2: Fluent UI <Dialog> Component

If all you need is a confirm/cancel type popup in SPFx — like “Are you sure you want to delete this item?” — the <Dialog> component is cleaner and takes less code than <Modal>.

The <Dialog> component is purpose-built for quick confirmations and simple messages. It handles the layout for you: title, subtitle, buttons in the footer — all baked in.

A Real-World Example: Delete Confirmation

Here is a real world example to display a Delete confirmation message dialog box on a button click.

import * as React from 'react';
import { useState } from 'react';
import {
Dialog,
DialogType,
DialogFooter,
} from '@fluentui/react/lib/Dialog';
import { PrimaryButton, DefaultButton } from '@fluentui/react/lib/Button';

const DeleteConfirmation: React.FC = () => {
const [hideDialog, setHideDialog] = useState(true);

const dialogContentProps = {
type: DialogType.normal,
title: 'Delete Item',
subText: 'Are you sure you want to delete this item? This action cannot be undone.',
};

const modalProps = {
isBlocking: true,
};

const handleDelete = () => {
// your delete logic here
console.log('Item deleted!');
setHideDialog(true);
};

return (
<div>
<DefaultButton text="Delete Item" onClick={() => setHideDialog(false)} />
<Dialog
hidden={hideDialog}
onDismiss={() => setHideDialog(true)}
dialogContentProps={dialogContentProps}
modalProps={modalProps}
>
<DialogFooter>
<PrimaryButton onClick={handleDelete} text="Delete" />
<DefaultButton onClick={() => setHideDialog(true)} text="Cancel" />
</DialogFooter>
</Dialog>
</div>
);
};

export default DeleteConfirmation;
fluent ui dialog in spfx

Notice that <Dialog> uses hidden (the opposite of isOpen). It’s a small thing but it trips people up when switching between Dialog and Modal.

Using useBoolean for Cleaner State Management

One thing I love about the Fluent UI hooks package is useBoolean. Instead of managing useState manually for your open/close logic, you can do this:

import { useBoolean } from '@fluentui/react-hooks';

const [isDialogVisible, { setTrue: showDialog, setFalse: hideDialog }] = useBoolean(false);

Now instead of setIsDialogVisible(true) you just call showDialog(). Much more readable, especially when you have multiple dialogs on a page.

Method 3: BaseDialog from @microsoft/sp-dialog

This method is specifically for SPFx ExtensionsListView Command Sets and Application Customizers. The BaseDialog class from @microsoft/sp-dialog is designed to render dialogs outside the normal React component tree, which is exactly what you need in extensions where you don’t have a typical component hierarchy.

Important: When you scaffold a ListView Command Set, React is not included by default. So unlike web parts, you should build your dialog using vanilla DOM manipulation inside BaseDialog — no JSX, no Fluent UI imports needed.

When to use this

  • You want to display selected item details in a dialog
  • You’re building a ListView Command Set extension
  • You need to trigger a popup from a toolbar button on a SharePoint list

Step 1 — Scaffold an Extension

yo @microsoft/sharepoint

Select:

  • Extension → ListView Command Set
  • Name it: TaskDialog

Step 2 — Create the Dialog File

Create src/extensions/taskDialog/TaskDialog.ts.

This file contains a class that extends BaseDialog. Instead of using React or JSX, we build the dialog content using vanilla DOM methodsdocument.createElement, textContent, and addEventListener. SharePoint provides this.domElement as the dialog container, and we append our built elements directly into it.

The dialog shows five fields from the selected list item: ID, Title, Created By, Created date, and Modified date. A styled Close button calls this.close() to dismiss the dialog.

In onAfterClose(), we clear this.domElement.innerHTML to clean up the DOM between openings — this is the equivalent of ReactDOM.unmountComponentAtNode in React-based dialogs.

import { BaseDialog, IDialogConfiguration } from '@microsoft/sp-dialog';

export interface IItemDetails {
id: string;
title: string;
createdBy: string;
created: string;
modified: string;
}

export default class TaskDialog extends BaseDialog {
public itemDetails: IItemDetails;

constructor(itemDetails: IItemDetails) {
super({ isBlocking: true });
this.itemDetails = itemDetails;
}

public render(): void {
const { id, title, createdBy, created, modified } = this.itemDetails;

const container = document.createElement('div');
container.style.cssText = 'padding:24px;min-width:420px;font-family:"Segoe UI",sans-serif;';

const heading = document.createElement('div');
heading.style.cssText = 'font-size:20px;font-weight:600;margin-bottom:20px;color:#323130;';
heading.textContent = 'Item Details';

const table = document.createElement('table');
table.style.cssText = 'width:100%;border-collapse:collapse;';

const rows: [string, string][] = [
['ID', id],
['Title', title],
['Created By', createdBy],
['Created', created],
['Modified', modified],
];

rows.forEach(([label, value]) => {
const tr = document.createElement('tr');

const td1 = document.createElement('td');
td1.style.cssText = 'font-weight:600;padding:10px 12px;border-bottom:1px solid #edebe9;color:#605e5c;width:40%;';
td1.textContent = label;

const td2 = document.createElement('td');
td2.style.cssText = 'padding:10px 12px;border-bottom:1px solid #edebe9;color:#323130;';
td2.textContent = value;

tr.appendChild(td1);
tr.appendChild(td2);
table.appendChild(tr);
});

const footer = document.createElement('div');
footer.style.cssText = 'margin-top:24px;text-align:right;';

const closeBtn = document.createElement('button');
closeBtn.style.cssText = 'padding:8px 20px;background-color:#0078d4;color:#fff;border:none;border-radius:2px;cursor:pointer;font-size:14px;';
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', () => {
this.close().catch(() => { /* handle error */ });
});

footer.appendChild(closeBtn);
container.appendChild(heading);
container.appendChild(table);
container.appendChild(footer);

this.domElement.appendChild(container);
}

public getConfig(): IDialogConfiguration {
return { isBlocking: true };
}

protected onAfterClose(): void {
super.onAfterClose();
this.domElement.innerHTML = '';
}
}

Step 3 — Trigger It from Your Command Set

In TaskDialogCommandSet.ts, we import TaskDialog and wire it to COMMAND_1. The command stays hidden by default and only becomes visible when exactly one row is selected — handled inside _onListViewStateChanged.

When clicked, we read the selected row’s field values using getValueByName(). The Author field is a person field — SPFx returns it as an array of objects, so we extract the title property from the first element.

import { Log } from '@microsoft/sp-core-library';
import {
BaseListViewCommandSet,
type Command,
type IListViewCommandSetExecuteEventParameters,
type ListViewStateChangedEventArgs
} from '@microsoft/sp-listview-extensibility';
import TaskDialog from './TaskDialog';

export interface ITaskDialogCommandSetProperties {
sampleTextOne: string;
}

const LOG_SOURCE: string = 'TaskDialogCommandSet';

export default class TaskDialogCommandSet extends BaseListViewCommandSet<ITaskDialogCommandSetProperties> {

public onInit(): Promise<void> {
Log.info(LOG_SOURCE, 'Initialized TaskDialogCommandSet');

const viewDetailsCommand: Command = this.tryGetCommand('COMMAND_1');
viewDetailsCommand.visible = false;

this.context.listView.listViewStateChangedEvent.add(this, this._onListViewStateChanged);

return Promise.resolve();
}

public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
switch (event.itemId) {
case 'COMMAND_1': {
const row = event.selectedRows[0];

const id = String(row.getValueByName('ID') || '');
const title = String(row.getValueByName('Title') || '');
const created = new Date(row.getValueByName('Created') as string).toLocaleString();
const modified = new Date(row.getValueByName('Modified') as string).toLocaleString();

// Author is a person field — SPFx returns an array of objects with a title property
const authorRaw = row.getValueByName('Author');
let createdBy = '';
if (Array.isArray(authorRaw) && authorRaw.length > 0) {
const first = authorRaw[0];
createdBy = typeof first === 'object' && first !== null
? String((first as { title?: string; Title?: string }).title || (first as { title?: string; Title?: string }).Title || first)
: String(first);
}

const dialog = new TaskDialog({ id, title, createdBy, created, modified });
dialog.show().catch((err) => { console.error('TaskDialog error:', err); });
break;
}
default:
throw new Error('Unknown command');
}
}

private _onListViewStateChanged = (_args: ListViewStateChangedEventArgs): void => {
Log.info(LOG_SOURCE, 'List view state changed');

const viewDetailsCommand: Command = this.tryGetCommand('COMMAND_1');
if (viewDetailsCommand) {
// Show only when exactly one row is selected
viewDetailsCommand.visible = this.context.listView.selectedRows?.length === 1;
}

this.raiseOnChange();
}
}

Manifest file (TaskDialogCommandSet.manifest.json)

Only COMMAND_1 is defined. The title is set to “View Details” so the toolbar button is meaningful to the user.

{
  "$schema": "https://developer.microsoft.com/json-schemas/spfx/command-set-extension-manifest.schema.json",
  "id": "0465f20d-8ea6-4117-9e72-4924124472e1",
  "alias": "TaskDialogCommandSet",
  "componentType": "Extension",
  "extensionType": "ListViewCommandSet",
  "version": "*",
  "manifestVersion": 2,
  "requiresCustomScript": false,
  "items": {
    "COMMAND_1": {
      "title": { "default": "View Details" },
      "iconImageUrl": "icons/request.png",
      "type": "command"
    }
  }
}

Important: Always clean up the DOM

Always clear this.domElement.innerHTML in onAfterClose(). If you skip this, the previous dialog content remains in the DOM, and you’ll see stale or duplicated data the next time the dialog is opened.

spfx model popup on list view command set extension

Method 4: Native HTML <dialog> Element

This one’s worth knowing about. Modern browsers have a built-in <dialog> HTML element that works perfectly fine in SPFx. No Fluent UI needed. No extra packages. Just plain HTML and TypeScript.

It’s a bit more of a “do it yourself” approach, but it’s lightweight, and it works well for non-React web parts.

export default class NativeDialogWebPart extends BaseClientSideWebPart<{}> {
private _dialog: HTMLDialogElement;

public render(): void {
this.domElement.innerHTML = `
<div>
<button id="openBtn">Open Dialog</button>
<dialog id="myDialog">
<h2>Hello from a native dialog!</h2>
<p>This is a lightweight modal using the HTML dialog element.</p>
<button id="closeBtn">Close</button>
</dialog>
</div>
`;

this._dialog = this.domElement.querySelector('#myDialog') as HTMLDialogElement;

this.domElement.querySelector('#openBtn')!.addEventListener('click', () => {
(this._dialog as any).showModal();
});

this.domElement.querySelector('#closeBtn')!.addEventListener('click', () => {
this._dialog.close();
});
}
}
native dailog html in spfx web part

One heads-up: TypeScript’s type definitions in SPFx don’t always recognize the showModal() method on the HTMLDialogElement, so you may need to cast it to any like I did above. It’s a known limitation in the SPFx TypeScript setup, not a bug in your code.

Which Method Should You Pick?

Here’s a simple way to think about it:

  • Building a web part? → Use Fluent UI <Modal> (Method 1) for complex UIs, or <Dialog> (Method 2) for confirmations
  • Building a ListView Command Set extension? → Use BaseDialog (Method 3)
  • Want something ultra-lightweight with no extra packages? → Native HTML <dialog> (Method 4)
  • Need clean state management code? → Combine any React method with useBoolean from @fluentui/react-hooks

Common Issues and How to Fix Them

Here are some common issues that I faced and the fixes

ScenarioBest Approach
Modal is not blocking (user can click outside to close)Set isBlocking={true} on your <Modal> or <Dialog> component, or set isBlocking: true in modalProps.
Dialog re-renders show stale data
This usually happens in BaseDialog if you forget to call ReactDOM.unmountComponentAtNode in onAfterClose(). Always clean up.
TypeScript error: showModal is not a function
Cast your HTMLDialogElement to any before calling showModal(). It’s a TypeScript types issue, not a runtime issue.
Icons not rendering in Modal header
Make sure you’ve registered the Fluent UI icon set early in your component with initializeIcons() from @fluentui/react/lib/Icons.

import { initializeIcons } from '@fluentui/react/lib/Icons';
initializeIcons();
Modal appears behind other elements
This can happen if a parent element has a z-index or overflow: hidden set. SPFx’s modal should handle z-index automatically, but custom CSS on parent containers can interfere.

Testing Your Modal Popup in SPFx

Run your solution locally:

gulp serve

For web parts, it opens your local workbench at https://tenantname.sharepoint.com/sites/sitename/_layouts/15/workbench.aspx. For extensions, update the pageUrl in config/serve.json to point to an actual SharePoint list URL in your tenant.

When prompted in the browser, click Load debug scripts to allow your local code to run on the SharePoint page.

Download SPFx Modal Popup Solution

Click the button below to download the SPFx solution packages for all examples covered in this post, plus access to 100+ additional solutions.

Wrapping Up

In this tutorial, I explained various methods to create modal popups in SharePoint Framework (SPFx). For most web part scenarios, the Fluent UI <Modal> or <Dialog> component is all you need. For extensions, BaseDialog it gives you the right structure.

The key thing to remember is that Fluent UI keeps things consistent with the SharePoint UI. Your modal will look and feel native to SharePoint rather than like something bolted on.

Also, you may like:

Power Apps functions free pdf

30 Power Apps Functions

This free guide walks you through the 30 most-used Power Apps functions with real business examples, exact syntax, and results you can see.

Download User registration canvas app

DOWNLOAD USER REGISTRATION POWER APPS CANVAS APP

Download a fully functional Power Apps Canvas App (with Power Automate): User Registration App