Color Input
Color input is still under development.
#FF24BD-100%
'use client';
import {
ColorInput,
ColorChit,
ColorInputPrimitive,
InputMultiRoot,
NumericInputPrimitive,
} from '@/components/ui/input';
import { useState } from 'react';
export default function ColorInputDemo() {
const [color, setColor] = useState('FF24BD');
const [opacity, setOpacity] = useState(100);
return (
<div className='flex flex-col gap-2'>
<ColorInput className='w-32' defaultValue={`${color}`} />
<InputMultiRoot>
<ColorInputPrimitive
className='w-16'
value={color}
onChange={(e) => setColor(e.target.value)}
iconLead={<ColorChit color={color} opacity={opacity} />}
/>
<NumericInputPrimitive
className='w-8'
iconTrail={'%'}
value={opacity}
min={0}
max={100}
onValueChange={(next) => {
const n = Number(next);
if (Number.isFinite(n)) setOpacity(n);
}}
/>
</InputMultiRoot>
<span className='text-sm text-gray-500'>{`#${color}-${opacity}%`}</span>
<div
className='size-12 rounded-lg'
style={{ backgroundColor: `#${color}`, opacity: opacity / 100 }}
/>
</div>
);
}
Manual Installation
Make sure to install chroma-js and add input-utils.tsx and text-input.tsx to your project.
import React from 'react';
import { cn } from '@/lib/utils';
import chroma from 'chroma-js';
import { TextInputPrimitive } from './text-input';
import { InputRoot, type BaseInputProps } from './input-utils';
import { Input as BaseInput } from '@base-ui-components/react';
const HEX_RE = /^([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const HEX8_RE = /^#?[0-9a-fA-F]{8}$/;
const RGBA_RE = /^rgba\(/i;
const HSLA_RE = /^hsla\(/i;
const COLOR_FUNC_RE = /^color\(/i;
const normalizeHex = (s: string): string => {
const t = s.trim();
return HEX_RE.test(t) ? `#${t}` : t;
};
const toStringValue = (v: unknown): string => (typeof v === 'string' ? v : '');
const formatHexNoHashUpper = (s: string): string => {
return chroma(normalizeHex(s)).hex().slice(1).toUpperCase();
};
const hasExplicitAlpha = (s: string): boolean => {
const t = s.trim();
return (
HEX8_RE.test(t) ||
RGBA_RE.test(t) ||
HSLA_RE.test(t) ||
COLOR_FUNC_RE.test(t)
);
};
const ColorChit = React.memo(function ColorChit({
color,
opacity,
className,
onClick,
}: {
color: string;
opacity?: string | number;
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}) {
const swatch = React.useMemo(() => {
try {
const norm = normalizeHex(color);
if (chroma.valid(norm)) return chroma(norm).css();
} catch {}
return null;
}, [color]);
return (
<div
className={cn(
'inset-ring-black-200 dark:inset-ring-white-200 flex size-4 shrink-0 justify-start overflow-hidden rounded-sm inset-ring',
onClick && 'cursor-pointer',
className,
)}
style={{
backgroundImage:
'conic-gradient(#eee 25%, #ccc 0 50%, #eee 0 75%, #ccc 0)',
backgroundSize: '8px 8px',
backgroundPosition: '0 0',
backgroundRepeat: 'repeat',
}}
onClick={onClick}
>
<div
className='h-full w-1/2'
style={{
backgroundColor: swatch ?? 'transparent',
}}
/>
<div
className='h-full w-1/2'
style={{
backgroundColor: swatch ?? 'transparent',
opacity: opacity === undefined ? undefined : Number(opacity) / 100,
}}
/>
</div>
);
});
function ColorInputPrimitive({
onChange,
onBlur,
onKeyDown,
value,
defaultValue,
className,
...props
}: BaseInputProps) {
type BaseInputChangeEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onChange']>
>[0];
type BaseInputBlurEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onBlur']>
>[0];
type BaseInputKeyDownEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onKeyDown']>
>[0];
const initial = toStringValue(value ?? defaultValue ?? '');
const [inputValue, setInputValue] = React.useState<string>(initial);
const lastValidRef = React.useRef<string>(
chroma.valid(normalizeHex(initial))
? formatHexNoHashUpper(initial)
: '000000',
);
const [opacityPercent, setOpacityPercent] = React.useState<number>(() => {
try {
if (chroma.valid(normalizeHex(initial))) {
const a = chroma(normalizeHex(initial)).alpha();
return Math.round(a * 100);
}
} catch {}
return 100;
});
React.useEffect(() => {
if (value !== undefined) {
const s = toStringValue(value);
setInputValue(s);
if (chroma.valid(normalizeHex(s))) {
lastValidRef.current = formatHexNoHashUpper(s);
if (hasExplicitAlpha(s)) {
try {
const a = chroma(normalizeHex(s)).alpha();
setOpacityPercent(Math.round(a * 100));
} catch {}
}
}
}
}, [value]);
const handleChange = React.useCallback(
(e: BaseInputChangeEvent) => {
const next = e.target.value;
setInputValue(next);
if (chroma.valid(normalizeHex(next))) {
lastValidRef.current = formatHexNoHashUpper(next);
if (hasExplicitAlpha(next)) {
try {
const a = chroma(normalizeHex(next)).alpha();
setOpacityPercent(Math.round(a * 100));
} catch {}
}
}
onChange?.(e);
},
[onChange],
);
const commit = React.useCallback(() => {
if (chroma.valid(normalizeHex(inputValue))) {
const hexNoHashUpper = formatHexNoHashUpper(inputValue);
setInputValue(hexNoHashUpper);
lastValidRef.current = hexNoHashUpper;
if (hasExplicitAlpha(inputValue)) {
try {
const a = chroma(normalizeHex(inputValue)).alpha();
setOpacityPercent(Math.round(a * 100));
} catch {}
}
} else {
setInputValue(lastValidRef.current);
}
}, [inputValue]);
const handleBlur = React.useCallback(
(e: BaseInputBlurEvent) => {
commit();
onBlur?.(e);
},
[commit, onBlur],
);
const handleKeyDown = React.useCallback(
(e: BaseInputKeyDownEvent) => {
if (e.key === 'Enter') {
commit();
e.currentTarget.blur();
}
onKeyDown?.(e);
},
[commit, onKeyDown],
);
const previewHex = lastValidRef.current;
const previewColor = React.useMemo(() => {
try {
return chroma(`#${previewHex}`)
.alpha((opacityPercent ?? 100) / 100)
.css();
} catch {
return `#${previewHex}`;
}
}, [previewHex, opacityPercent]);
return (
<TextInputPrimitive
type='text'
{...props}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={cn('w-full flex-1 outline-none', className)}
/>
);
}
function ColorInput({
className,
...props
}: React.ComponentProps<typeof BaseInput>) {
return (
<InputRoot className={cn(className)}>
<ColorInputPrimitive {...props} />
</InputRoot>
);
}
export { ColorInput, ColorChit, ColorInputPrimitive };