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