Skip to content

Instantly share code, notes, and snippets.

@bonrow
Last active July 20, 2025 23:20
Show Gist options
  • Select an option

  • Save bonrow/6dce44834a3714d518015e98e2315841 to your computer and use it in GitHub Desktop.

Select an option

Save bonrow/6dce44834a3714d518015e98e2315841 to your computer and use it in GitHub Desktop.
A simple React + Tailwind color palette input
import React from "react";
import { cn } from "~/lib/utils";
const COLOR_PALETTE_DEFAULTS = Object.freeze({
rows: 4,
columns: 8,
saturation: 60,
lightOffset: 25,
hueOffset: 0,
});
export function ColorPalette({
rows = COLOR_PALETTE_DEFAULTS.rows,
columns = COLOR_PALETTE_DEFAULTS.columns,
saturation = COLOR_PALETTE_DEFAULTS.saturation,
lightOffset = COLOR_PALETTE_DEFAULTS.lightOffset,
hueOffset = COLOR_PALETTE_DEFAULTS.hueOffset,
color,
defaultColor,
onChange,
className,
...restProps
}: Omit<React.ComponentProps<"div">, "children" | "onChange"> & {
rows?: number;
columns?: number;
saturation?: number;
/** Minimum brightness (`l` in `hsl`) that is offsetted. */
lightOffset?: number;
/** Optional hue offset to apply to the colors.. */
hueOffset?: number;
color?: string;
defaultColor?: string;
onChange?: (color: string) => void;
}) {
const [_color, _setColor] = React.useState(defaultColor);
React.useEffect(() => _setColor(color), [color]);
const calculateColor = React.useCallback(
(index: number) => {
const row = 1 + Math.floor(index / columns);
const column = 1 + (index % columns);
// Last row should be a grayscale
if (row === rows) return `hsl(0, 0%, ${column * (100 / columns)}%)`;
// Any other row should be a color based on the hsl
const loffset = lightOffset;
const hue = (hueOffset + index * ((360 - hueOffset) / columns)) % 360;
const light = loffset + (rows - row - 1) * ((100 - loffset) / rows);
return `hsl(${hue}, ${saturation}%, ${light}%)`;
},
[columns, rows, saturation, lightOffset, hueOffset],
);
return (
<div className={cn("flex gap-0.5 w-full", className)} {...restProps}>
<div
role="radiogroup"
className="gap-1 grid rounded w-full overflow-hidden"
style={{ gridTemplateColumns: `repeat(${columns},minmax(1.5rem,1fr))` }}
>
<PaletteList
amount={rows * columns}
calculateColor={calculateColor}
active={_color}
onSelect={(color) => {
onChange?.(color);
_setColor(color);
}}
/>
</div>
</div>
);
}
function PaletteList({
amount,
active,
calculateColor,
onSelect,
}: Readonly<{
amount: number;
active?: string;
calculateColor: (index: number) => string;
onSelect: (color: string, index: number) => void;
}>) {
return Array.from({ length: amount }, (_, i) => {
const color = calculateColor(i);
const checked = active === color;
return (
<label
key={i}
aria-label={color}
className={cn(
"rounded-xs w-full h-7 cursor-pointer list-none",
checked && "border border-accent outline outline-foreground",
)}
style={{ background: color }}
>
<input
type="radio"
name="color"
value={color}
checked={checked}
className="sr-only"
onChange={(e) => e.currentTarget.checked && onSelect(color, i)}
/>
</label>
);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment