Chroma Panel

Search documentation

Find a page or section

Follow the system, force one theme, or match your toggle.

Out of the box the picker follows the operating system, which is right until your app has a theme toggle of its own. Then the two disagree: someone reading your site in dark mode on a light desktop opens the picker and gets a white panel. Hand the picker your resolved theme and the mismatch goes away.

"use client";
 
import { ColorInput } from "chroma-panel";
import "chroma-panel/style.css";
import { useTheme } from "next-themes";
 
function panelTheme(value: string | undefined) {
  if (value === "dark") return "dark" as const;
  if (value === "light") return "light" as const;
  return undefined;
}
 
export function ThemedColorInput() {
  const { resolvedTheme } = useTheme();
 
  return (
    <ColorInput
      name="brand"
      defaultValue="#3366cc"
      theme={panelTheme(resolvedTheme)}
    />
  );
}

theme accepts "dark" and "light" and nothing else, while resolvedTheme is typed string | undefined, so the narrowing above is not ceremony. Returning undefined is a real answer too: leaving theme off puts the panel back on the system preference, which is the best guess while your provider has not resolved yet.

The same prop is on ChromaPanel, and works the same way on both — see the ChromaPanel reference for where it sits among the other panel props.

Following the system

This is the default. The panel's surfaces, borders, text and accent are all declared as light-dark() pairs, and the panel roots declare color-scheme: light dark, so the browser picks the side that matches the reader's preference. Nothing to pass, nothing to hydrate, and no flash on first paint, because the choice is made in CSS rather than in JavaScript.

Forcing one theme

Pass theme directly when the surrounding surface is fixed. A picker in a dark editor chrome should stay dark even in a light app:

<ChromaPanel theme="dark" defaultValue="#3366cc" />

How the stylesheet decides

theme lands on the panel root as data-cp-theme, with the value dark or light. The stylesheet then sets color-scheme on that root to the single value, which is what makes every light-dark() token resolve to that side. Pass nothing and there is no attribute, so color-scheme: light dark stands and the preference decides.

The bottom sheet that the picker becomes below 640px is matched separately, so its backdrop and surface follow the panel inside it rather than going their own way.

Because the attribute sits on the panel root itself, it travels with the panel into the popover, which renders in document.body rather than inside your layout. Dark styling you scope to a wrapper element will not reach it; a selector on .cp-root will.

Changing the dark palette

The package's rules live in a @layer chroma-panel cascade layer, and unlayered CSS beats a layer. Your own stylesheet can therefore replace any token without fighting specificity:

.cp-root {
  --cp-surface: light-dark(#ffffff, #0b0b0f);
  --cp-accent: light-dark(#2d7ff9, #7aa2ff);
}

Writing the override as a light-dark() pair keeps both sides in one rule and keeps the automatic behavior working. The full list of tokens is on the theming page, and running the picker inside the Next.js App Router is covered on the Next.js page.

Two ways to be dark

theme changes the panel only. The --cp- custom properties change what the panel's colors are. Reach for theme to pick a side, and for the properties when the built-in dark side is not the dark you want.