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

Examples

Default

Open alone

Ana renk · Altın

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.

PropTypeDefaultNotes
colors*ColorGridColor[]—
onChange*(id: string) => void—
value*string | undefined—
columns6 | 8 | 108Swatches per row; arrow up and down move by a whole row.

Dependencies

npm

  • None

Source

components/ds/color-grid.tsxShow
'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 };