Chroma Panel

Search documentation

Find a page or section

Custom trigger

View as Markdown

Pair Popover with ChromaPanel behind your own button.

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.

"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 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.

<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, and the trigger you are replacing is on the ColorInput reference.