diff --git a/.changeset/dialog-browser-chrome.md b/.changeset/dialog-browser-chrome.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/dialog-browser-chrome.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx
index ebe7b975a50..9dcf9b4f578 100644
--- a/packages/swingset/src/stories/dialog.component.mdx
+++ b/packages/swingset/src/stories/dialog.component.mdx
@@ -26,6 +26,7 @@ primitive's focus trapping, scroll lock, and ARIA wiring.
{ name: 'onOpenChange', type: '(open: boolean, details: DialogOpenChangeDetails) => void' },
{ name: 'modal', type: 'boolean', default: 'true' },
{ name: 'closedBy', type: "'any' | 'closerequest' | 'none'", default: "'any'" },
+ { name: 'syncBrowserChrome', type: 'boolean', default: 'true' },
]}
/>
@@ -234,6 +235,22 @@ than the entrance. The popup scales from its own centre. Under
`prefers-reduced-motion: reduce` only `transform` drops out — the fade still runs, since the
vestibular concern is the movement.
+### Browser chrome
+
+On a phone an open dialog also tints the browser's own chrome, so it reads as one continuous
+surface rather than a dimmed page inside undimmed furniture. Two things move together:
+`` (the address bar and toolbar) and `
`'s background (the overscroll
+gutter and the strip revealed as the address bar collapses — areas a `position: fixed` scrim cannot
+cover).
+
+It is on by default and holds no opinion: the colour is derived from the backdrop's computed
+background composited over the page's own, the meta is prepended rather than mutated (so it
+overrides the app's tag — including framework-managed ones like Next's `viewport.themeColor` —
+and removal restores it), the body colour is saved and restored, and stacked dialogs share one
+meta.
+
+Pass `syncBrowserChrome={false}` if the app drives `theme-color` itself.
+
### On-screen keyboards
iOS shrinks the visual viewport when the keyboard opens but leaves layout alone, so a
diff --git a/packages/ui/src/mosaic/components/dialog/browser-chrome.ts b/packages/ui/src/mosaic/components/dialog/browser-chrome.ts
new file mode 100644
index 00000000000..b4aaeca91cd
--- /dev/null
+++ b/packages/ui/src/mosaic/components/dialog/browser-chrome.ts
@@ -0,0 +1,342 @@
+/**
+ * Tints the mobile browser's own chrome to match the dialog's scrim, so an open overlay reads as
+ * one continuous surface instead of a dimmed page inside undimmed browser furniture.
+ *
+ * Two surfaces have to move together:
+ *
+ * - `` tints the address bar and toolbar on iOS Safari and Chrome/Firefox
+ * for Android.
+ * - ``'s background propagates to the CANVAS (per CSS, when `` has none of its own),
+ * which is what paints everything OUTSIDE the layout viewport: the rubber-band overscroll gutter,
+ * the strip revealed as the address bar collapses, and the area behind the home indicator. A
+ * `position: fixed` scrim covers none of those, so without this the app's original colour shows
+ * through at the edges as an undimmed band.
+ *
+ * Nothing here is an opinion. The target colour is DERIVED — the backdrop's own computed
+ * background composited over whatever the page already had — so this ships no colour, and stays
+ * correct if a consumer retunes the scrim. It reverts exactly, and it is inert on platforms that
+ * ignore `theme-color`.
+ */
+
+/** How the page looked before any dialog opened. Captured once, on the first open. */
+interface Snapshot {
+ /** The meta we inserted, so teardown removes exactly ours. */
+ meta: HTMLMetaElement;
+ /** ``'s own inline background, restored verbatim (including "not set"). */
+ bodyBackground: string;
+ /** The colour the chrome had before we touched it, and what we composite over. */
+ base: string;
+}
+
+/** One open dialog's contribution to the tint. */
+interface Layer {
+ /** The backdrop's computed background, composited over whatever is beneath it. */
+ scrim: string;
+ /**
+ * The backdrop's LIVE computed style, not a snapshot of its timing.
+ *
+ * `getComputedStyle` returns a live object, and that matters twice. The backdrop's duration
+ * differs by direction — shorter leaving than arriving — so reading it at each use gets the
+ * right one for free. And it is `0s` on the entering frame, where the headless layer sets an
+ * inline `transition: none`; a value captured there would make every fade a snap.
+ */
+ styles: CSSStyleDeclaration;
+}
+
+/** Read at each use, never cached — see `Layer.styles`. */
+const layerDuration = (layer: Layer) => firstDuration(layer.styles.transitionDuration);
+const layerEase = (layer: Layer) => makeEasing(layer.styles.transitionTimingFunction);
+
+let snapshot: Snapshot | null = null;
+/**
+ * The open dialogs, outermost first. A STACK rather than a count, because the tint has to be
+ * reversible: closing a nested dialog must return the chrome to what the dialogs still open
+ * compose to, which a counter cannot reconstruct. Recomputing from the base every time also makes
+ * the result independent of the order things happened in.
+ */
+const layers: Layer[] = [];
+let frame = 0;
+/**
+ * The deferred teardown, so it can be CANCELLED if a dialog opens again before it fires.
+ *
+ * Without this, closing schedules a `finish` that removes the meta after the fade — and anything
+ * that re-opens inside that window (a second dialog, or React StrictMode's mount → cleanup →
+ * mount in dev) gets its tint torn out from under it a beat later. The symptom is a dialog that
+ * tints the chrome correctly and then reverts to the page's own colour while still open.
+ */
+let teardown = 0;
+
+/**
+ * A 1x1 scratch canvas, used as the colour engine. Created lazily and reused.
+ */
+let scratch: CanvasRenderingContext2D | null | undefined;
+function context(): CanvasRenderingContext2D | null {
+ if (scratch === undefined) {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ // Feature-detected rather than assumed: jsdom hands back a context object with none of the
+ // drawing methods on it, so a plain null check is not enough. Without a usable canvas the
+ // colour work is skipped entirely — the meta is still added and removed, so nothing else
+ // changes; only the tint is absent.
+ const usable =
+ typeof ctx?.clearRect === 'function' &&
+ typeof ctx.fillRect === 'function' &&
+ typeof ctx.getImageData === 'function';
+ scratch = usable ? ctx : null;
+ }
+ return scratch;
+}
+
+/**
+ * Resolves any CSS colour the browser can render to sRGB `[r, g, b]`, by painting it and reading
+ * the pixel back.
+ *
+ * Parsing the string ourselves is not an option, and the reason is worth stating: a computed
+ * colour is NOT necessarily `rgb()`. Our scrim serialises as `oklab(0 0 0 / 0.4)` and a light
+ * page's background as `oklab(1 0 0)` — where the three numbers are lightness and two opponent
+ * axes, not channels. Reading them positionally turns white into `rgb(1, 0, 0)`, i.e. black, which
+ * is exactly the bug this replaces. Canvas applies the real colour grammar and hands back sRGB.
+ *
+ * Returns `null` when the value is not a colour the canvas will take, which callers treat as "do
+ * nothing" — a colour we cannot resolve is not one to guess at.
+ */
+function readColor(input: string): [number, number, number] | null {
+ const ctx = context();
+ if (!ctx || !input) {
+ return null;
+ }
+ // A sentinel that the input cannot coincidentally equal: if assignment is rejected, `fillStyle`
+ // keeps this value and we know the parse failed rather than silently painting the wrong colour.
+ ctx.fillStyle = '#010203';
+ ctx.fillStyle = input;
+ if (ctx.fillStyle === '#010203' && input !== '#010203') {
+ return null;
+ }
+ ctx.clearRect(0, 0, 1, 1);
+ ctx.fillRect(0, 0, 1, 1);
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
+ return [r, g, b];
+}
+
+/**
+ * The scrim over the page, composited by the canvas rather than by hand — so the scrim's alpha,
+ * its colour space, and the blend are all the browser's own arithmetic.
+ */
+function composite(under: string, over: string): [number, number, number] | null {
+ const ctx = context();
+ if (!ctx || !readColor(under) || !readColor(over)) {
+ return null;
+ }
+ ctx.clearRect(0, 0, 1, 1);
+ ctx.fillStyle = under;
+ ctx.fillRect(0, 0, 1, 1);
+ ctx.fillStyle = over;
+ ctx.fillRect(0, 0, 1, 1);
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
+ return [r, g, b];
+}
+
+const toCss = ([r, g, b]: [number, number, number]) => `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
+
+/**
+ * The colour the chrome already had. Prefers the app's own `theme-color` — honouring `media`, since
+ * an app may ship one per colour scheme and only the first MATCHING one applies — and falls back to
+ * the body's background, which is what a browser samples when no meta is present.
+ */
+function readBaseColor(): string {
+ const metas = document.head.querySelectorAll('meta[name="theme-color"]');
+ for (const meta of metas) {
+ const media = meta.getAttribute('media');
+ if (!media || window.matchMedia(media).matches) {
+ return meta.content;
+ }
+ }
+ return getComputedStyle(document.body).backgroundColor;
+}
+
+/**
+ * A cubic-bézier sampler backed by a lookup table.
+ *
+ * Solving x→t exactly per frame is a Newton iteration on the main thread during the one animation
+ * the user is watching. Sampling the curve once into a table and interpolating between entries is a
+ * binary search instead, and at this resolution the error is far below a colour step.
+ */
+function makeEasing(spec: string): (t: number) => number {
+ const match = spec.match(/cubic-bezier\(([^)]+)\)/);
+ if (!match) {
+ return t => t;
+ }
+ const [x1, y1, x2, y2] = match[1].split(',').map(Number);
+ if ([x1, y1, x2, y2].some(Number.isNaN)) {
+ return t => t;
+ }
+ const axis = (p1: number, p2: number, t: number) => {
+ const u = 1 - t;
+ return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;
+ };
+ const SAMPLES = 32;
+ const table = Array.from({ length: SAMPLES + 1 }, (_, i) => axis(x1, x2, i / SAMPLES));
+ return (x: number) => {
+ let lo = 0;
+ while (lo < SAMPLES && table[lo + 1] < x) {
+ lo++;
+ }
+ const span = table[lo + 1] - table[lo];
+ const t = (lo + (span > 0 ? (x - table[lo]) / span : 0)) / SAMPLES;
+ return axis(y1, y2, t);
+ };
+}
+
+/** Seconds from the first entry of a computed `transition-duration` list. */
+const firstDuration = (value: string) => {
+ const first = value.split(',')[0].trim();
+ const n = parseFloat(first);
+ return Number.isNaN(n) ? 0 : first.endsWith('ms') ? n : n * 1000;
+};
+
+function animate(from: string, to: string, durationMs: number, ease: (t: number) => number) {
+ cancelAnimationFrame(frame);
+ const apply = (value: string) => {
+ if (!snapshot) {
+ return;
+ }
+ snapshot.meta.content = value;
+ document.body.style.backgroundColor = value;
+ };
+ const a = readColor(from);
+ const b = readColor(to);
+ if (!a || !b || durationMs <= 0) {
+ apply(to);
+ return;
+ }
+ const start = performance.now();
+ const step = () => {
+ if (!snapshot) {
+ return;
+ }
+ const p = Math.min(1, (performance.now() - start) / durationMs);
+ const e = ease(p);
+ apply(toCss([a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e, a[2] + (b[2] - a[2]) * e]));
+ if (p < 1) {
+ frame = requestAnimationFrame(step);
+ }
+ };
+ frame = requestAnimationFrame(step);
+}
+
+/**
+ * Called by every mounted backdrop. Refcounted like floating-ui's scroll lock, so stacked dialogs
+ * compose: the first open captures and tints, each further open re-derives from the deeper scrim,
+ * and only the last close restores.
+ *
+ * @param backdrop - the element whose computed background and transition timing drive both the
+ * target colour and how long it takes to get there. Reading the timing from CSS rather than
+ * duplicating a constant means the chrome automatically follows the sheet's longer fade on mobile.
+ */
+/**
+ * The colour the chrome should show right now: the captured base with every open dialog's scrim
+ * composited over it in order, so two stacked dialogs land on the same value their two backdrops
+ * do. Recomputed from scratch on every change rather than accumulated, which is what makes
+ * closing one of them exactly reversible.
+ */
+function resolveTint(): string | null {
+ if (!snapshot) {
+ return null;
+ }
+ let colour = snapshot.base;
+ for (const layer of layers) {
+ const next = composite(colour, layer.scrim);
+ if (!next) {
+ return null;
+ }
+ colour = toCss(next);
+ }
+ return colour;
+}
+
+export function acquireBrowserChrome(backdrop: HTMLElement): () => void {
+ if (typeof document === 'undefined') {
+ return () => {};
+ }
+
+ // Reclaim a teardown that has not fired yet: the snapshot it would have torn down is the one
+ // about to be reused.
+ window.clearTimeout(teardown);
+ teardown = 0;
+
+ if (!snapshot) {
+ const base = readBaseColor();
+ const meta = document.createElement('meta');
+ meta.name = 'theme-color';
+ meta.content = base;
+ // PREPENDED, never mutating the app's own. The UA uses the first `theme-color` in tree order
+ // whose media matches, so inserting ahead of theirs overrides it without touching it — and
+ // removing ours restores their value with no bookkeeping. That also sidesteps frameworks that
+ // manage the tag themselves (Next's `viewport.themeColor`), which can revert a mutation on
+ // any re-render.
+ document.head.prepend(meta);
+ snapshot = { meta, bodyBackground: document.body.style.backgroundColor, base };
+ }
+
+ const styles = getComputedStyle(backdrop);
+ const layer: Layer = { scrim: styles.backgroundColor, styles };
+ layers.push(layer);
+
+ const target = resolveTint();
+ if (target) {
+ animate(snapshot.meta.content, target, layerDuration(layer), layerEase(layer));
+ }
+
+ // Idempotent: callers release on `data-ending-style` and again at unmount, and a second call
+ // must not re-run the fade or the teardown.
+ let released = false;
+ return () => {
+ if (released) {
+ return;
+ }
+ released = true;
+ const index = layers.indexOf(layer);
+ if (index >= 0) {
+ layers.splice(index, 1);
+ }
+ if (!snapshot) {
+ return;
+ }
+
+ // Dialogs still open: return to what THEY compose to. The old code returned early here, which
+ // left a nested dialog's deeper tint on the chrome after it closed.
+ if (layers.length > 0) {
+ const remaining = resolveTint();
+ if (remaining) {
+ animate(snapshot.meta.content, remaining, layerDuration(layer), layerEase(layer));
+ }
+ return;
+ }
+
+ const closing = snapshot;
+ const { meta, bodyBackground, base } = closing;
+ // Fade back before tearing down, so closing reads as the reverse of opening rather than as a
+ // flash. `snapshot` is cleared only once the colour has landed.
+ const finish = () => {
+ teardown = 0;
+ // Identity check as well as the cancel above: a re-open replaces `snapshot`, and this
+ // closure must not remove a meta that now belongs to a dialog which is still open.
+ if (snapshot !== closing) {
+ return;
+ }
+ cancelAnimationFrame(frame);
+ meta.remove();
+ document.body.style.backgroundColor = bodyBackground;
+ snapshot = null;
+ };
+ const durationMs = layerDuration(layer);
+ if (durationMs <= 0) {
+ finish();
+ return;
+ }
+ animate(meta.content, base, durationMs, layerEase(layer));
+ teardown = window.setTimeout(finish, durationMs);
+ };
+}
diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx
index 9b7ff5eb24e..de37cf661d8 100644
--- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx
+++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx
@@ -494,6 +494,111 @@ describe('viewport scroll behaviour', () => {
});
});
+describe('browser chrome sync', () => {
+ const themeColor = () => document.head.querySelector('meta[name="theme-color"]');
+
+ const addEmailTriggerShared = (props: MosaicComponentProps<'button'>) => (
+
+ );
+
+ afterEach(() => {
+ document.head.querySelectorAll('meta[name="theme-color"]').forEach(m => m.remove());
+ document.body.style.backgroundColor = '';
+ });
+
+ it('adds a theme-color meta while open and removes it on close', async () => {
+ const user = userEvent.setup();
+ expect(themeColor()).toBeNull();
+
+ render(
+ ,
+ );
+ // Acquired when the backdrop's transition arms — one frame after mount — not on the mount
+ // frame itself, where an inline `transition: none` would make the fade a snap.
+ await waitFor(() => expect(themeColor()).not.toBeNull());
+
+ await user.click(screen.getByRole('button', { name: 'Dismiss' }));
+ await waitFor(() => expect(themeColor()).toBeNull());
+ });
+
+ it('prepends its meta so it wins over the app’s own, and leaves that one untouched', async () => {
+ const appMeta = document.createElement('meta');
+ appMeta.name = 'theme-color';
+ appMeta.content = 'rgb(10, 20, 30)';
+ document.head.append(appMeta);
+
+ render();
+ await waitFor(() => expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(2));
+
+ const metas = document.head.querySelectorAll('meta[name="theme-color"]');
+ // First in tree order is what the UA uses, so ours has to be first — and theirs unchanged.
+ expect(metas).toHaveLength(2);
+ expect(metas[0]).not.toBe(appMeta);
+ expect(appMeta.content).toBe('rgb(10, 20, 30)');
+ });
+
+ it('opts out with syncBrowserChrome={false}', () => {
+ render(
+ ,
+ );
+
+ expect(themeColor()).toBeNull();
+ });
+
+ it('keeps the tint when a dialog re-opens before the previous teardown fires', async () => {
+ // Regression: closing schedules the meta's removal after the fade. React StrictMode's
+ // mount → cleanup → mount, or simply opening again quickly, used to let that deferred
+ // removal fire and strip the tint from a dialog that was still open.
+ const user = userEvent.setup();
+ const { rerender } = render();
+ await waitFor(() => expect(themeColor()).not.toBeNull());
+
+ rerender();
+ rerender();
+
+ await new Promise(resolve => setTimeout(resolve, 250));
+ expect(themeColor()).not.toBeNull();
+ await user.keyboard('{Escape}');
+ });
+
+ it('keeps one meta for stacked dialogs and removes it only with the last', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Add email' }));
+ expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1);
+
+ await user.keyboard('{Escape}');
+ expect(themeColor()).not.toBeNull();
+ });
+});
+
describe('accessible name warning', () => {
it('warns when the dialog has no accessible name', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx
index 14d08dabf45..668450d4e08 100644
--- a/packages/ui/src/mosaic/components/dialog/dialog.tsx
+++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx
@@ -10,6 +10,7 @@ import { mergeStyleProps, themeProps } from '../../props';
import { Button } from '../button';
import { Icon } from '../icon';
import { reset } from '../reset.styles';
+import { acquireBrowserChrome } from './browser-chrome';
import { backdropMotion, closeInsets, popupMotion, sizes, styles, viewportSizes } from './dialog.styles';
import { acquireKeyboardInset } from './keyboard-inset';
@@ -19,6 +20,17 @@ export type DialogSize = keyof typeof sizes;
export interface DialogRootProps extends HeadlessDialogProps {
/** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */
size?: DialogSize;
+ /**
+ * Tint the mobile browser's own chrome — the address bar, and the canvas behind the overscroll
+ * gutter — to match the dialog's scrim, so an open dialog reads as one continuous surface.
+ *
+ * On by default. It ships no colour of its own (the target is derived from the backdrop
+ * composited over whatever the page already had), reverts exactly on close, and is inert
+ * wherever `theme-color` is ignored. Pass `false` if the app drives `theme-color` itself.
+ *
+ * @default true
+ */
+ syncBrowserChrome?: boolean;
}
/**
@@ -29,6 +41,56 @@ export interface DialogRootProps extends HeadlessDialogProps<
*/
const DialogSizeContext = React.createContext('prompt');
+/** Whether the dialog tints the mobile browser's chrome to match its scrim. See `browser-chrome.ts`. */
+const DialogChromeContext = React.createContext(true);
+
+/**
+ * Drives the browser-chrome tint off the backdrop element itself, so both the colour and the timing
+ * come from the CSS rather than from constants duplicated in JS.
+ *
+ * Keyed on the NODE via state rather than a ref: the effect has to run once the backdrop is in the
+ * DOM and its computed style is readable, and a ref gives no signal when that happens.
+ */
+function useBrowserChrome(node: HTMLElement | null, enabled: boolean) {
+ React.useEffect(() => {
+ if (!enabled || !node) {
+ return;
+ }
+
+ // Driven by the backdrop's own transition attributes rather than by mount and unmount, so the
+ // colour runs on exactly the same clock as the scrim in both directions.
+ //
+ // Both attributes matter, for different reasons. `data-ending-style` because the headless
+ // layer keeps the backdrop mounted until its exit animation finishes, so releasing at unmount
+ // starts the revert only once the scrim has already gone. And `data-starting-style` because
+ // that frame carries an inline `transition: none` — acquiring there reads a duration of `0s`
+ // and the fade becomes a snap. Waiting for both to be absent is precisely waiting for the
+ // scrim's transition to arm.
+ //
+ // Two-way, because an exit can be interrupted: re-opening mid-exit clears the attribute on the
+ // same element, and the tint has to come back without waiting for a remount.
+ let handle: (() => void) | null = null;
+ const sync = () => {
+ const transitioning = node.hasAttribute('data-starting-style') || node.hasAttribute('data-ending-style');
+ if (transitioning && handle) {
+ handle();
+ handle = null;
+ } else if (!transitioning && !handle) {
+ handle = acquireBrowserChrome(node);
+ }
+ };
+
+ const observer = new MutationObserver(sync);
+ observer.observe(node, { attributes: true, attributeFilter: ['data-starting-style', 'data-ending-style'] });
+ sync();
+
+ return () => {
+ observer.disconnect();
+ handle?.();
+ };
+ }, [node, enabled]);
+}
+
/**
* The headless parts type their props (and the `render` callback's argument) against
* the raw tag props, which carry the non-standard HTML `color` attribute typed
@@ -73,10 +135,17 @@ export type DialogPopupProps = MosaicComponentProps<'div'> & {
};
/** Owns the open state and the size both the backdrop and the popup read. */
-function Root({ size = 'prompt', children, ...rest }: DialogRootProps) {
+function Root({
+ size = 'prompt',
+ syncBrowserChrome = true,
+ children,
+ ...rest
+}: DialogRootProps) {
return (
- {...rest}>{children}
+
+ {...rest}>{children}
+
);
}
@@ -167,9 +236,22 @@ const Backdrop = React.forwardRef(function
ref,
) {
const size = React.useContext(DialogSizeContext);
+ const [node, setNode] = React.useState(null);
+ useBrowserChrome(node, React.useContext(DialogChromeContext));
+ const mergedRef = React.useCallback(
+ (el: HTMLDivElement | null) => {
+ setNode(el);
+ if (typeof ref === 'function') {
+ ref(el);
+ } else if (ref) {
+ ref.current = el;
+ }
+ },
+ [ref],
+ );
return (
void }) => ReactNode);
/** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */
size?: DialogSize;
+ /** Tint the mobile browser chrome to match the scrim. @default true */
+ syncBrowserChrome?: boolean;
}
function DialogContent({ children }: { children: DialogProps['children'] }) {
@@ -275,10 +359,21 @@ function DialogContent({ children }: { children: DialogProps['children'] }) {
* keeps its focus management, scroll lock, and ARIA wiring. Drop to the compound parts
* (`Dialog.Root` and friends) for layouts this wrapper does not cover.
*/
-export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal, closedBy }: DialogProps) {
+export function Dialog({
+ trigger,
+ children,
+ size,
+ syncBrowserChrome,
+ open,
+ defaultOpen,
+ onOpenChange,
+ modal,
+ closedBy,
+}: DialogProps) {
return (