Lunia DS

Base components

Color picker

A small swatch button that opens a panel of 16 color swatches and a hex field, where the panel grows from the button, a ring moves onto the picked swatch, and the button dot eases to the new color.

Loading preview

The tool button in a short text editor toolbar row whose text takes the picked color. Picking Purple changes the trigger dot; typing 7F56D9 in Custom picks a custom color. Replay opens the panel and picks Blue.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add color-picker

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { TooltipProvider } from '../tooltip/tooltip';
import { ColorPicker } from './color-picker';

const TOOLS = [
  {
    key: 'bold',
    label: 'Bold',
    path: 'M5 3.333h5.833a3.333 3.333 0 0 1 0 6.667H5V3.333Zm0 6.667h6.667a3.333 3.333 0 0 1 0 6.667H5V10Z',
  },
  { key: 'italic', label: 'Italic', path: 'M15.833 3.333H8.333M11.667 16.667H4.167M12.5 3.333 7.5 16.667' },
] as const;

const toolClass =
  'inline-flex size-8 cursor-pointer items-center justify-center rounded-sm text-fg-quaternary outline-none transition-colors duration-sm ease-out hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:shadow-focus-ring aria-pressed:bg-secondary_hover aria-pressed:text-fg-secondary motion-reduce:transition-none';

export default function ColorPickerDemo({ replay = 0 }: { replay?: number }) {
  const [color, setColor] = React.useState('#9333EA');
  const [open, setOpen] = React.useState(false);
  const [pressed, setPressed] = React.useState<Record<string, boolean>>({ bold: true });

  React.useEffect(() => {
    if (replay === 0) return;
    setColor('#9333EA');
    setOpen(true);
    const timers = [
      window.setTimeout(() => setColor('#2563EB'), 700),
      window.setTimeout(() => setOpen(false), 1300),
    ];
    return () => {
      for (const timer of timers) window.clearTimeout(timer);
    };
  }, [replay]);

  return (
    <TooltipProvider>
      <div className="w-full max-w-sm overflow-hidden rounded-xl border border-secondary bg-primary shadow-xs">
        <div className="flex items-center gap-1 border-b border-secondary px-2 py-1.5">
          {TOOLS.map((tool) => (
            <button
              key={tool.key}
              type="button"
              aria-label={tool.label}
              aria-pressed={Boolean(pressed[tool.key])}
              className={toolClass}
              onClick={() => setPressed((p) => ({ ...p, [tool.key]: !p[tool.key] }))}
            >
              <svg
                aria-hidden="true"
                viewBox="0 0 20 20"
                className="size-5"
                fill="none"
                stroke="currentColor"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d={tool.path} strokeWidth={1.67} vectorEffect="non-scaling-stroke" />
              </svg>
            </button>
          ))}
          <span aria-hidden="true" className="mx-1 h-5 w-px bg-(--lunia-border-secondary)" />
          <ColorPicker value={color} onValueChange={setColor} open={open} onOpenChange={setOpen} />
        </div>
        <p
          className="px-4 py-3 text-md text-primary transition-colors duration-sm ease-out motion-reduce:transition-none"
          style={{
            color,
            fontWeight: pressed.bold ? 600 : 400,
            fontStyle: pressed.italic ? 'italic' : 'normal',
          }}
        >
          Release notes for the spring update.
        </p>
      </div>
    </TooltipProvider>
  );
}

API

Parts: trigger (32px tool button with a 16px color dot; a ring when open), panel (popover), swatch grid (2 rows of 8), swatch (16px dot, 24px ring when picked), custom row (label "Custom", hex field with a color dot).

PropTypeDefaultDescription
valuestringundefinedPicked color (controlled) as a 6-digit hex string with #, for example "#7F56D9".
defaultValuestringthe first swatchPicked color when uncontrolled. The first default swatch is Neutral 900, read from the tokens.
swatchesArray<{ value: string; label: string }>the 16 Lunia swatchesColors in the grid, 8 per row. Each label is the color name read by screen readers.
custombooleantrueShows the Custom row with the hex field.
labelstring"Text color"Accessible name of the trigger and the panel.
openbooleanundefinedPanel open state (controlled).
defaultOpenbooleanfalsePanel open state when uncontrolled.
side"top" | "bottom""bottom"Side of the trigger where the panel opens; it flips when there is no room.
disabledbooleanfalseTurns the trigger off (50% opacity).
EventDescription
valueChangeA swatch was picked, or a valid hex value was entered in the Custom field (on Enter or blur).
openChangeThe panel opened or closed.
SlotDescription
triggerReplaces the default tool button, for example to use the picker outside a toolbar.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
CPK1hover the triggertriggerbackground-colortransparent → bg-primary_hoverduration-sm (200)ease-outSwaps at once
CPK2keyboard focus on the trigger, a swatch or the hex fieldfocused elementbox-shadownone → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once
CPK3openpanelopacity0 → 1duration-sm (200)ease-outShows at once
CPK4openpanel (transform origin at the trigger)scale, translate toward the triggerscale-press, -distance-rise → 1, 0duration-md (260)ease-settleShows at once
CPK5openswatch grid and custom rowopacity0 → 1duration-sm (200)ease-outdelay 60msShows at once
CPK6close (pick, Escape, outside click)panel and its contentopacity, scale1, 1 → 0, scale-pressduration-xs (160)ease-exitHides at once
CPK7open -> true / falsetrigger ringopacity0 → 1duration-sm (200)ease-outSwaps at once
CPK8hover a swatchswatch ringopacity, border-color0, transparent → 1, border-primaryduration-sm (200)ease-outSwaps at once
CPK9a swatch is pickedpicked ring (old swatch out, new swatch in)opacity, scale0, scale-swap → 1, 1 (the old ring fades out in duration-2xs)duration-sm (200)ease-outSwaps at once
CPK10the value changestrigger dot and hex field dotbackground-colorold color → new colorduration-sm (200)ease-outSwaps at once
CPK11the hex field is left with a value that is not a colorhex field border and hintborder-color, opacityborder-primary, 0 → border-error, 1duration-sm (200)ease-outSwaps at once

Accessibility

  • The trigger is a button with aria-haspopup dialog and aria-expanded, named by label plus the color name (for example "Text color: Purple"); the color dot is aria-hidden.
  • The panel is a non-modal popover named by label. Focus moves to the picked swatch when it opens and returns to the trigger when it closes; Escape closes it.
  • The swatch grid is a radio group: the arrow keys move between swatches (Up and Down move by a row), Space or Enter picks and closes. Each swatch is a radio named by its color label, never by the hex code alone.
  • The hex field is a text input labelled "Hex color" with a visible "Custom" label; a wrong value shows "Enter a hex code, for example 7F56D9." and keeps the old color.
  • The white swatch has a 1px border-primary ring so it stays visible on the white panel (WCAG 1.4.11).
  • Swatches are 16px; each has a transparent 24 x 24px hit area (WCAG 2.5.8), which the 8px gap allows.
  • Reduced motion: the panel shows and hides at once, rings and colors swap at once.