# Picker in a modal

> Put the React color picker inside a dialog: why the inline panel is the safer choice, and what the popover does when it portals out of your modal.

Source: https://chroma-panel.jscrate.dev/react/recipes/in-a-modal
Last updated: 2026-09-18

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.

```tsx
"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](https://chroma-panel.jscrate.dev/react/handbook/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](https://chroma-panel.jscrate.dev/react/components/popover).
The inline panel in the example above does none of it, which is the point. Its
props are on the [ChromaPanel reference](https://chroma-panel.jscrate.dev/react/components/chroma-panel).
