Anatomy
A modal interrupts the page, so it has a few jobs to handle from the start.
It needs its own layer, clear ways to open and close, a way to stop the page from scrolling behind it, and focus that stays inside the dialog while it is open.
That is the part I like to solve first. The centered panel is the easy part.
The layer comes first. A modal should render through a portal so it can leave the layout tree that opened it. That keeps it out of clipped containers, stacked transforms, and local z-index traps.
This is where simple modal demos can be misleading. A dialog often looks finished once you see a box centered on the screen with a dark backdrop behind it. Then you drop the same component into a real page and it gets clipped by overflow: hidden, sits under a sticky header, or loses focus the moment someone tabs through it. The visual part is small. The surrounding behavior is where the component earns its keep.
I like thinking about modals as infrastructure, not decoration. The content inside can change from page to page, but the rules around layering, focus, scrolling, and dismissal stay almost the same. Once those rules are stable, the rest of the component becomes much easier to trust.
Trigger and state
The first primitive is state. One boolean decides whether the dialog exists.
I like to keep that state close to the UI that owns the action. The trigger opens the modal, and the same component decides when to close it.
That sounds small, but it saves a lot of friction later. If the open state lives too far away, the component starts depending on props that travel through unrelated layers of the tree. At that point, a straightforward interaction starts feeling heavier than it should.
Keeping the state near the trigger also makes intent easier to read. You can scan the component and see the whole interaction at once: button click, open state, modal render, onClose callback. That is a good default unless you have a real reason to coordinate the modal from somewhere higher up.
"use client";
import { motion } from "framer-motion";
import { Link2 } from "lucide-react";
import { useState } from "react";
export function ShareTrigger() {
const [open, setOpen] = useState(false);
const isVisible = true;
return (
<>
<motion.button
animate={{
opacity: isVisible ? 1 : 0,
scale: isVisible ? 1 : 0.5,
}}
type="button"
initial={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.2, ease: "easeOut" }}
onClick={() => setOpen(true)}
className="flex h-12 select-none items-center justify-center gap-2 rounded-full bg-black pl-5 pr-6 text-[15px] font-medium tracking-[-0.015em] text-white transition-[scale,background-color] duration-200 ease-out active:scale-[0.97] dark:bg-white dark:text-black"
>
<Link2 className="size-5" aria-hidden="true" />
Share file
</motion.button>
<ShareModal open={open} onClose={() => setOpen(false)} />
</>
);
}The trigger should feel like it belongs to the rest of the page. In this case, the button follows the site theme. light mode gets a black surface. dark mode gets a white surface.
I think this matters more than it gets credit for. The trigger is the first promise the interaction makes. If the button feels out of place, the modal tends to feel bolted on even when the implementation is solid.
The portal
The dialog needs a root that sits above the page.
import { createPortal } from "react-dom";
function ShareModal({
open,
onClose,
children,
}: {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
if (!open) {
return null;
}
return createPortal(children, document.body);
}This line changes the whole behavior of the modal:
return createPortal(children, document.body);Without it, the dialog inherits the layout problems of its parent. With it, the modal gets a clean root at the page level, which makes the rest of the component easier to reason about.
That separation is the whole point. A page section might have transforms for animation, local stacking contexts for sticky UI, or clipping rules that make sense for the layout it owns. None of those concerns should control whether a modal can appear correctly. A portal gives the dialog a stable place to live, no matter where the trigger sits in the tree.
It also changes how I think about the component boundary. The trigger belongs to the local UI. The modal surface belongs to the document level. That split lines up with how the interaction behaves, so the code ends up matching the mental model.
Share this file
Rendered above the page through a portal.
The trigger stays in the page. The dialog moves to a cleaner layer.
Focus and scroll lock
A modal should own the interaction while it is open. The page behind it can stay visible, but it should stop competing for scroll and focus.
I start by locking the page scroll.
useEffect(() => {
if (!open) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);That small effect changes the feel of the whole layer. The background no longer drifts while someone is trying to read or act inside the dialog.
Focus needs the same care. When the modal opens, move focus into it. When it closes, send focus back to the trigger. The browser will not do all of that work for you.
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [open, onClose]);Escape is still part of the behavior, but it sits inside a bigger contract: the modal is a temporary context. While that context is active, keyboard movement should stay inside it and page scroll should wait.
Share this file
Rendered above the page through a portal.
The page behind the dialog should stop competing for focus and scroll.
Scrollbar gap
There is one detail hidden inside scroll locking: the scrollbar gap. On pages with a visible scrollbar, setting overflow: hidden removes the scrollbar and gives the page a little more horizontal space. If the layout is centered, that extra space can make the whole page jump sideways when the modal opens.
The fix is to measure the missing scrollbar width and reserve that space while scroll is locked.
useEffect(() => {
if (!open) {
return;
}
const root = document.documentElement;
const scrollbarWidth = window.innerWidth - root.clientWidth;
const previousOverflow = root.style.overflow;
const previousPaddingRight = root.style.paddingRight;
root.style.overflow = "hidden";
if (scrollbarWidth > 0) {
root.style.paddingRight = previousPaddingRight
? `calc(${previousPaddingRight} + ${scrollbarWidth}px)`
: `${scrollbarWidth}px`;
}
return () => {
root.style.overflow = previousOverflow;
root.style.paddingRight = previousPaddingRight;
};
}, [open]);I prefer applying the compensation to the root element because it avoids fighting page-level body transitions. The important part is restoring both values when the modal closes.
Share this file
Rendered above the page through a portal.
Reserve the scrollbar gap so the page does not shift when scroll locks.
Dialog semantics
The semantic contract is small.
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
<h2 id={titleId}>Share this file</h2>
<p id={descriptionId}>Invite your team to review and collaborate.</p>
</div>Those attributes name the dialog and tell assistive tech that it owns the interaction while it is open. It is a small detail, but it changes how complete the component feels.
This is one of the reasons I like accessibility work. The markup is not large, but it forces the component to describe itself with some precision. Once you add role="dialog", aria-modal="true", aria-labelledby, and aria-describedby, the structure tends to get better for everyone.
Rendering the modal
The share sheet in the preview breaks into a few small pieces. That keeps the component readable and makes it easier to adjust later.
This is where I stop thinking about modal behavior and start thinking about composition. The mechanics should already be handled by this point. What is left is the surface, and I want that surface to be easy to edit without dragging the low-level interaction code back into the conversation.
The first piece is a surface button that matches the site palette.
function ChromeButton({
children,
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
className?: string;
}) {
return (
<button
type="button"
className={`flex items-center justify-center rounded-xl border border-preview-border bg-preview-surface text-preview-text dark:border-preview-dark-border dark:bg-preview-dark-surface dark:text-preview-dark-text ${className ?? ""}`}
{...props}
>
{children}
</button>
);
}That helper removes a lot of repeated surface classes from the modal body.
I like pulling this sort of thing into a helper when the styles carry meaning, not just repetition. In this case, ChromeButton is part of the modal's visual language. It defines the neutral surface color, border treatment, and radius that other actions can share.
The second piece is the link-permission row.
function ShareRow() {
return (
<div className="mt-5 rounded-xl border border-preview-border bg-preview-surface-muted p-4 dark:border-preview-dark-border dark:bg-preview-dark-stage">
<div className="flex items-start gap-3">
<Link2 className="mt-0.5 size-4 text-gray-200 dark:text-gray-100" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 text-[15px] font-medium tracking-[-0.01em]">
<span>Anyone with the link can view</span>
<ChevronDown className="size-4" />
</div>
<p className="mt-1 text-[14px] text-gray-200 dark:text-gray-100">
cajaun.com/project/valmiera
</p>
</div>
<ChromeButton className="h-9 px-3 text-[13px] font-medium">
Copy link
</ChromeButton>
</div>
</div>
);
}This is the part where small helpers pay off. The modal body stays focused on structure, while the repeated surface styles live in one place.
The row itself is simple, but it does a lot of visual work. It groups the link state, the permission label, and the main action into one compact unit. That makes the share sheet easier to scan because the user can read the current state and the next action in the same glance.
The third piece is the access panel. I like using plain labels here so the hierarchy stays easy to scan.
function CollaboratorRow({
initials,
name,
email,
}: {
initials: string;
name: string;
email: string;
}) {
return (
<div className="flex items-center gap-3 rounded-xl px-1 py-2">
<Avatar initials={initials} />
<div className="flex-1">
<p className="text-[15px] font-medium tracking-[-0.01em]">
{name}
</p>
<p className="mt-0.5 text-[14px] text-gray-200 dark:text-gray-100">
{email}
</p>
</div>
<button className="text-[14px] font-medium text-gray-200 dark:text-gray-100">
Viewer
</button>
</div>
);
}The modal stays easier to scan when each row follows the same text rhythm as the rest of the page: medium weight for labels, muted copy for secondary details, and tight spacing.
That consistency matters in a component like this because the content can grow. Once you have several collaborators, different permission states, and more actions, the modal needs typography to do some of the organizational work. Clear text rhythm keeps the surface calm even when the amount of information increases.
The last step is composition.
<div className="mt-5">
<h2 className="text-[1.45rem] font-medium tracking-[-0.03em]">
Share this file
</h2>
<p className="mt-2 text-[15px] font-medium leading-6 tracking-[-0.01em] text-gray-200 dark:text-gray-100">
Invite your team to review and collaborate.
</p>
</div>
<ShareRow />
<CollaboratorPanel />
<div className="mt-5 flex items-center justify-between gap-3">
<ChromeButton className="h-10 gap-2 px-3.5 text-[14px] font-medium">
<Code2 className="size-4" />
Get embed code
</ChromeButton>
<button className="h-10 rounded-xl bg-black px-4 text-[14px] font-semibold text-white dark:bg-white dark:text-black">
Done
</button>
</div>Small pieces make the modal easier to adjust. You can change ShareRow, CollaboratorPanel, or the footer actions without digging through one large render function.
That is usually the point where the component starts paying you back. If design changes later, you are not rebuilding the modal from scratch. You are adjusting one row, one helper, or one footer action while the structure stays intact.
Closing thoughts
portals solve the part of modals that tends to go wrong first: layering. Once the dialog can escape the layout tree, the rest becomes a series of smaller problems you can handle one by one.
The main pieces are simple. Keep state near the trigger. Render the dialog through a portal. Give people clear ways to close it. Lock the page scroll. Treat focus as part of the component, not as a nice extra.
That combination gets you a modal that feels solid before you spend time polishing the visuals.
The part I keep coming back to is that modals need restraint. They should not be clever. They should feel stable, predictable, and easy to leave. If the component does those jobs well, the design has a much stronger foundation to sit on.
