chroma-panel works as a React color picker for Remix with no wrapper, no
ClientOnly and no typeof window guard. This page adds the stylesheet through
a links export and renders the picker in a route. The same steps apply to
React Router v7 in framework mode, which is the same code under a new name.
Install
npm install chroma-panelThe package has no dependencies. react and react-dom are peer dependencies,
so the copies your app already has are the ones that get used.
Link the stylesheet
The panel injects its stylesheet from an effect, which leaves the
server-rendered markup unstyled until hydration. Remix has its own way to load
CSS: import the file's URL and return it from a links export.
// app/root.tsx
import type { LinksFunction } from "@remix-run/node";
import chromaPanelStyles from "chroma-panel/style.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: chromaPanelStyles },
];On React Router v7, the type comes from react-router instead:
import type { LinksFunction } from "react-router";
import chromaPanelStyles from "chroma-panel/style.css?url";Then pass injectStyles={false} wherever you render the panel, so it does not
write a second copy into a <style> tag.
?url is a Vite feature, not a Remix one
Vite resolves the specifier with the query stripped, then hands you the built
file's URL. chroma-panel lists "./style.css" in its exports map, so the
subpath resolves to dist/style.css. It works in Remix v2 and React Router v7
because both build with Vite. On a build that does not use Vite, drop the
?url and load the CSS some other way.
If your app uses Tailwind, the layer order matters more than the loading mechanism — styling with Tailwind has the import order that keeps your utility classes winning.
A route
// app/routes/brand.tsx
import { useState } from "react";
import { ColorInput, type ColorChangeResult } from "chroma-panel";
export default function BrandRoute() {
const [color, setColor] = useState<string>("#3366cc");
const handleChange = (result: ColorChangeResult): void => {
setColor(result.hex);
};
return (
<main>
<h2>Brand color</h2>
<ColorInput value={color} onChange={handleChange} injectStyles={false} />
<p style={{ color }}>{color}</p>
</main>
);
}To post the color to an action instead of holding it in state, give
ColorInput a name and drop value and onChange. It renders a hidden input
and submits with the form like any other control, as
forms describes.
Server rendering
Nothing needs guarding. The route above renders on the server as it stands:
- Every file in the package that touches the browser carries
'use client', and nothing is read fromwindowat module scope. injectStylesreturns early whendocumentis undefined, so the server pass never tries to create a<style>tag.- The first render writes the color as CSS custom properties on the panel's root element, so the server-rendered markup already carries the right color and there is no flash before hydration.
Server rendering covers the same points for Next.js and other SSR setups, and entry points lists the subpaths if you want to ship fewer modes than the default import registers.