Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Colorpicker

An inline color swatch picker with an optional hex input.

An inline swatch grid for choosing a color from presets — a radiogroup of color circles with platform-correct selection chrome, plus an optional free-form hex input. Values are canonical lowercase #rrggbb hex strings.

Installation

npx touchcn add colorpicker

Usage

<tcn-colorpicker [(value)]="color">
  <tcn-colorpicker-swatch value="#007aff" label="Blue" />
  <tcn-colorpicker-swatch value="#34c759" label="Green" />
</tcn-colorpicker>
import { TcnColorpicker, TcnColorpickerSwatch } from '@/components/ui/colorpicker';

<TcnColorpicker value={color} onValueChange={setColor}>
  <TcnColorpickerSwatch value="#007aff" label="Blue" />
  <TcnColorpickerSwatch value="#34c759" label="Green" />
</TcnColorpicker>
<script setup lang="ts">
import { TcnColorpicker, TcnColorpickerSwatch } from '@/components/ui/colorpicker';
</script>

<template>
  <TcnColorpicker v-model="color">
    <TcnColorpickerSwatch value="#007aff" label="Blue" />
    <TcnColorpickerSwatch value="#34c759" label="Green" />
  </TcnColorpicker>
</template>

Hex input

TcnColorpickerInput adds free-form hex entry — bind it to the same value as the grid. The draft commits (canonicalized) only on Enter or blur and only when valid; a color outside the presets simply leaves every swatch unchecked.

<tcn-colorpicker-input [(value)]="color" label="Hex" />
<TcnColorpickerInput value={color} onValueChange={setColor} label="Hex" />
<TcnColorpickerInput v-model="color" label="Hex" />

Props

Colorpicker

Prop Type Default Description
value string | null null Selected color (two-way bound); emitted as canonical #rrggbb.
onValueChange (value: string) => void Change callback (React).
disabled boolean false Disable the whole group.

ColorpickerSwatch

Prop Type Default Description
value string Swatch color (any hex form; canonicalized).
label string hex Accessible name.
disabled boolean false Disable this swatch.

ColorpickerInput

Prop Type Default Description
value string | null null Committed color (two-way bound).
onValueChange (value: string) => void Change callback (React).
label string Field label.
disabled boolean false Disable the field.
Source · Angular
export * from './tcn-colorpicker';
import {
  booleanAttribute,
  Component,
  computed,
  contentChildren,
  effect,
  ElementRef,
  input,
  model,
  signal,
  viewChildren,
} from '@angular/core';
import { TcnRadioKeyNavDirective } from '@touchcn/angular/radio';
import { isValidHex, normalizeHex, onColorFor } from '@touchcn/core';
import { TcnInput } from '@/components/ui/input';

/**
 * Colorpicker — an inline swatch grid with a hex value model. A radiogroup of
 * colour circles: MD3 marks the selection with a surface-gap ring in the
 * swatch's own colour, iOS with a checkmark and a hairline ring. Both platforms
 * share this markup; the cascade shapes the chrome.
 *
 * Values are canonicalized to lowercase `#rrggbb` in both directions, so
 * `#FFF` from the outside still checks the `#ffffff` swatch. Each swatch
 * carries its colour and contrast check colour as inline `--tcn-colorpicker-*`
 * variables — the only per-swatch styling the component computes. Arrow-key
 * focus movement reuses the engine `TcnRadioKeyNav` directive; the group owns
 * the roving-`tabindex` decision (WAI-ARIA radio pattern).
 */
@Component({
  selector: 'tcn-colorpicker',
  imports: [TcnRadioKeyNavDirective],
  template: `
    <div
      role="radiogroup"
      class="tcn-colorpicker"
      tcnRadioKeyNav
      [items]="swatchEls()"
      (activate)="onActivate($event)"
      [attr.aria-disabled]="disabled() || null"
    >
      @for (swatch of swatches(); track swatch.hex()) {
        <button
          #swatchBtn
          type="button"
          role="radio"
          class="tcn-colorpicker-swatch"
          [attr.data-state]="swatch.hex() === selectedHex() ? 'checked' : 'unchecked'"
          [attr.aria-checked]="swatch.hex() === selectedHex()"
          [attr.aria-label]="swatch.label() || swatch.hex()"
          [attr.tabindex]="swatch.hex() === focusableHex() ? 0 : -1"
          [disabled]="disabled() || swatch.disabled()"
          [style.--tcn-colorpicker-swatch]="swatch.hex()"
          [style.--tcn-colorpicker-on-swatch]="swatch.onHex()"
          (click)="select(swatch.hex())"
        >
          <span class="tcn-colorpicker-swatch-state" aria-hidden="true"></span>
          <span class="tcn-colorpicker-swatch-check" aria-hidden="true">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
              <path d="M5 12l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </span>
        </button>
      }
    </div>
  `,
  host: { class: 'block' },
})
export class TcnColorpicker {
  readonly value = model<string | null>(null);
  readonly disabled = input(false, { transform: booleanAttribute });

  protected readonly swatches = contentChildren(TcnColorpickerSwatch);
  private readonly swatchButtons = viewChildren<ElementRef<HTMLButtonElement>>('swatchBtn');

  protected readonly swatchEls = computed(() => this.swatchButtons().map((ref) => ref.nativeElement));

  /** The canonical selected hex, or null when nothing (or nothing valid) is selected. */
  readonly selectedHex = computed(() => {
    const value = this.value();
    return value ? (normalizeHex(value) ?? value) : null;
  });

  /** The swatch that is currently tabbable (selected, else first enabled). */
  readonly focusableHex = computed<string | null>(() => {
    const selected = this.selectedHex();
    const swatches = this.swatches();
    if (selected && swatches.some((swatch) => swatch.hex() === selected && !swatch.disabled())) {
      return selected;
    }
    const firstEnabled = swatches.find((swatch) => !swatch.disabled());
    return firstEnabled ? firstEnabled.hex() : null;
  });

  select(hex: string): void {
    if (!this.disabled()) {
      this.value.set(hex);
    }
  }

  protected onActivate(element: HTMLElement): void {
    const index = this.swatchEls().indexOf(element as HTMLButtonElement);
    const swatch = this.swatches()[index];
    if (swatch) {
      this.select(swatch.hex());
    }
  }
}

/** A single colour within a `tcn-colorpicker`; `label` is the accessible name (defaults to the hex). */
@Component({
  selector: 'tcn-colorpicker-swatch',
  template: '',
})
export class TcnColorpickerSwatch {
  readonly value = input.required<string>();
  readonly label = input('');
  readonly disabled = input(false, { transform: booleanAttribute });

  /** The canonical `#rrggbb` form of `value` (left as-is when unparseable). */
  readonly hex = computed(() => normalizeHex(this.value()) ?? this.value());
  /** Black or white, whichever reads on the swatch — the checkmark colour. */
  readonly onHex = computed(() => onColorFor(this.hex()));
}

/**
 * Free-form hex entry for a `tcn-colorpicker` — bind both to the same value.
 * The draft is local: it commits (canonicalized) only on Enter or blur and only
 * when valid, shows the error state while invalid, and never reverts the
 * user's typing. The preview chip tracks the draft live.
 */
@Component({
  selector: 'tcn-colorpicker-input',
  imports: [TcnInput],
  template: `
    <div class="tcn-colorpicker-input flex items-center gap-3" (focusout)="commit()" (keydown.enter)="commit()">
      <span class="tcn-colorpicker-preview" aria-hidden="true" [style.--tcn-colorpicker-swatch]="previewHex()"></span>
      <tcn-input
        class="min-w-0 flex-1"
        [label]="label()"
        placeholder="#RRGGBB"
        [disabled]="disabled()"
        [error]="invalid()"
        [(value)]="draft"
      />
    </div>
  `,
  host: { class: 'block' },
})
export class TcnColorpickerInput {
  readonly value = model<string | null>(null);
  readonly label = input('');
  readonly disabled = input(false, { transform: booleanAttribute });

  protected readonly draft = signal('');
  protected readonly invalid = computed(() => this.draft().trim() !== '' && !isValidHex(this.draft()));

  /** Live preview: the valid draft, else the committed value, else unset (transparent chip). */
  protected readonly previewHex = computed(() => {
    const fromDraft = normalizeHex(this.draft());
    if (fromDraft) {
      return fromDraft;
    }
    const value = this.value();
    return value ? (normalizeHex(value) ?? value) : null;
  });

  constructor() {
    effect(() => {
      const value = this.value();
      this.draft.set(value ? (normalizeHex(value) ?? value) : '');
    });
  }

  protected commit(): void {
    const normalized = normalizeHex(this.draft());
    if (!normalized) {
      return;
    }
    this.draft.set(normalized);
    this.value.set(normalized);
  }
}
Source · React
export * from './tcn-colorpicker';
import { forwardRef, useEffect, useState } from 'react';
import type { CSSProperties, ComponentPropsWithoutRef, ReactNode } from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { cn, isValidHex, normalizeHex, onColorFor } from '@touchcn/core';
import { TcnInput } from '@/components/ui/input';

export interface TcnColorpickerProps {
  /** Selected colour (any hex form); emitted values are canonical `#rrggbb`. */
  value?: string | null;
  onValueChange?(value: string): void;
  disabled?: boolean;
  className?: string;
  children?: ReactNode;
}

export interface TcnColorpickerSwatchProps
  extends Omit<ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>, 'asChild' | 'value'> {
  /** Swatch colour (any hex form; canonicalized). */
  value: string;
  /** Accessible name; defaults to the canonical hex. */
  label?: string;
}

export interface TcnColorpickerInputProps {
  value?: string | null;
  onValueChange?(value: string): void;
  label?: string;
  disabled?: boolean;
  className?: string;
}

/**
 * Colorpicker — an inline swatch grid with a hex value model. A radiogroup of
 * colour circles: MD3 marks the selection with a surface-gap ring in the
 * swatch's own colour, iOS with a checkmark and a hairline ring. Both platforms
 * share this markup; the cascade shapes the chrome.
 *
 * Radix supplies the `role="radiogroup"` container with roving `tabindex`,
 * arrow-key navigation and the single-selection value model (the behaviour the
 * Angular port re-implements). Values are canonicalized to lowercase `#rrggbb`
 * in both directions, so `#FFF` from the outside still checks the `#ffffff`
 * swatch.
 */
export const TcnColorpicker = forwardRef<
  React.ElementRef<typeof RadioGroupPrimitive.Root>,
  TcnColorpickerProps
>(function TcnColorpicker({ value, onValueChange, disabled, className, children }, ref) {
  return (
    <RadioGroupPrimitive.Root
      ref={ref}
      className={cn('tcn-colorpicker', className)}
      value={value ? (normalizeHex(value) ?? value) : ''}
      onValueChange={onValueChange}
      disabled={disabled}
    >
      {children}
    </RadioGroupPrimitive.Root>
  );
});

/**
 * A single colour within a `TcnColorpicker`. Carries its colour and contrast
 * check colour as inline `--tcn-colorpicker-*` variables — the only per-swatch
 * styling the component computes.
 */
export const TcnColorpickerSwatch = forwardRef<
  React.ElementRef<typeof RadioGroupPrimitive.Item>,
  TcnColorpickerSwatchProps
>(function TcnColorpickerSwatch({ value, label, className, style, ...props }, ref) {
  const hex = normalizeHex(value) ?? value;
  return (
    <RadioGroupPrimitive.Item
      ref={ref}
      value={hex}
      aria-label={label || hex}
      className={cn('tcn-colorpicker-swatch', className)}
      style={{ '--tcn-colorpicker-swatch': hex, '--tcn-colorpicker-on-swatch': onColorFor(hex), ...style } as CSSProperties}
      {...props}
    >
      <span className="tcn-colorpicker-swatch-state" aria-hidden="true" />
      <span className="tcn-colorpicker-swatch-check" aria-hidden="true">
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
          <path d="M5 12l4 4L19 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </span>
    </RadioGroupPrimitive.Item>
  );
});

/**
 * Free-form hex entry for a `TcnColorpicker` — bind both to the same value.
 * The draft is local: it commits (canonicalized) only on Enter or blur and only
 * when valid, shows the error state while invalid, and never reverts the
 * user's typing. The preview chip tracks the draft live.
 */
export function TcnColorpickerInput({ value, onValueChange, label, disabled, className }: TcnColorpickerInputProps) {
  const committed = value ? (normalizeHex(value) ?? value) : '';
  const [draft, setDraft] = useState(committed);

  useEffect(() => {
    setDraft(committed);
  }, [committed]);

  const invalid = draft.trim() !== '' && !isValidHex(draft);
  const previewHex = normalizeHex(draft) ?? (committed || null);

  const commit = () => {
    const normalized = normalizeHex(draft);
    if (!normalized) {
      return;
    }
    setDraft(normalized);
    onValueChange?.(normalized);
  };

  return (
    <div className={cn('tcn-colorpicker-input flex items-center gap-3', className)}>
      <span
        className="tcn-colorpicker-preview"
        aria-hidden="true"
        style={previewHex ? ({ '--tcn-colorpicker-swatch': previewHex } as CSSProperties) : undefined}
      />
      <TcnInput
        wrapperClassName="min-w-0 flex-1"
        label={label}
        placeholder="#RRGGBB"
        disabled={disabled}
        error={invalid}
        value={draft}
        onValueChange={setDraft}
        onBlur={commit}
        onKeyDown={(event) => {
          if (event.key === 'Enter') {
            commit();
          }
        }}
      />
    </div>
  );
}
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import { RadioGroupRoot } from 'reka-ui';
import { normalizeHex } from '@touchcn/core';

/**
 * Colorpicker — an inline swatch grid with a hex value model. A radiogroup of
 * colour circles: MD3 marks the selection with a surface-gap ring in the
 * swatch's own colour, iOS with a checkmark and a hairline ring. Both platforms
 * share this markup; the cascade shapes the chrome.
 *
 * reka-ui `RadioGroupRoot` supplies the `role="radiogroup"` container with
 * roving `tabindex`, arrow-key navigation and the single-selection value model
 * (the behaviour the Angular port re-implements). Values are canonicalized to
 * lowercase `#rrggbb` in both directions, so `#FFF` from the outside still
 * checks the `#ffffff` swatch. Two-way bound with `v-model`.
 */
defineProps<{ disabled?: boolean }>();

const model = defineModel<string | null>({ default: null });

/** Bridges the nullable hex model to reka-ui, canonicalizing on the way in. */
const proxy = computed({
  get: () => {
    const value = model.value;
    return value ? (normalizeHex(value) ?? value) : undefined;
  },
  set: (value?: string) => {
    model.value = value ?? null;
  },
});
</script>

<template>
  <RadioGroupRoot v-model="proxy" :disabled="disabled" class="tcn-colorpicker">
    <slot />
  </RadioGroupRoot>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { isValidHex, normalizeHex } from '@touchcn/core';
import { TcnInput } from '@/components/ui/input';

/**
 * Free-form hex entry for a `TcnColorpicker` — bind both to the same value.
 * The draft is local: it commits (canonicalized) only on Enter or blur and only
 * when valid, shows the error state while invalid, and never reverts the
 * user's typing. The preview chip tracks the draft live.
 */
defineProps<{ label?: string; disabled?: boolean }>();

const model = defineModel<string | null>({ default: null });

const committed = computed(() => {
  const value = model.value;
  return value ? (normalizeHex(value) ?? value) : '';
});

const draft = ref(committed.value);
watch(committed, (value) => {
  draft.value = value;
});

const invalid = computed(() => draft.value.trim() !== '' && !isValidHex(draft.value));

/** Live preview: the valid draft, else the committed value, else unset (transparent chip). */
const previewHex = computed(() => normalizeHex(draft.value) ?? (committed.value || null));

function commit(): void {
  const normalized = normalizeHex(draft.value);
  if (!normalized) {
    return;
  }
  draft.value = normalized;
  model.value = normalized;
}
</script>

<template>
  <div class="tcn-colorpicker-input flex items-center gap-3">
    <span
      class="tcn-colorpicker-preview"
      aria-hidden="true"
      :style="previewHex ? { '--tcn-colorpicker-swatch': previewHex } : undefined"
    />
    <TcnInput
      v-model="draft"
      wrapper-class="min-w-0 flex-1"
      :label="label"
      placeholder="#RRGGBB"
      :disabled="disabled"
      :error="invalid"
      @blur="commit"
      @keydown.enter="commit"
    />
  </div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { RadioGroupItem } from 'reka-ui';
import { normalizeHex, onColorFor } from '@touchcn/core';

/**
 * A single colour within a `TcnColorpicker`. Carries its colour and contrast
 * check colour as inline `--tcn-colorpicker-*` variables — the only per-swatch
 * styling the component computes. `label` is the accessible name (defaults to
 * the canonical hex).
 */
const props = defineProps<{ value: string; label?: string; disabled?: boolean }>();

const hex = computed(() => normalizeHex(props.value) ?? props.value);
const onHex = computed(() => onColorFor(hex.value));
</script>

<template>
  <RadioGroupItem
    :value="hex"
    :disabled="disabled"
    :aria-label="label || hex"
    class="tcn-colorpicker-swatch"
    :style="{ '--tcn-colorpicker-swatch': hex, '--tcn-colorpicker-on-swatch': onHex }"
  >
    <span class="tcn-colorpicker-swatch-state" aria-hidden="true" />
    <span class="tcn-colorpicker-swatch-check" aria-hidden="true">
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
        <path
          d="M5 12l4 4L19 7"
          stroke="currentColor"
          stroke-width="2.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </span>
  </RadioGroupItem>
</template>
export { default as TcnColorpicker } from './TcnColorpicker.vue';
export { default as TcnColorpickerInput } from './TcnColorpickerInput.vue';
export { default as TcnColorpickerSwatch } from './TcnColorpickerSwatch.vue';

Last updated on August 11, 2026

Was this page helpful?