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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/color-picker/ColorArea.astro
---
import type { HTMLAttributes } from "astro/types";

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

type Props = HTMLAttributes<"div"> & {
  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
---
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
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  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 };

    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]);
        input.setAttribute("aria-valuetext", `${rounded[key]}${key === "h" ? "°" : "%"}, ${name}`);
      }
      if (thumb) {
        thumb.style.left = `${rounded.s}%`;
        thumb.style.top = `${100 - rounded.l}%`;
        thumb.style.backgroundColor = hex;
      }
      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;
        native.dispatchEvent(new Event("change", { bubbles: true }));
      }
    };

    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();
      if (hexToHsl(hex)) adopt(hex);
      else field.setAttribute("aria-invalid", "true");
    });

    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) => {
        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
---
import type { HTMLAttributes } from "astro/types";

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

type Props = HTMLAttributes<"input"> & {
  channel?: "hue" | "saturation" | "lightness";
  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
---
import type { HTMLAttributes } from "astro/types";

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

type Props = HTMLAttributes<"span"> & {
  value?: string;
  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
export interface Rgb {
  readonly r: number;
  readonly g: number;
  readonly b: number;
}

export interface Hsl {
  readonly h: number;
  readonly s: number;
  readonly l: number;
}

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

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;
}

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 };
}

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)}`;
}

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 };
}

export function roundHsl({ h, s, l }: Hsl): Hsl {
  return { h: Math.round(h), s: Math.round(s), l: Math.round(l) };
}

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 };
}

export function hexToHsl(hex: string): Hsl | null {
  const rgb = hexToRgb(hex);
  return rgb && rgbToHsl(rgb);
}

export function hslToHex(hsl: Hsl): string {
  return rgbToHex(hslToRgb(hsl));
}

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"],
];

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";
  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.