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 withoverflow: hiddencan 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.