Skip to main content
astrocraft-ui/ components · 101

Color Picker

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/color-picker

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/color-picker --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/color-picker --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/color-picker --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/color-picker --theme tailwind --bridge lumos

Live demo

ColorPicker

The saturation/lightness area is two native range inputs, so it has a keyboard for free; the script adds the pointer drag on top. Drag saturation to zero and the HUE IS KEPT — the picker does not snap back to red. The swatch names the colour in words, which is also what the sliders announce as you move them.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`ColorArea.astro``color-area` `color-area-lightness` `color-area-saturation` `color-area-thumb`———
`ColorField.astro``color-field``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
——
`ColorPicker.astro``color-picker` `color-picker-native`—`data-value`—
`ColorSlider.astro``color-slider``data-channel`: `hue` · `saturation` · `lightness`——
`ColorSwatch.astro``color-swatch`———

Source

What the command copies — 7 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/color-picker/ColorArea.astro
---
// src/components/ui/forms/color-picker/ColorArea.astro — ColorPicker compound part (see ../../README.md).
// The saturation / lightness square — and it is TWO NATIVE RANGE INPUTS, not a div with a drag
// handler. That is the whole design of this file, and it is what makes a 2-D control accessible
// without inventing anything: each axis is a real slider, so it has a keyboard, a value, a label, a
// focus ring and an announcement, all from the platform.
//
// ColorPicker's script adds the pointer drag on top, writing both inputs at once — so a mouse user
// drags the square, a keyboard user arrows each axis, and there is one source of truth underneath.
// Unstyled, this renders as two ordinary sliders: fully operable, which is the acceptance gate.
// A theme stacks them into a square and hides them behind `[data-slot="color-area-thumb"]`, which
// the script positions; when it does, the theme owns making focus visible again (`:focus-within`).
//
// `aria-valuetext` on each axis carries the COLOUR, not just the number — "62%, muted blue" —
// because "62" alone tells a screen reader user nothing about what they are aiming at.
import type { HTMLAttributes } from "astro/types";

import { hexToHsl, roundHsl } from "./color-math";

type Props = HTMLAttributes<"div"> & {
  /** The starting colour, so the axes render in the right place before any script runs. */
  value?: string;
  label?: string;
  saturationLabel?: string;
  lightnessLabel?: string;
};

const {
  value = "#000000",
  label = "Saturation and lightness",
  saturationLabel = "Saturation",
  lightnessLabel = "Lightness",
  class: className,
  ...rest
} = Astro.props;

const hsl = roundHsl(hexToHsl(value) ?? { h: 0, s: 0, l: 0 });
---

<div role="group" aria-label={label} data-slot="color-area" class={className} {...rest}>
  <input
    type="range"
    min="0"
    max="100"
    value={hsl.s}
    aria-label={saturationLabel}
    data-slot="color-area-saturation"
    data-channel="s"
  />
  <input
    type="range"
    min="0"
    max="100"
    value={hsl.l}
    aria-label={lightnessLabel}
    data-slot="color-area-lightness"
    data-channel="l"
  />
  <span data-slot="color-area-thumb" aria-hidden="true"></span>
</div>
src/components/ui/forms/color-picker/ColorField.astro
---
// src/components/ui/forms/color-picker/ColorField.astro — ColorPicker compound part (see ../../README.md).
// The hex box. It is a plain text input on purpose: `<input type="color">` cannot be typed into, and
// typing a hex code is how a designer transfers a colour from anywhere else.
//
// It accepts what people actually type — `#abc`, `abc`, `AABBCC` — and normalises on change (see
// `normalizeHex`). A value it cannot parse is left alone and marked `aria-invalid`, rather than
// being silently replaced with black: losing what someone typed is worse than showing it is wrong.
//
// It carries the shared `data-size` / `data-state` field surface, so it styles with Input and the
// rest of the fields.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & {
  value?: string;
  label?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
};

const {
  value = "#000000",
  label = "Hex colour",
  size = "md",
  state = "default",
  class: className,
  ...rest
} = Astro.props;
---

<input
  type="text"
  value={value}
  aria-label={label}
  inputmode="text"
  autocomplete="off"
  spellcheck="false"
  data-slot="color-field"
  data-size={size}
  data-state={state}
  class={className}
  {...rest}
/>
src/components/ui/forms/color-picker/ColorPicker.astro
---
// src/components/ui/forms/color-picker/ColorPicker.astro — headless primitive (see ../../README.md).
// A colour picker built around a real <input type="color">. That input is the value: it submits with
// the form, it validates, and on any browser — including with JavaScript off, and on a phone, where
// the OS picker is better than anything a library can draw — it is a complete, usable control on its
// own. ColorArea, ColorSlider, ColorField and ColorSwatch are an enhancement layered over it.
//
// Compose the parts you want as children:
//
//   <ColorPicker name="accent" value="#1f3fd8">
//     <ColorArea value="#1f3fd8" />
//     <ColorSlider channel="hue" value="#1f3fd8" />
//     <ColorField value="#1f3fd8" />
//     <ColorSwatch value="#1f3fd8" />
//   </ColorPicker>
//
// HSL is the working space (see color-math.ts): hue on a rail, saturation and lightness on a square,
// because those are the two controls a person can aim. The script keeps one HSL state and writes hex
// outward — and it keeps the HUE when a colour goes grey, which is the detail that stops a picker
// snapping to red the moment you drag saturation to zero.
//
// The pointer drag on the area is the only thing here the platform does not give us. Everything
// else — keyboard, focus, value semantics, the native fallback — is a native input doing its job.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** `#rrggbb`. The native input's value, and what the form submits. */
  value?: string;
  name?: string;
  label?: string;
};

const { value = "#000000", name, label = "Colour", class: className, ...rest } = Astro.props;
---

<div class={className} data-slot="color-picker" data-value={value} {...rest}>
  <input
    type="color"
    value={value}
    name={name}
    aria-label={label}
    data-slot="color-picker-native"
  />
  <slot />
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { describeColor, hexToHsl, type Hsl, hslToHex, roundHsl } from "./color-math";

  function wire(root: HTMLElement) {
    const native = root.querySelector<HTMLInputElement>('[data-slot="color-picker-native"]');
    if (!native) return;
    const area = root.querySelector<HTMLElement>('[data-slot="color-area"]');
    const thumb = root.querySelector<HTMLElement>('[data-slot="color-area-thumb"]');
    const field = root.querySelector<HTMLInputElement>('[data-slot="color-field"]');
    const swatches = [...root.querySelectorAll<HTMLElement>('[data-slot="color-swatch"]')];
    const channels = [
      ...root.querySelectorAll<HTMLInputElement>("[data-channel]"),
    ] as HTMLInputElement[];

    let hsl: Hsl = hexToHsl(native.value) ?? { h: 0, s: 0, l: 0 };

    /** Push the current colour to every part except the one the user is holding. */
    const publish = (source?: Element | null) => {
      const hex = hslToHex(hsl);
      const rounded = roundHsl(hsl);
      const name = describeColor(hex);
      root.dataset.value = hex;

      for (const input of channels) {
        const key = input.dataset.channel as "h" | "s" | "l";
        if (input !== source) input.value = String(rounded[key]);
        // The number alone says nothing about the colour being aimed at, so the value text says both.
        input.setAttribute("aria-valuetext", `${rounded[key]}${key === "h" ? "°" : "%"}, ${name}`);
      }
      if (thumb) {
        // Percentages, not pixels: the theme decides how big the square is. Lightness runs UP the
        // square, which is why `top` is inverted. The colour on the thumb is the value, like the
        // swatch's — see ColorSwatch.astro on why that is data rather than an opinion.
        thumb.style.left = `${rounded.s}%`;
        thumb.style.top = `${100 - rounded.l}%`;
        thumb.style.backgroundColor = hex;
      }
      // The area's BACKGROUND COLOUR is the pure hue, and nothing else about the square is set here.
      // A theme paints its white and black gradients with `background-image`, which does not collide
      // — so it gets a correct hue backdrop without the library minting a custom property for it
      // (see the headless rules: every one of those is a name the library owns forever).
      if (area) area.style.backgroundColor = hslToHex({ h: hsl.h, s: 100, l: 50 });
      for (const swatch of swatches) {
        swatch.style.backgroundColor = hex;
        swatch.dataset.value = hex;
        swatch.setAttribute("aria-label", name);
      }
      if (field && field !== source) {
        field.value = hex;
        field.removeAttribute("aria-invalid");
      }
      if (native.value !== hex) {
        native.value = hex;
        // `change` on the native input is the event a form library is already listening for; the
        // platform only fires it for a user's own edit, so writing `.value` obliges us to fire it.
        native.dispatchEvent(new Event("change", { bubbles: true }));
      }
    };

    /** Adopt a hex from outside, KEEPING the hue a grey cannot carry. */
    const adopt = (hex: string, source?: Element | null) => {
      const next = hexToHsl(hex);
      if (!next) return;
      hsl = { h: next.s === 0 ? hsl.h : next.h, s: next.s, l: next.l };
      publish(source);
    };

    for (const input of channels) {
      input.addEventListener("input", () => {
        const key = input.dataset.channel as "h" | "s" | "l";
        hsl = { ...hsl, [key]: Number(input.value) };
        publish(input);
      });
    }

    native.addEventListener("input", () => adopt(native.value, native));

    field?.addEventListener("change", () => {
      const hex = field.value.trim();
      // Normalised on CHANGE, not on input: `change` means committed (blur or Enter), so `#f00`
      // becomes `#ff0000` in the box without ever rewriting what someone is still typing. Text that
      // is not a colour is left exactly as typed and flagged — replacing it with black would throw
      // away the correction they were in the middle of making.
      if (hexToHsl(hex)) adopt(hex);
      else field.setAttribute("aria-invalid", "true");
    });

    // The pointer drag is the one behavior the platform does not provide for a 2-D control. It
    // writes the same two range inputs the keyboard does, so there is still one source of truth.
    if (area) {
      const saturation = area.querySelector<HTMLInputElement>('[data-channel="s"]');
      const lightness = area.querySelector<HTMLInputElement>('[data-channel="l"]');
      const aim = (event: PointerEvent) => {
        const box = area.getBoundingClientRect();
        if (box.width === 0 || box.height === 0) return;
        const s = Math.round(((event.clientX - box.left) / box.width) * 100);
        const l = Math.round(100 - ((event.clientY - box.top) / box.height) * 100);
        hsl = { ...hsl, s: Math.min(100, Math.max(0, s)), l: Math.min(100, Math.max(0, l)) };
        publish();
      };
      area.addEventListener("pointerdown", (event) => {
        // Let a click that landed on a slider's own thumb be the slider's — dragging THAT should
        // move one axis, which is what a pointer user expects from a control they can see.
        if (event.target === saturation || event.target === lightness) return;
        area.setPointerCapture(event.pointerId);
        aim(event);
      });
      area.addEventListener("pointermove", (event) => {
        if (area.hasPointerCapture(event.pointerId)) aim(event);
      });
      area.addEventListener("pointerup", (event) => area.releasePointerCapture(event.pointerId));
    }

    publish();
  }

  onReadyOnce('[data-slot="color-picker"]', wire);
</script>
src/components/ui/forms/color-picker/ColorSlider.astro
---
// src/components/ui/forms/color-picker/ColorSlider.astro — ColorPicker compound part (see ../../README.md).
// One channel of the colour as a native <input type="range"> — the hue rail, usually. Native because
// a range input already has the keyboard, the value semantics and the drag; all this adds is the
// channel it belongs to and a value text that says what the number means.
//
// Like Slider, this ships no `appearance: none`: that one declaration erases the UA's whole control
// in WebKit, so a library that shipped it without also shipping geometry would hand consumers an
// invisible slider. Paint the rail in your theme — `theme-default.css` has the worked example.
import type { HTMLAttributes } from "astro/types";

import { hexToHsl, roundHsl } from "./color-math";

type Props = HTMLAttributes<"input"> & {
  channel?: "hue" | "saturation" | "lightness";
  /** The starting colour, so the rail renders in the right place before any script runs. */
  value?: string;
  label?: string;
};

const { channel = "hue", value = "#000000", label, class: className, ...rest } = Astro.props;

const hsl = roundHsl(hexToHsl(value) ?? { h: 0, s: 0, l: 0 });
const current = channel === "hue" ? hsl.h : channel === "saturation" ? hsl.s : hsl.l;
const key = channel === "hue" ? "h" : channel === "saturation" ? "s" : "l";
const max = channel === "hue" ? 360 : 100;
---

<input
  type="range"
  min="0"
  max={max}
  value={current}
  aria-label={label ?? channel[0].toUpperCase() + channel.slice(1)}
  data-slot="color-slider"
  data-channel={key}
  class={className}
  {...rest}
/>
src/components/ui/forms/color-picker/ColorSwatch.astro
---
// src/components/ui/forms/color-picker/ColorSwatch.astro — ColorPicker compound part (see ../../README.md).
// A patch of the current colour. Inside a ColorPicker its script keeps it in step; on its own it is
// a way to show any colour with a name attached.
//
// THIS IS THE ONE PLACE THE LIBRARY WRITES A COLOUR, and it is not an opinion — it is the value.
// A swatch whose background a theme had to supply could not show a colour chosen at runtime, which
// is the only thing a swatch is for. Everything about how it LOOKS — size, shape, border, the
// chequerboard behind a transparent colour — stays the theme's.
//
// `role="img"` with a label, because a coloured box with no accessible name is invisible to a screen
// reader; the name says the colour in words ("blue"), which a hex code does not.
import type { HTMLAttributes } from "astro/types";

import { describeColor } from "./color-math";

type Props = HTMLAttributes<"span"> & {
  /** The colour to show — any CSS colour; ColorPicker writes `#rrggbb`. */
  value?: string;
  /** Overrides the spoken name. The default describes the colour in words. */
  label?: string;
};

const { value = "#000000", label, class: className, ...rest } = Astro.props;
---

<span
  role="img"
  aria-label={label ?? describeColor(value)}
  style={`background-color: ${value}`}
  data-slot="color-swatch"
  data-value={value}
  class={className}
  {...rest}></span>
src/components/ui/forms/color-picker/color-math.ts
// src/components/ui/forms/color-picker/color-math.ts — hex ⇄ HSL, and the plain-English name a colour
// announces with. A module rather than inline code because it is arithmetic with edge cases (greys
// have no hue; `#abc` is a colour) and because it is the only part of ColorPicker that can be
// checked without a browser — see color-math.test.ts.
//
// HSL is the working space for one reason: it is the one a person can operate. A hue slider and a
// saturation/lightness area are two controls anyone can aim; three RGB sliders are a puzzle. Hex is
// what goes in and out, because that is what `<input type="color">` speaks and what a form submits.

export interface Rgb {
  readonly r: number;
  readonly g: number;
  readonly b: number;
}

export interface Hsl {
  /** Degrees, 0–360. */
  readonly h: number;
  /** Percent, 0–100. */
  readonly s: number;
  /** Percent, 0–100. */
  readonly l: number;
}

const clamp = (value: number, min: number, max: number): number =>
  Math.min(max, Math.max(min, value));

/**
 * Accept the forms people actually type — `#abc`, `abc`, `#AABBCC`, with stray spaces — and return
 * a lower-case `#rrggbb`, or `null` when it is not a colour at all.
 *
 * `<input type="color">` only ever holds the long form, so this exists for ColorField, where a user
 * types `#f00` and expects it to work.
 */
export function normalizeHex(input: string): string | null {
  const raw = input.trim().replace(/^#/, "").toLowerCase();
  if (/^[0-9a-f]{3}$/.test(raw)) {
    return `#${[...raw].map((c) => c + c).join("")}`;
  }
  return /^[0-9a-f]{6}$/.test(raw) ? `#${raw}` : null;
}

/** `#rrggbb` → channels 0–255. Returns `null` for anything `normalizeHex` rejects. */
export function hexToRgb(hex: string): Rgb | null {
  const normalized = normalizeHex(hex);
  if (!normalized) return null;
  const n = Number.parseInt(normalized.slice(1), 16);
  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}

/** Channels 0–255 (rounded and clamped) → `#rrggbb`. */
export function rgbToHex({ r, g, b }: Rgb): string {
  const hex = (v: number) =>
    Math.round(clamp(v, 0, 255))
      .toString(16)
      .padStart(2, "0");
  return `#${hex(r)}${hex(g)}${hex(b)}`;
}

/**
 * Channels 0–255 → degrees + percents. A grey reports `h: 0`, since it has no hue to report.
 *
 * UNROUNDED on purpose. Rounding here would cost a colour on every conversion — `#1f3fd8` comes back
 * as `#1f3dd6` — and these functions run in both directions on every drag of the area. Round at the
 * point of DISPLAY instead (the sliders are integers, so the control's own precision is integer HSL;
 * that is one lossy step, not one per keystroke).
 */
export function rgbToHsl({ r, g, b }: Rgb): Hsl {
  const [rn, gn, bn] = [r / 255, g / 255, b / 255];
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;
  if (d === 0) return { h: 0, s: 0, l: l * 100 };
  const s = d / (1 - Math.abs(2 * l - 1));
  const h = max === rn ? ((gn - bn) / d) % 6 : max === gn ? (bn - rn) / d + 2 : (rn - gn) / d + 4;
  return { h: (((h * 60) % 360) + 360) % 360, s: s * 100, l: l * 100 };
}

/** HSL with each component rounded — what a slider, a label or `aria-valuenow` should show. */
export function roundHsl({ h, s, l }: Hsl): Hsl {
  return { h: Math.round(h), s: Math.round(s), l: Math.round(l) };
}

/** Degrees + percents → channels 0–255. */
export function hslToRgb({ h, s, l }: Hsl): Rgb {
  const sn = clamp(s, 0, 100) / 100;
  const ln = clamp(l, 0, 100) / 100;
  const c = (1 - Math.abs(2 * ln - 1)) * sn;
  const hp = (((h % 360) + 360) % 360) / 60;
  const x = c * (1 - Math.abs((hp % 2) - 1));
  const [r, g, b] =
    hp < 1
      ? [c, x, 0]
      : hp < 2
        ? [x, c, 0]
        : hp < 3
          ? [0, c, x]
          : hp < 4
            ? [0, x, c]
            : hp < 5
              ? [x, 0, c]
              : [c, 0, x];
  const m = ln - c / 2;
  return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
}

/** `#rrggbb` → HSL, or `null` when the input is not a colour. */
export function hexToHsl(hex: string): Hsl | null {
  const rgb = hexToRgb(hex);
  return rgb && rgbToHsl(rgb);
}

/** HSL → `#rrggbb`. */
export function hslToHex(hsl: Hsl): string {
  return rgbToHex(hslToRgb(hsl));
}

// Hue names by upper bound, in degrees. Coarse on purpose: this is what a colour is CALLED, and
// nobody needs "chartreuse" read out to know which way to push a slider.
const HUE_NAMES: readonly (readonly [number, string])[] = [
  [15, "red"],
  [45, "orange"],
  [70, "yellow"],
  [170, "green"],
  [200, "cyan"],
  [250, "blue"],
  [290, "purple"],
  [335, "pink"],
  [360, "red"],
];

/**
 * A plain-English name for a colour — what `aria-valuetext` says while someone arrows around the
 * saturation area. Without it a screen reader reads "62, 48" and the control is unusable: the whole
 * point of a colour picker is the colour, and that is the one thing a number cannot convey.
 *
 * @example describeColor("#1f3fd8") // => "blue"
 * @example describeColor("#f3f4f6") // => "light grey"
 */
export function describeColor(hex: string): string {
  const hsl = hexToHsl(hex);
  if (!hsl) return hex;
  const { h, s, l } = hsl;
  if (l >= 97) return "white";
  if (l <= 3) return "black";
  // Saturation reads as colour less and less as lightness goes to either extreme: `#f3f4f6` is 14%
  // saturated and nobody would call it blue. So the grey band widens at the ends.
  if (s <= 8 || (s <= 20 && (l >= 90 || l <= 12))) {
    return l >= 65 ? "light grey" : l <= 35 ? "dark grey" : "grey";
  }
  const name = HUE_NAMES.find(([max]) => h < max)?.[1] ?? "red";
  const shade = l >= 75 ? "light " : l <= 30 ? "dark " : "";
  const muted = s <= 25 ? "muted " : "";
  return `${shade}${muted}${name}`;
}
src/components/ui/forms/color-picker/index.ts
import ColorArea from "./ColorArea.astro";
import ColorField from "./ColorField.astro";
import ColorPicker from "./ColorPicker.astro";
import ColorSlider from "./ColorSlider.astro";
import ColorSwatch from "./ColorSwatch.astro";

export { ColorArea, ColorField, ColorPicker, ColorSlider, ColorSwatch };
export default ColorPicker;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/color-picker — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.