# Custom trigger

> Open the React color picker from a button of your own by pairing Popover with ChromaPanel, with the same ARIA attributes ColorInput sets for you.

Source: https://chroma-panel.jscrate.dev/react/recipes/custom-trigger
Last updated: 2026-09-18

`ColorInput` gives you a small square swatch button. When your design calls for
something else — a toolbar icon, a row in a settings list, a button with a label
beside the color — build the trigger yourself and pair `Popover` with
`ChromaPanel`. That is exactly what `ColorInput` does internally, so you are not
giving anything up except the hidden form input.

```tsx
"use client";

import { ChromaPanel, Popover } from "chroma-panel";
import "chroma-panel/style.css";
import { useState } from "react";

export function BrandColorButton() {
  const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
  const [open, setOpen] = useState(false);
  const [color, setColor] = useState("#3366cc");

  return (
    <>
      <button
        ref={setAnchor}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        onClick={() => setOpen((current) => !current)}
      >
        <span
          aria-hidden="true"
          style={{
            display: "inline-block",
            width: 14,
            height: 14,
            borderRadius: 4,
            background: color,
          }}
        />
        Brand color
      </button>

      <Popover anchor={anchor} open={open} onClose={() => setOpen(false)}>
        <div role="dialog" aria-label="Brand color" aria-modal="false">
          <ChromaPanel
            value={color}
            onChange={(next) => setColor(next.hex)}
            title="Brand color"
            onClose={() => {
              setOpen(false);
              anchor?.focus();
            }}
          />
        </div>
      </Popover>
    </>
  );
}
```

## Hold the anchor in state, not a ref

`anchor` is the element the surface positions against, and it takes `null` until
the trigger exists. A `useRef` would not do here: filling a ref does not
re-render, so the popover would never learn that its anchor had arrived. Passing
the state setter as `ref` gives you both the element and a render once it is
there.

## The attributes to carry over

Three of them sit on the trigger, and `ColorInput` sets all three:
`aria-haspopup="dialog"` says what the button opens, `aria-expanded` tracks
whether it is open, and an accessible name comes from the button's own text or
from `aria-label`. The surface then needs `role="dialog"` with a matching name,
which is why the example wraps the panel in a labelled `div`. `ColorInput` marks
that wrapper `aria-modal="false"`, since the rest of the page stays where it is.

Give the panel `onClose` and its red window control closes the popover instead
of sitting dimmed, which is what the example's handler does. Returning focus to
the trigger afterwards is on you. See
[accessibility](https://chroma-panel.jscrate.dev/react/overview/accessibility) for what the panel handles on its
own.

## What Popover already does

You do not need to add outside-click or Escape handling; the surface brings it.

- It renders through a portal into `document.body`, so no ancestor with
  `overflow: hidden` can clip it.
- It places itself below the anchor when there is room and above it when there
  is not, and pulls itself back from the viewport edges with an 8px margin. The
  gap is `offset`, 8px by default.
- It re-places on scroll and on resize, and passes the room it found to the
  panel, which caps its own height to fit.
- Escape closes it and moves focus back to the anchor.
- A pointer press outside both the surface and the anchor closes it too.
- On open, focus moves to the first focusable control inside. Tab and Shift+Tab
  cycle within the surface rather than walking out into the page behind it.

## Below 640px

At narrow widths the surface stops being an anchored popover and becomes a
bottom sheet: a scrim over the page, a grab handle at the top that closes it,
and page scrolling locked while it is up. That is the `sheetOnMobile` prop, and
it defaults to `true`.

```tsx
<Popover
  anchor={anchor}
  open={open}
  onClose={() => setOpen(false)}
  sheetOnMobile={false}
>
  <div role="dialog" aria-label="Brand color">
    <ChromaPanel value={color} onChange={(next) => setColor(next.hex)} />
  </div>
</Popover>
```

> **sheetOnMobile is a Popover prop**
>
> It exists on `Popover` only. `ColorInput` does not accept it and never passes
> it, so a `ColorInput` always becomes a sheet below 640px. Building your own
> trigger is how you opt out. The full prop list is on the [Popover
> reference](https://chroma-panel.jscrate.dev/react/components/popover), and the trigger you are replacing is on
> the [ColorInput reference](https://chroma-panel.jscrate.dev/react/components/color-input).
