Chroma Panel

Search documentation

Find a page or section

Link the stylesheet from a route's links export.

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-panel

The package has no dependencies. react and react-dom are peer dependencies, so the copies your app already has are the ones that get used.

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 from window at module scope.
  • injectStyles returns early when document is 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.