Sooner or later the picker ends up inside someone else's dialog: an edit sheet, a settings modal, a form in a drawer. Two things are worth settling before you build it. A popover opened from inside a dialog leaves that dialog's DOM, which changes how both of them handle clicks, focus and stacking. And an inline panel avoids the question entirely, which is usually the right answer inside a modal.
"use client";
import { ChromaPanel } from "chroma-panel";
import "chroma-panel/style.css";
import type { CSSProperties } from "react";
import { useRef, useState } from "react";
export function ColorDialog() {
const dialogRef = useRef<HTMLDialogElement>(null);
const [color, setColor] = useState("#3366cc");
return (
<>
<button type="button" onClick={() => dialogRef.current?.showModal()}>
Edit color
</button>
<dialog ref={dialogRef} aria-label="Edit color">
<form method="dialog">
<ChromaPanel
defaultValue={color}
showTitleBar={false}
onChangeComplete={(next) => setColor(next.hex)}
style={{ "--cp-width": "100%" } as CSSProperties}
/>
<button type="submit">Done</button>
</form>
</dialog>
</>
);
}Why the inline panel fits a modal
ChromaPanel is the picker without a trigger or a popover. Nothing portals,
nothing is positioned, and the panel is an ordinary block your dialog lays out
like any other content. Your dialog keeps sole ownership of Escape, of the
backdrop click, and of the focus trap, which is one less set of rules to
reconcile.
Two props make it sit properly in a dialog you have already given a header and
padding. showTitleBar={false} drops the panel's own title row and window
controls. Setting --cp-width to 100% lets the panel fill the dialog instead
of holding its default 320px. Both, and the rest of the panel's sizing
variables, are on the theming page.
A modal dialog is in the top layer
A dialog opened with showModal() is painted in the browser's top layer.
Content portaled into document.body is not, so a popover surface can land
behind it no matter how high its z-index goes. Inside such a dialog, use the
inline panel.
What the popover does inside a dialog
If you do want ColorInput inside a non-modal dialog, this is what it brings.
The surface renders through createPortal into document.body, so it is a
sibling of your dialog rather than a descendant. That is what stops a dialog
with overflow: hidden or overflow: auto from clipping the panel, and it is
why a short dialog does not get a scrollbar when the picker opens. The surface
carries a very high z-index of its own, so ordinary stacking contexts do not
put it behind the dialog.
The same portal is the reason to check two behaviors against whatever dialog you are using:
- Outside clicks. A dialog that closes when a pointer press lands outside its own element may count a press inside the picker as outside, since the picker is not inside it. Open the picker and click the color wheel: if the dialog closes underneath you, that is this.
- Focus. A dialog that traps focus by checking DOM containment may pull focus back out of the picker as soon as the popover moves it in, for the same reason.
Both are about your dialog's rules, not the picker's, so the fix lives there too — most libraries expose a way to treat a given element, or a click whose target is outside the dialog, as inside it.
Escape and focus, precisely
It helps to know what the picker's own surface does, so you can tell whose handler fired.
Escape is handled on document during the capture phase. The surface stops the
event propagating any further and then closes itself and returns focus to the
trigger. A dialog listening for Escape later in the path therefore does not see
that first press, and a second press reaches it normally. A dialog that also
listens on document during the capture phase is the case to test.
When the surface opens, focus moves to the first focusable control inside it, and Tab cycles within the surface. When it closes with focus still inside, focus goes back to whatever held it before.
None of this is special to dialogs — it is how the surface always behaves, and it is described in full on the Popover reference. The inline panel in the example above does none of it, which is the point. Its props are on the ChromaPanel reference.