Place overlays outside clipping containers while retaining React ownership.
Read the explanation, run the worked code, then complete the practice lab in Assignments. This advanced lesson includes worked code and a dedicated practice lab.
Understand the idea
A portal renders children into another DOM container while keeping them in the same React tree. Context still reaches those children and React events follow the React tree. This can help an overlay escape an ancestor with overflow clipping, but a portal alone does not provide modal behavior, focus management or accessibility.
This example portals a native dialog into document.body and opens it with showModal. The browser supplies modal focus behavior and makes the rest of the document inert while it is open. Give the dialog an accessible name, provide a visible close button and restore focus to the trigger when it closes. Escape should also close it. Test with a keyboard rather than only clicking.
The browser document is accessed after the component mounts. This keeps the initial server-compatible render free from document access. The dialog is only mounted when open, and effect cleanup closes it before removal. Modern native dialog support is assumed for this exercise; a custom modal needs an equally complete focus and keyboard strategy.
Run the example
Use a separate React practice project with a Node.js version supported by Vite. Create it once with these commands, then use its development URL. Do not paste this into the Edupoly website’s own source files.
npm create vite@latest react-practice -- --template react
cd react-practice
npm install
npm run dev
Replace src/App.jsx with the following complete example. Keep Vite’s generated src/main.jsx entry point.
src/App.jsx
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
function Modal({onClose}) {
const [host, setHost] = useState(null); const dialog = useRef(null);
useEffect(() => {setHost(document.body);}, []);
useEffect(() => {
if (!host) return;
const node = dialog.current; node.showModal();
return () => {node.close();};
}, [host]);
if (!host) return null;
return createPortal(<dialog ref={dialog} aria-labelledby="dialog-title" onCancel={e => {e.preventDefault(); onClose();}}>
<h2 id="dialog-title">Delete practice task?</h2><p>This demonstration does not delete saved data.</p><button onClick={onClose}>Cancel</button><button onClick={onClose}>Confirm demo</button>
</dialog>, host);
}
export default function App() {
const [open, setOpen] = useState(false); const trigger = useRef(null);
function close() {setOpen(false); trigger.current?.focus();}
return <main><button ref={trigger} onClick={() => setOpen(true)}>Open confirmation</button>{open && <Modal onClose={close} />}</main>;
}
Expected result
Open confirmation shows a modal dialog. Tab stays within it; Cancel, Confirm demo and Escape close it and return focus to the trigger.
Apply this to your assignments
Test every control using only the keyboard. Put the trigger in an overflow-hidden card and verify the portal still displays. Add a labelled input to the dialog and check focus order. Inspect React event propagation if the surrounding component has a click handler.
Practice starting point
Use the worked example above as your starting point.
Open Assignments for this lesson’s practice lab and acceptance checks.
Common mistakes
A portal is not automatically a modal. Avoid reading document during server rendering. Do not close on every bubbled click from inside the dialog.
Check your understanding
- Predict the output before running the example, then compare it with the result panel.
- Explain which value each expression reads and which action changes it.
- Try the boundary cases listed in the practice task and record what you observe.
- Rebuild one matching assignment without copying, then compare behavior with the original example.
Reference
Official documentation for this topic · Vite setup requirements
