Game UI
Colour grid
A palette of square swatches, walked by row.
stablev0.5.0 Chromium 103 ui-scale safe
Install
Copies the file into
components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.bunx shadcn@3 add @fastrp/color-gridExamples
Default
API
<ColorGrid />A palette to pick from: paint, hair, eyes. Square swatches; the chosen one takes the white selection frame and pops. Arrow keys walk it — left and right by one, up and down by a row.
| Prop | Type | Default | Notes |
|---|---|---|---|
| colors* | ColorGridColor[] | — | |
| onChange* | (id: string) => void | — | |
| value* | string | undefined | — | |
| columns | 6 | 8 | 10 | 8 | Swatches per row; arrow up and down move by a whole row. |
Dependencies
npm
- None
Registry
- @fastrp/menu-list
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/color-grid.tsxShowHide
'use client';
import type * as React from 'react';
import { useMenuListNav } from '@/components/ds/menu-list';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';
// @fast-ds [email protected]
export type ColorGridColor = { id: string; hex: string; name?: string };
export type ColorGridProps = Omit<React.ComponentProps<'div'>, 'onChange'> & {
colors: ColorGridColor[];
value: string | undefined;
onChange: (id: string) => void;
/** Swatches per row; arrow up and down move by a whole row. */
columns?: 6 | 8 | 10;
};
const COLUMNS = { 6: 'grid-cols-6', 8: 'grid-cols-8', 10: 'grid-cols-10' } as const;
/**
* A palette to pick from: paint, hair, eyes. Square swatches; the chosen one takes the white
* selection frame and pops. Arrow keys walk it — left and right by one, up and down by a row.
*/
function ColorGrid({ colors, value, onChange, columns = 8, className, ...props }: ColorGridProps) {
const nav = useMenuListNav();
const sound = useUiSound();
const pick = (id: string) => {
if (id === value) return;
sound('adjust');
onChange(id);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, index: number) => {
const moves: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -columns,
ArrowDown: columns,
};
const move = moves[event.key];
if (move === undefined) return;
event.preventDefault();
event.stopPropagation();
const next = colors[index + move];
if (!next) {
// Off the top or bottom edge: the cursor leaves the grid for the row above or below.
if (nav && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
nav.move(event.currentTarget, event.key === 'ArrowDown' ? 1 : -1);
}
return;
}
pick(next.id);
const target = event.currentTarget.parentElement?.children[index + move] as
| HTMLElement
| undefined;
target?.focus();
};
return (
<div
data-slot="color-grid"
data-menu-row
role="radiogroup"
// The list hands its cursor to the grid; the grid passes it on to the chosen swatch.
tabIndex={-1}
onFocus={(event) => {
if (event.target !== event.currentTarget) return;
const chosen =
event.currentTarget.querySelector<HTMLElement>('[aria-checked="true"]') ??
event.currentTarget.querySelector<HTMLElement>('[role="radio"]');
chosen?.focus({ preventScroll: true });
}}
className={cn('grid gap-1.5', COLUMNS[columns], className)}
{...props}
>
{colors.map((color, index) => {
const selected = color.id === value;
return (
// biome-ignore lint/a11y/useSemanticElements: a swatch, not a form radio — no native input in the NUI
<div
key={color.id}
role="radio"
aria-checked={selected}
aria-label={color.name ?? color.hex}
title={color.name}
tabIndex={selected || (!value && index === 0) ? 0 : -1}
onClick={() => pick(color.id)}
onKeyDown={(event) => onKeyDown(event, index)}
className={cn(
'aspect-square cursor-pointer rounded-menu outline-none ring-1 ring-inset ring-black/30 transition-transform duration-fast hover:scale-105 focus-visible:select-frame',
selected && 'animate-pop select-frame',
)}
// The swatch is the data: its own colour is the only inline style here.
style={{ backgroundColor: color.hex }}
/>
);
})}
</div>
);
}
export { ColorGrid };