extend
uidocument-color-pickersource: ok · refreshed
document-color-picker
npx shadcn add "https://ui.extend.ai/r/document-color-picker.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
utilsbuttonpopoverselecttooltip
Files2 · source cached 10/8/2026, 8:54:13 AM
components/ui/color-picker.tsx
"use client"
import * as React from "react"
import { Popover as InlinePopover } from "@base-ui/react/popover"
import {
clamp,
cuspColorForHue,
formatColorValue,
formatCssColor,
getOklchPlanePixels,
hslToRgb,
hsvToHsl,
normalizeHue,
oklchPlaneToRgb,
oklchToRgb,
parseColorInput,
rgbaToHex,
rgbToHsv,
rgbToOklch,
rgbToOklchPlane,
type ColorFormat,
type ColorParts,
type HslColor,
type HsvColor,
type OklchColor,
type RgbColor,
} from "@/lib/color-picker-utils"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Popover, PopoverTrigger } from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { IconPlaceholder } from "@/components/icon-placeholder"
const cossInputKeyboardFocusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
const cossInputWrapperFocusRing =
"focus-within:border-input-focus focus-within:ring-1 focus-within:ring-input-focus/24 focus-within:ring-offset-0 dark:focus-within:border-input-focus dark:focus-within:ring-input-focus/24"
const cossOutlineEdge =
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit]"
const cossOutlineEdgeHighlight =
"before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"
interface HsvaColor {
h: number
s: number
v: number
a: number
}
type EyeDropperConstructor = new () => {
open: () => Promise<{
sRGBHex: string
}>
}
declare global {
interface Window {
EyeDropper?: EyeDropperConstructor
}
}
const COLOR_FORMAT_OPTIONS: Array<{
value: ColorFormat
label: string
}> = [
{ value: "oklch", label: "OKLCH" },
{ value: "hsl", label: "HSL" },
{ value: "rgb", label: "RGB" },
{ value: "hex", label: "Hex" },
]
const OK_HUE_TRACK = `linear-gradient(to bottom, ${Array.from({ length: 25 }, (_, i) => rgbaToHex(cuspColorForHue(i * 15))).join(", ")})`
// Ignore hue noise from RGB round trips.
function withHueContinuity<
T extends {
h: number
},
>(next: T, previousHue: number, chromaFraction: number): T {
const delta = Math.abs(next.h - previousHue) % 360
const angular = Math.min(delta, 360 - delta)
const tolerance = Math.min(10, 1.5 / Math.max(chromaFraction, 0.15))
return angular < tolerance ? { ...next, h: previousHue } : next
}
const triggerGroupClass =
"relative flex h-10 w-full min-w-0 items-center rounded-md border border-input bg-popover not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-input-focus/32 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] not-has-disabled:not-has-focus-visible:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:border-input-focus has-focus-visible:ring-[3px] has-disabled:opacity-64 has-[:disabled,:focus-visible]:shadow-none dark:bg-input/32 dark:ring-input-focus/32 dark:has-focus-visible:border-input-focus dark:not-has-focus-visible:border-white/[0.08] dark:not-has-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-has-disabled:not-has-focus-visible:before:shadow-[0_-1px_--theme(--color-white/6%)]"
const fieldSurfaceClass = cn(
"relative rounded-md border border-input bg-popover text-foreground shadow-xs/5 transition-shadow not-dark:bg-clip-padding dark:border-white/[0.08] dark:bg-input/32",
cossOutlineEdge,
cossOutlineEdgeHighlight,
cossInputWrapperFocusRing
)
const channelInputClass =
"h-full w-full min-w-0 border-0 bg-transparent px-1 py-0 text-center text-sm leading-none tabular-nums text-foreground outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus:border-0 focus:outline-none focus:ring-0"
const thumbShadow = "shadow-[0_0_0_1px_rgb(0_0_0/24%),0_1px_2px_rgb(0_0_0/32%)]"
function Checkerboard({
className,
size = 8,
}: {
className?: string
size?: number
}) {
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 bg-white [--checker:#00000024] dark:bg-neutral-700 dark:[--checker:#ffffff2e]",
className
)}
style={{
backgroundImage:
"conic-gradient(var(--checker) 0 25%, transparent 0 50%, var(--checker) 0 75%, transparent 0)",
backgroundSize: `${size}px ${size}px`,
}}
/>
)
}
interface DraftInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange"
> {
value: string
onCommitText: (text: string) => void
onStep?: (direction: 1 | -1, big: boolean) => void
copyValue?: () => string
inputRef?: React.MutableRefObject<HTMLInputElement | null>
}
function DraftInput({
value,
onCommitText,
onStep,
copyValue,
inputRef,
...props
}: DraftInputProps) {
const [draft, setDraft] = React.useState<string | null>(null)
const internalRef = React.useRef<HTMLInputElement | null>(null)
// Keep mouseup from clearing the focus selection.
const keepSelectionRef = React.useRef(false)
const setRefs = (node: HTMLInputElement | null) => {
internalRef.current = node
if (inputRef) inputRef.current = node
}
const commitDraft = () => {
if (draft !== null && draft.trim() !== value) onCommitText(draft)
setDraft(null)
}
const reselect = () => {
requestAnimationFrame(() => internalRef.current?.select())
}
return (
<input
autoComplete="off"
autoCorrect="off"
spellCheck={false}
type="text"
{...props}
ref={setRefs}
value={draft ?? value}
onFocus={(event) => {
setDraft(event.currentTarget.value)
event.currentTarget.select()
keepSelectionRef.current = true
}}
onMouseUp={(event) => {
if (keepSelectionRef.current) {
event.preventDefault()
keepSelectionRef.current = false
}
}}
onChange={(event) => setDraft(event.target.value)}
onBlur={commitDraft}
onCopy={(event) => {
if (!copyValue) return
const input = event.currentTarget
const fullySelected =
input.value.length > 0 &&
input.selectionStart === 0 &&
input.selectionEnd === input.value.length
if (!fullySelected) return
event.preventDefault()
event.clipboardData.setData("text/plain", copyValue())
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitDraft()
reselect()
} else if (event.key === "Escape") {
if (draft !== null) {
event.stopPropagation()
setDraft(null)
}
} else if (
onStep &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
event.preventDefault()
setDraft(null)
onStep(event.key === "ArrowUp" ? 1 : -1, event.shiftKey)
reselect()
}
}}
/>
)
}
interface ChannelSpec {
key: string
label: string
ariaLabel: string
value: string
numericValue: number
step: number
bigStep: number
commit: (next: number) => void
}
function parseChannelText(text: string): number | null {
const numeric = Number.parseFloat(text.replace(/[%°]/g, "").trim())
return Number.isFinite(numeric) ? numeric : null
}
function ChannelGroup({
channels,
copyValue,
disabled,
}: {
channels: ChannelSpec[]
copyValue: string
disabled?: boolean
}) {
return (
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<div className={cn(fieldSurfaceClass, "grid h-8 grid-cols-3")}>
{channels.map((channel) => (
<DraftInput
key={channel.key}
aria-label={channel.ariaLabel}
className={channelInputClass}
disabled={disabled}
inputMode="decimal"
value={channel.value}
onCommitText={(text) => {
const numeric = parseChannelText(text)
if (numeric !== null) channel.commit(numeric)
}}
onStep={(direction, big) =>
channel.commit(
channel.numericValue +
direction * (big ? channel.bigStep : channel.step)
)
}
/>
))}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-1/3 w-px bg-input dark:bg-white/[0.08]"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-2/3 w-px bg-input dark:bg-white/[0.08]"
/>
</div>
<div className="mt-1 grid h-4 grid-cols-3 items-center text-center text-xs font-medium text-muted-foreground">
{channels.map((channel) => (
<span key={channel.key}>{channel.label}</span>
))}
</div>
</div>
<CopyColorButton text={copyValue} />
</div>
)
}
function SaturationValueArea({
hue,
s,
v,
color,
onMove,
}: {
hue: number
s: number
v: number
color: string
onMove: (s: number, v: number) => void
}) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
// Avoid redraws for imperceptible hue changes.
const quantizedHue = Math.round(normalizeHue(hue) * 2) / 2
React.useEffect(() => {
const canvas = canvasRef.current
const context = canvas?.getContext("2d")
if (!canvas || !context) return
const image = context.createImageData(canvas.width, canvas.height)
image.data.set(
getOklchPlanePixels(canvas.width, canvas.height, quantizedHue)
)
context.putImageData(image, 0, 0)
}, [quantizedHue])
const update = (event: React.PointerEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const inset = 7
onMove(
clamp(
((event.clientX - rect.left - inset) / (rect.width - inset * 2)) * 100,
0,
100
),
clamp(
100 -
((event.clientY - rect.top - inset) / (rect.height - inset * 2)) *
100,
0,
100
)
)
}
return (
<button
type="button"
aria-label="Chroma and lightness"
className={cn(
"relative size-53 shrink-0 cursor-crosshair touch-none rounded-lg",
cossInputKeyboardFocusRing
)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.focus()
update(event)
}}
onPointerMove={(event) => {
if ((event.buttons & 1) === 0) return
update(event)
}}
onKeyDown={(event) => {
const delta = event.shiftKey ? 10 : 1
let next: [number, number] | null = null
if (event.key === "ArrowLeft") next = [s - delta, v]
if (event.key === "ArrowRight") next = [s + delta, v]
if (event.key === "ArrowUp") next = [s, v + delta]
if (event.key === "ArrowDown") next = [s, v - delta]
if (next) {
event.preventDefault()
onMove(clamp(next[0], 0, 100), clamp(next[1], 0, 100))
}
}}
>
<canvas
ref={canvasRef}
aria-hidden="true"
width={168}
height={168}
className="pointer-events-none absolute inset-0 size-full rounded-[inherit]"
/>
<span
className={cn(
"pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[1.5px] border-white",
thumbShadow
)}
style={{
left: `calc(7px + (100% - 14px) * ${clamp(s, 0, 100) / 100})`,
top: `calc(7px + (100% - 14px) * ${clamp(100 - v, 0, 100) / 100})`,
backgroundColor: color,
}}
/>
</button>
)
}
function VerticalSlider({
ariaLabel,
ariaValueMax,
ariaValueNow,
ariaValueText,
fraction,
onFraction,
onStep,
track,
}: {
ariaLabel: string
ariaValueMax: number
ariaValueNow: number
ariaValueText?: string
fraction: number
onFraction: (fraction: number) => void
onStep: (direction: 1 | -1, big: boolean) => void
track: React.ReactNode
}) {
const update = (event: React.PointerEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const inset = 2.5
onFraction(
clamp(
(event.clientY - rect.top - inset) / (rect.height - inset * 2),
0,
1
)
)
}
return (
<button
type="button"
role="slider"
aria-label={ariaLabel}
aria-orientation="vertical"
aria-valuemin={0}
aria-valuemax={ariaValueMax}
aria-valuenow={ariaValueNow}
aria-valuetext={ariaValueText}
className={cn(
"relative w-3 shrink-0 touch-none self-stretch rounded-full",
cossInputKeyboardFocusRing
)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.focus()
update(event)
}}
onPointerMove={(event) => {
if ((event.buttons & 1) === 0) return
update(event)
}}
onKeyDown={(event) => {
let direction: 1 | -1 | null = null
if (event.key === "ArrowUp" || event.key === "ArrowRight") direction = 1
if (event.key === "ArrowDown" || event.key === "ArrowLeft")
direction = -1
if (direction) {
event.preventDefault()
onStep(direction, event.shiftKey)
}
}}
>
<span className="absolute inset-0 overflow-hidden rounded-full ring-1 ring-black/8 ring-inset dark:ring-white/12">
{track}
</span>
<span
className="pointer-events-none absolute left-1/2 h-[5px] w-[calc(100%+3px)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_0_1px_rgb(0_0_0/20%),0_1px_2px_rgb(0_0_0/24%)]"
style={{ top: `calc(2.5px + (100% - 5px) * ${clamp(fraction, 0, 1)})` }}
/>
</button>
)
}
function CopyColorButton({
text,
label = "Copy color",
}: {
text: string
label?: string
}) {
const [copied, setCopied] = React.useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
} catch {
// Clipboard access can be unavailable outside secure browser contexts.
}
}
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={label}
className="size-8! shrink-0"
onClick={handleCopy}
/>
}
>
{copied ? (
<IconPlaceholder
lucide="Check"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className="size-4"
/>
) : (
<IconPlaceholder
lucide="Copy"
tabler="IconCopy"
hugeicons="Copy01Icon"
phosphor="CopyIcon"
remixicon="RiFileCopyLine"
className="size-4"
/>
)}
</TooltipTrigger>
<TooltipContent side="top">{copied ? "Copied" : "Copy"}</TooltipContent>
</Tooltip>
)
}
function AlphaScrubField({
alphaPercent,
disabled,
onCommit,
scrubbable = true,
className,
inputClassName,
suffixClassName,
}: {
alphaPercent: number
disabled?: boolean
onCommit: (percent: number) => void
scrubbable?: boolean
className?: string
inputClassName?: string
suffixClassName?: string
}) {
const inputRef = React.useRef<HTMLInputElement | null>(null)
const scrubRef = React.useRef<{
startX: number
startPercent: number
scrubbed: boolean
} | null>(null)
const rounded = Math.round(alphaPercent)
const scrubHandlers: React.HTMLAttributes<HTMLDivElement> = scrubbable
? {
onPointerDown: (event) => {
if (disabled) return
if (document.activeElement === inputRef.current) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
scrubRef.current = {
startX: event.clientX,
startPercent: alphaPercent,
scrubbed: false,
}
},
onPointerMove: (event) => {
const scrub = scrubRef.current
if (!scrub || (event.buttons & 1) === 0) return
const dx = event.clientX - scrub.startX
if (!scrub.scrubbed && Math.abs(dx) < 3) return
scrub.scrubbed = true
onCommit(clamp(scrub.startPercent + dx / 2, 0, 100))
},
onPointerUp: () => {
const scrub = scrubRef.current
scrubRef.current = null
if (scrub && !scrub.scrubbed) inputRef.current?.focus()
},
onPointerCancel: () => {
scrubRef.current = null
},
}
: {}
return (
<div
className={cn(
"flex h-full shrink-0 items-center gap-0.5",
scrubbable && "cursor-ew-resize touch-none select-none",
className
)}
{...scrubHandlers}
>
<DraftInput
aria-label="Opacity"
className={cn(
"border-0 bg-transparent p-0 text-right tabular-nums outline-none focus:border-0 focus:ring-0",
inputClassName ?? "w-8 text-base"
)}
disabled={disabled}
inputMode="decimal"
inputRef={inputRef}
value={String(rounded)}
onCommitText={(text) => {
const numeric = parseChannelText(text)
if (numeric !== null) onCommit(clamp(numeric, 0, 100))
}}
onStep={(direction, big) =>
onCommit(clamp(alphaPercent + direction * (big ? 10 : 1), 0, 100))
}
/>
<span
className={cn("text-muted-foreground", suffixClassName ?? "text-base")}
>
%
</span>
</div>
)
}
export type ColorPickerProps = {
value?: string
color?: string
onChange?: (value: string) => void
label?: string
disabled?: boolean
showAlpha?: boolean
defaultFormat?: ColorFormat
variant?: "input" | "swatch" | "panel"
className?: string
icon?: React.ComponentType<InlineRegistryIconProps>
rainbowTrigger?: boolean
onTriggerMouseDown?: React.MouseEventHandler<HTMLButtonElement>
onTriggerPointerDown?: React.PointerEventHandler<HTMLButtonElement>
}
export function ColorPicker({
value: valueProp,
color,
onChange,
label = "Choose color",
disabled = false,
showAlpha = false,
defaultFormat = "hex",
variant,
className,
icon: IconComponent,
rainbowTrigger = false,
onTriggerMouseDown,
onTriggerPointerDown,
}: ColorPickerProps) {
const value = valueProp ?? color ?? "#111827"
const resolvedVariant = variant ?? (color !== undefined ? "swatch" : "input")
const [open, setOpen] = React.useState(false)
// Keep drag coordinates independent from exact typed RGB values.
const [picker, setPicker] = React.useState<{
plane: HsvColor
rgb: RgbColor
a: number
}>(() => {
const parsed = parseColorInput(value, "hex")
if (!parsed)
return { plane: { h: 0, s: 0, v: 0 }, rgb: { r: 0, g: 0, b: 0 }, a: 1 }
return {
plane: rgbToOklchPlane(parsed.rgb),
rgb: parsed.rgb,
a: showAlpha ? (parsed.alpha ?? 1) : 1,
}
})
const [format, setFormat] = React.useState<ColorFormat>(defaultFormat)
const [controlledValue, setControlledValue] = React.useState({
value,
showAlpha,
pending: [] as string[],
})
const triggerGroupRef = React.useRef<HTMLDivElement>(null)
if (
controlledValue.value !== value ||
controlledValue.showAlpha !== showAlpha
) {
const parsed = value ? parseColorInput(value, "hex") : null
const alpha = showAlpha ? (parsed?.alpha ?? picker.a) : 1
const incoming = parsed ? rgbaToHex(parsed.rgb, alpha) : null
const echoIndex =
incoming === null ? -1 : controlledValue.pending.lastIndexOf(incoming)
setControlledValue({
value,
showAlpha,
pending:
echoIndex < 0 ? [] : controlledValue.pending.slice(echoIndex + 1),
})
if (
parsed &&
(echoIndex < 0 ||
echoIndex === controlledValue.pending.length - 1 ||
controlledValue.showAlpha !== showAlpha)
) {
if (rgbaToHex(picker.rgb, picker.a) !== incoming) {
const derived = rgbToOklchPlane(parsed.rgb, picker.plane.h)
setPicker({
plane: withHueContinuity(derived, picker.plane.h, derived.s / 100),
rgb: parsed.rgb,
a: alpha,
})
}
}
}
const applyState = (next: { plane: HsvColor; rgb: RgbColor; a: number }) => {
setPicker(next)
const hex = rgbaToHex(next.rgb, next.a)
if ((valueProp !== undefined || color !== undefined) && onChange) {
setControlledValue((current) => ({
...current,
pending: [...current.pending, hex],
}))
}
onChange?.(hex)
}
const commitHsva = (next: HsvaColor) => {
const plane: HsvColor = {
h: clamp(next.h, 0, 360),
s: clamp(next.s, 0, 100),
v: clamp(next.v, 0, 100),
}
applyState({
plane,
rgb: oklchPlaneToRgb(plane),
a: showAlpha ? clamp(next.a, 0, 1) : 1,
})
}
const hsva: HsvaColor = { ...picker.plane, a: picker.a }
const rgb = picker.rgb
// Preserve hue for achromatic colors.
const [srgbHue, setSrgbHue] = React.useState(0)
const displayHsv = rgbToHsv(rgb, srgbHue)
if (srgbHue !== displayHsv.h) setSrgbHue(displayHsv.h)
const hsl = hsvToHsl(displayHsv)
const rawOklch = rgbToOklch(rgb, hsva.h)
const oklch = withHueContinuity(rawOklch, hsva.h, rawOklch.c / 0.33)
const parts: ColorParts = { rgb, hsl, oklch, alpha: hsva.a }
const opaqueColor = `rgb(${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)})`
const currentColor = `rgb(${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)} / ${hsva.a})`
const commitRgb = (
nextRgb: RgbColor,
alpha?: number | null,
intendedHue?: number
) => {
const exact: RgbColor = {
r: clamp(nextRgb.r, 0, 255),
g: clamp(nextRgb.g, 0, 255),
b: clamp(nextRgb.b, 0, 255),
}
const targetHue = intendedHue ?? picker.plane.h
const derived = rgbToOklchPlane(exact, targetHue)
applyState({
plane: withHueContinuity(derived, targetHue, derived.s / 100),
rgb: exact,
a: showAlpha ? clamp(alpha ?? picker.a, 0, 1) : 1,
})
}
const commitHsl = (nextHsl: HslColor) => commitRgb(hslToRgb(nextHsl))
const commitOklch = (nextOklch: OklchColor) =>
commitRgb(oklchToRgb(nextOklch), undefined, normalizeHue(nextOklch.h))
const commitText = (text: string) => {
const parsed = parseColorInput(text, format)
if (!parsed) return
if (parsed.format && parsed.format !== format) setFormat(parsed.format)
commitRgb(parsed.rgb, parsed.alpha)
}
const cssColor = formatCssColor(format, parts, { includeAlpha: showAlpha })
const supportsEyeDropper =
typeof window !== "undefined" && window.EyeDropper !== undefined
const handleEyeDropper = () => {
const eyeDropperCtor = window.EyeDropper
if (!eyeDropperCtor) return
new eyeDropperCtor()
.open()
.then((result) => {
const parsed = parseColorInput(result.sRGBHex, "hex")
if (parsed) commitRgb(parsed.rgb)
})
.catch(() => {})
}
const oklchChannels: ChannelSpec[] = [
{
key: "l",
label: "L",
ariaLabel: "OKLCH lightness",
value: (oklch.l * 100).toFixed(1),
numericValue: oklch.l * 100,
step: 1,
bigStep: 10,
commit: (next) => commitOklch({ ...oklch, l: clamp(next, 0, 100) / 100 }),
},
{
key: "c",
label: "C",
ariaLabel: "OKLCH chroma",
value: oklch.c.toFixed(3),
numericValue: oklch.c,
step: 0.01,
bigStep: 0.05,
commit: (next) => commitOklch({ ...oklch, c: Math.max(0, next) }),
},
{
key: "h",
label: "H",
ariaLabel: "OKLCH hue",
value: oklch.h.toFixed(1),
numericValue: oklch.h,
step: 1,
bigStep: 10,
commit: (next) => commitOklch({ ...oklch, h: normalizeHue(next) }),
},
]
const hslChannels: ChannelSpec[] = [
{
key: "h",
label: "H",
ariaLabel: "HSL hue",
value: hsl.h.toFixed(1),
numericValue: hsl.h,
step: 1,
bigStep: 10,
commit: (next) => commitHsl({ ...hsl, h: normalizeHue(next) }),
},
{
key: "s",
label: "S",
ariaLabel: "HSL saturation",
value: hsl.s.toFixed(1),
numericValue: hsl.s,
step: 1,
bigStep: 10,
commit: (next) => commitHsl({ ...hsl, s: clamp(next, 0, 100) }),
},
{
key: "l",
label: "L",
ariaLabel: "HSL lightness",
value: hsl.l.toFixed(1),
numericValue: hsl.l,
step: 1,
bigStep: 10,
commit: (next) => commitHsl({ ...hsl, l: clamp(next, 0, 100) }),
},
]
const rgbChannels: ChannelSpec[] = (["r", "g", "b"] as const).map(
(channel) => ({
key: channel,
label: channel.toUpperCase(),
ariaLabel: { r: "Red", g: "Green", b: "Blue" }[channel],
value: String(Math.round(rgb[channel])),
numericValue: rgb[channel],
step: 1,
bigStep: 10,
commit: (next) => commitRgb({ ...rgb, [channel]: clamp(next, 0, 255) }),
})
)
const panel = (
<div
className={cn(
"flex items-stretch gap-3",
resolvedVariant === "panel" && className
)}
>
<SaturationValueArea
hue={hsva.h}
s={hsva.s}
v={hsva.v}
color={opaqueColor}
onMove={(s, v) => commitHsva({ ...hsva, s, v })}
/>
<div className="flex shrink-0 flex-col items-center gap-1.5 self-stretch">
<div className="flex min-h-0 flex-1 items-stretch gap-2">
{showAlpha && (
<VerticalSlider
ariaLabel="Opacity"
ariaValueMax={100}
ariaValueNow={Math.round(hsva.a * 100)}
ariaValueText={`${Math.round(hsva.a * 100)}%`}
fraction={1 - hsva.a}
onFraction={(fraction) =>
commitHsva({ ...hsva, a: 1 - fraction })
}
onStep={(direction, big) =>
commitHsva({
...hsva,
a: clamp(hsva.a + direction * (big ? 0.1 : 0.01), 0, 1),
})
}
track={
<>
<Checkerboard size={6} />
<span
className="absolute inset-0"
style={{
background: `linear-gradient(to bottom, ${opaqueColor}, transparent)`,
}}
/>
</>
}
/>
)}
<VerticalSlider
ariaLabel="Hue"
ariaValueMax={360}
ariaValueNow={Math.round(hsva.h)}
fraction={clamp(hsva.h, 0, 360) / 360}
onFraction={(fraction) =>
commitHsva({ ...hsva, h: fraction * 360 })
}
onStep={(direction, big) =>
commitHsva({
...hsva,
h: clamp(hsva.h - direction * (big ? 10 : 1), 0, 360),
})
}
track={
<span
className="absolute inset-0"
style={{ background: OK_HUE_TRACK }}
/>
}
/>
</div>
{supportsEyeDropper ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Pick color from screen"
className="size-8! shrink-0"
onClick={handleEyeDropper}
/>
}
>
<IconPlaceholder
lucide="Pipette"
tabler="IconColorPicker"
hugeicons="ColorPickerIcon"
phosphor="EyedropperIcon"
remixicon="RiDropperLine"
className="size-4"
/>
</TooltipTrigger>
<TooltipContent side="top" className="whitespace-nowrap">
Pick color from screen
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="flex w-64 shrink-0 flex-col gap-2">
<ChannelGroup
channels={oklchChannels}
copyValue={formatCssColor("oklch", parts, {
includeAlpha: showAlpha,
})}
disabled={disabled}
/>
<ChannelGroup
channels={hslChannels}
copyValue={formatCssColor("hsl", parts, { includeAlpha: showAlpha })}
disabled={disabled}
/>
<ChannelGroup
channels={rgbChannels}
copyValue={formatCssColor("rgb", parts, { includeAlpha: showAlpha })}
disabled={disabled}
/>
<div className="flex items-center gap-1.5">
<div
className={cn(
fieldSurfaceClass,
"flex h-8 min-w-0 flex-1 items-center"
)}
>
<DraftInput
aria-label="Color value"
className="h-full min-w-0 flex-1 rounded-[inherit] border-0 bg-transparent px-2 py-0 text-center text-sm leading-none tabular-nums outline-none focus:border-0 focus:ring-0"
copyValue={() => cssColor}
value={formatColorValue(format, parts)}
onCommitText={commitText}
/>
{showAlpha && (
<AlphaScrubField
alphaPercent={hsva.a * 100}
disabled={disabled}
scrubbable={false}
className="gap-0.5 pr-2 pl-0.5"
inputClassName="w-8 text-sm"
suffixClassName="text-sm"
onCommit={(percent) =>
commitHsva({ ...hsva, a: percent / 100 })
}
/>
)}
</div>
<Select
value={format}
onValueChange={(next) => {
if (
COLOR_FORMAT_OPTIONS.some((option) => option.value === next)
) {
setFormat(next as ColorFormat)
}
}}
>
<SelectTrigger
aria-label="Color format"
className="size-8! min-h-0 min-w-0 shrink-0 justify-center p-0 [&>[data-slot=select-icon]]:hidden [&>svg]:hidden"
>
<span className="flex items-center justify-center">
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelector"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiExpandUpDownLine"
className="size-3.5"
/>
</span>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="end"
className="min-w-28"
>
{COLOR_FORMAT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)
if (resolvedVariant === "panel") return panel
return (
<Popover open={open} onOpenChange={setOpen}>
{resolvedVariant === "swatch" ? (
<PopoverTrigger
render={
<Button
type="button"
variant={IconComponent || rainbowTrigger ? "ghost" : "outline"}
size="icon-sm"
aria-label={label}
disabled={disabled}
data-color-picker-trigger=""
className={cn("relative shrink-0", className)}
onMouseDown={onTriggerMouseDown}
onPointerDown={onTriggerPointerDown}
/>
}
>
{rainbowTrigger ? (
<span
aria-hidden="true"
className="size-5 shrink-0 rounded-full bg-[conic-gradient(from_90deg,#ff3b30,#ffcc00,#34c759,#00c7be,#007aff,#af52de,#ff2d55,#ff3b30)] shadow-[0_0_0_1px_rgb(0_0_0/10%)]"
/>
) : IconComponent ? (
<>
<IconComponent className="size-4" />
<span
aria-hidden="true"
className="absolute right-1 bottom-1 h-1 w-4 rounded-full border border-background"
style={{ backgroundColor: currentColor }}
/>
</>
) : (
<span className="relative size-4 shrink-0 overflow-hidden rounded-[4px]">
<Checkerboard size={4} />
<span
aria-hidden="true"
className="absolute inset-0"
style={{ backgroundColor: currentColor }}
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-[inherit] ring-1 ring-black/12 ring-inset dark:ring-white/16"
/>
</span>
)}
</PopoverTrigger>
) : (
<div
ref={triggerGroupRef}
data-color-picker-trigger=""
data-slot="color-picker"
className={cn(triggerGroupClass, className)}
>
<PopoverTrigger
render={
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn(
"relative ml-2 size-6 shrink-0 overflow-hidden rounded-[calc(var(--radius-md)-2px)]",
cossInputKeyboardFocusRing
)}
/>
}
>
<Checkerboard size={6} />
<span
aria-hidden="true"
className="absolute inset-0"
style={{ backgroundColor: currentColor }}
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-[inherit] ring-1 ring-black/12 ring-inset dark:ring-white/16"
/>
</PopoverTrigger>
<DraftInput
aria-label={`${label} value`}
className="h-full w-full min-w-0 grow border-0 bg-transparent px-[calc(--spacing(2.5)-1px)] text-base tabular-nums outline-none [transition:background-color_5000000s_ease-in-out_0s] placeholder:text-muted-foreground/72 focus:border-0 focus:ring-0"
copyValue={() => cssColor}
disabled={disabled}
value={formatColorValue(format, parts)}
onCommitText={commitText}
/>
{showAlpha && (
<AlphaScrubField
alphaPercent={hsva.a * 100}
disabled={disabled}
className="pr-[calc(--spacing(3)-1px)] pl-1"
onCommit={(percent) => commitHsva({ ...hsva, a: percent / 100 })}
/>
)}
</div>
)}
<InlineColorPopoverContent
anchor={resolvedVariant === "swatch" ? undefined : triggerGroupRef}
align={resolvedVariant === "swatch" ? "end" : "start"}
sideOffset={8}
className="w-auto p-0 [&>[data-slot=popover-viewport]]:p-3.5!"
>
{panel}
</InlineColorPopoverContent>
</Popover>
)
}
export function ColorPickerPanel({
className,
color,
label,
onChange,
showAlpha = false,
}: {
className?: string
color: string
label: string
onChange: (color: string) => void
showAlpha?: boolean
}) {
return (
<ColorPicker
className={className}
value={color}
label={label}
onChange={onChange}
showAlpha={showAlpha}
variant="panel"
/>
)
}
function InlineColorPopoverContent({
anchor,
align,
sideOffset,
className,
...props
}: InlinePopover.Popup.Props &
Pick<InlinePopover.Positioner.Props, "anchor" | "align" | "sideOffset">) {
return (
<InlinePopover.Portal>
<InlinePopover.Positioner
anchor={anchor}
align={align}
sideOffset={sideOffset}
className="z-50"
>
<InlinePopover.Popup
{...props}
className={cn(
"rounded-xl border bg-popover text-popover-foreground shadow-md outline-none",
className,
"p-3.5"
)}
/>
</InlinePopover.Positioner>
</InlinePopover.Portal>
)
}
type InlineRegistryIconProps = Omit<
React.ComponentProps<"svg">,
"children" | "strokeWidth"
> & { strokeWidth?: number }
lib/color-picker-utils.ts
export type ColorFormat = "oklch" | "hsl" | "rgb" | "hex"
/** 0-255 floats. */
export interface RgbColor {
r: number
g: number
b: number
}
/** h in 0-360, s/l in 0-100. */
export interface HslColor {
h: number
s: number
l: number
}
/** h in 0-360, s/v in 0-100. */
export interface HsvColor {
h: number
s: number
v: number
}
/** L: 0-1; C: >= 0; H: 0-360. */
export interface OklchColor {
l: number
c: number
h: number
}
export interface ParsedColor {
rgb: RgbColor
alpha: number | null
format: ColorFormat | null
}
export interface ColorParts {
rgb: RgbColor
hsl: HslColor
oklch: OklchColor
alpha: number
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function normalizeHue(value: number): number {
return ((value % 360) + 360) % 360
}
export function hsvToRgb({ h, s, v }: HsvColor): RgbColor {
const hue = normalizeHue(h) / 60
const sat = clamp(s, 0, 100) / 100
const val = clamp(v, 0, 100) / 100
const sector = Math.floor(hue) % 6
const f = hue - Math.floor(hue)
const p = val * (1 - sat)
const q = val * (1 - f * sat)
const t = val * (1 - (1 - f) * sat)
const pick: [number, number, number] =
sector === 0
? [val, t, p]
: sector === 1
? [q, val, p]
: sector === 2
? [p, val, t]
: sector === 3
? [p, q, val]
: sector === 4
? [t, p, val]
: [val, p, q]
return { r: pick[0] * 255, g: pick[1] * 255, b: pick[2] * 255 }
}
export function rgbToHsv(rgb: RgbColor, fallbackHue = 0): HsvColor {
const r = clamp(rgb.r, 0, 255) / 255
const g = clamp(rgb.g, 0, 255) / 255
const b = clamp(rgb.b, 0, 255) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const v = max * 100
const s = max === 0 ? 0 : (delta / max) * 100
if (delta === 0) return { h: normalizeHue(fallbackHue), s, v }
let h: number
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0)
else if (max === g) h = (b - r) / delta + 2
else h = (r - g) / delta + 4
return { h: normalizeHue(h * 60), s, v }
}
export function hsvToHsl({ h, s, v }: HsvColor): HslColor {
const sat = clamp(s, 0, 100) / 100
const val = clamp(v, 0, 100) / 100
const l = val * (1 - sat / 2)
const hslSat = l === 0 || l === 1 ? 0 : (val - l) / Math.min(l, 1 - l)
return {
h: normalizeHue(h),
s: clamp(hslSat * 100, 0, 100),
l: clamp(l * 100, 0, 100),
}
}
export function hslToHsv({ h, s, l }: HslColor): HsvColor {
const sat = clamp(s, 0, 100) / 100
const light = clamp(l, 0, 100) / 100
const v = light + sat * Math.min(light, 1 - light)
const hsvSat = v === 0 ? 0 : 2 * (1 - light / v)
return {
h: normalizeHue(h),
s: clamp(hsvSat * 100, 0, 100),
v: clamp(v * 100, 0, 100),
}
}
export function hslToRgb(hsl: HslColor): RgbColor {
return hsvToRgb(hslToHsv(hsl))
}
function srgbToLinear(channel: number): number {
const c = channel / 255
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
function linearToSrgb(channel: number): number {
const c =
channel <= 0.0031308
? channel * 12.92
: 1.055 * Math.pow(channel, 1 / 2.4) - 0.055
return c * 255
}
interface OklabColor {
l: number
a: number
b: number
}
function rgbToOklab(rgb: RgbColor): OklabColor {
const r = srgbToLinear(clamp(rgb.r, 0, 255))
const g = srgbToLinear(clamp(rgb.g, 0, 255))
const b = srgbToLinear(clamp(rgb.b, 0, 255))
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return {
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
}
}
function oklchToOklab({ l, c, h }: OklchColor): OklabColor {
const hueRad = (normalizeHue(h) * Math.PI) / 180
return { l, a: c * Math.cos(hueRad), b: c * Math.sin(hueRad) }
}
export function rgbToOklch(rgb: RgbColor, fallbackHue = 0): OklchColor {
const lab = rgbToOklab(rgb)
const c = Math.sqrt(lab.a * lab.a + lab.b * lab.b)
if (c < 1e-6)
return { l: clamp(lab.l, 0, 1), c: 0, h: normalizeHue(fallbackHue) }
return {
l: clamp(lab.l, 0, 1),
c,
h: normalizeHue((Math.atan2(lab.b, lab.a) * 180) / Math.PI),
}
}
function oklabToLinearSrgb({ l, a, b }: OklabColor): {
r: number
g: number
b: number
} {
const lp = l + 0.3963377774 * a + 0.2158037573 * b
const mp = l - 0.1055613458 * a - 0.0638541728 * b
const sp = l - 0.0894841775 * a - 1.291485548 * b
const lc = lp * lp * lp
const mc = mp * mp * mp
const sc = sp * sp * sp
return {
r: 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
g: -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
b: -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc,
}
}
function oklabToRgbRaw(lab: OklabColor): RgbColor {
const linear = oklabToLinearSrgb(lab)
return {
r: linearToSrgb(linear.r),
g: linearToSrgb(linear.g),
b: linearToSrgb(linear.b),
}
}
function oklchToRgbRaw(oklch: OklchColor): RgbColor {
return oklabToRgbRaw(oklchToOklab(oklch))
}
function isInSrgbGamut(rgb: RgbColor): boolean {
const eps = 0.001
return (
rgb.r >= -eps &&
rgb.r <= 255 + eps &&
rgb.g >= -eps &&
rgb.g <= 255 + eps &&
rgb.b >= -eps &&
rgb.b <= 255 + eps
)
}
function clampRgb(rgb: RgbColor): RgbColor {
return {
r: clamp(rgb.r, 0, 255),
g: clamp(rgb.g, 0, 255),
b: clamp(rgb.b, 0, 255),
}
}
function deltaEok(first: OklabColor, second: OklabColor): number {
const dl = first.l - second.l
const da = first.a - second.a
const db = first.b - second.b
return Math.sqrt(dl * dl + da * da + db * db)
}
/** CSS Color 4 local-MINDE gamut mapping. */
export function oklchToRgb(oklch: OklchColor): RgbColor {
const target: OklchColor = {
l: clamp(oklch.l, 0, 1),
c: Math.max(0, oklch.c),
h: oklch.h,
}
if (target.l >= 1) return { r: 255, g: 255, b: 255 }
if (target.l <= 0) return { r: 0, g: 0, b: 0 }
const raw = oklchToRgbRaw(target)
if (isInSrgbGamut(raw)) return clampRgb(raw)
const jnd = 0.02
const epsilon = 0.0001
let clipped = clampRgb(raw)
if (deltaEok(rgbToOklab(clipped), oklchToOklab(target)) < jnd) return clipped
let min = 0
let max = target.c
let minInGamut = true
while (max - min > epsilon) {
const chroma = (min + max) / 2
const candidate: OklchColor = { ...target, c: chroma }
const candidateRaw = oklchToRgbRaw(candidate)
if (minInGamut && isInSrgbGamut(candidateRaw)) {
min = chroma
continue
}
clipped = clampRgb(candidateRaw)
const distance = deltaEok(rgbToOklab(clipped), oklchToOklab(candidate))
if (distance < jnd) {
if (jnd - distance < epsilon) return clipped
minInGamut = false
min = chroma
} else {
max = chroma
}
}
return clipped
}
// OKHSV cusp math: https://bottosson.github.io/posts/colorpicker/
function computeMaxSaturation(a: number, b: number): number {
let k0: number, k1: number, k2: number, k3: number, k4: number
let wl: number, wm: number, ws: number
if (-1.88170328 * a - 0.80936493 * b > 1) {
k0 = 1.19086277
k1 = 1.76576728
k2 = 0.59662641
k3 = 0.75515197
k4 = 0.56771245
wl = 4.0767416621
wm = -3.3077115913
ws = 0.2309699292
} else if (1.81444104 * a - 1.19445276 * b > 1) {
k0 = 0.73956515
k1 = -0.45954404
k2 = 0.08285427
k3 = 0.1254107
k4 = 0.14503204
wl = -1.2684380046
wm = 2.6097574011
ws = -0.3413193965
} else {
k0 = 1.35733652
k1 = -0.00915799
k2 = -1.1513021
k3 = -0.50559606
k4 = 0.00692167
wl = -0.0041960863
wm = -0.7034186147
ws = 1.707614701
}
let s = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b
// Refine the gamut boundary.
const kl = 0.3963377774 * a + 0.2158037573 * b
const km = -0.1055613458 * a - 0.0638541728 * b
const ks = -0.0894841775 * a - 1.291485548 * b
const lp = 1 + s * kl
const mp = 1 + s * km
const sp = 1 + s * ks
const l = lp * lp * lp
const m = mp * mp * mp
const s3 = sp * sp * sp
const lds = 3 * kl * lp * lp
const mds = 3 * km * mp * mp
const sds = 3 * ks * sp * sp
const lds2 = 6 * kl * kl * lp
const mds2 = 6 * km * km * mp
const sds2 = 6 * ks * ks * sp
const f = wl * l + wm * m + ws * s3
const f1 = wl * lds + wm * mds + ws * sds
const f2 = wl * lds2 + wm * mds2 + ws * sds2
s = s - (f * f1) / (f1 * f1 - 0.5 * f * f2)
return s
}
function findCusp(a: number, b: number): { l: number; c: number } {
const sCusp = computeMaxSaturation(a, b)
const rgbAtMax = oklabToLinearSrgb({ l: 1, a: sCusp * a, b: sCusp * b })
const lCusp = Math.cbrt(1 / Math.max(rgbAtMax.r, rgbAtMax.g, rgbAtMax.b))
return { l: lCusp, c: lCusp * sCusp }
}
export function maxChromaForHue(hue: number): number {
const hueRad = (normalizeHue(hue) * Math.PI) / 180
return findCusp(Math.cos(hueRad), Math.sin(hueRad)).c
}
export function cuspColorForHue(hue: number): RgbColor {
const hueRad = (normalizeHue(hue) * Math.PI) / 180
const aUnit = Math.cos(hueRad)
const bUnit = Math.sin(hueRad)
const cusp = findCusp(aUnit, bUnit)
return clampRgb(
oklabToRgbRaw({ l: cusp.l, a: cusp.c * aUnit, b: cusp.c * bUnit })
)
}
// Lift saturated dark colors above black.
const PLANE_MIN_L = 0.25
function planeLightnessFloor(chromaFraction: number): number {
return PLANE_MIN_L * chromaFraction
}
export function oklchPlaneToRgb({ h, s, v }: HsvColor): RgbColor {
const s01 = clamp(s, 0, 100) / 100
const floor = planeLightnessFloor(s01)
const lightness = floor + (clamp(v, 0, 100) / 100) * (1 - floor)
return clampRgb(
oklchToRgbRaw({ l: lightness, c: s01 * maxChromaForHue(h), h })
)
}
export function rgbToOklchPlane(rgb: RgbColor, fallbackHue = 0): HsvColor {
const oklch = rgbToOklch(rgb, fallbackHue)
// Preserve hue below the reliable chroma threshold.
const hue = oklch.c < 0.004 ? normalizeHue(fallbackHue) : oklch.h
const cMax = maxChromaForHue(hue)
const s01 = cMax > 0 ? clamp(oklch.c / cMax, 0, 1) : 0
const floor = planeLightnessFloor(s01)
return {
h: hue,
s: s01 * 100,
v: clamp((oklch.l - floor) / (1 - floor), 0, 1) * 100,
}
}
/** RGBA pixels for the picker plane. */
export function getOklchPlanePixels(
width: number,
height: number,
hue: number
): Uint8ClampedArray {
const data = new Uint8ClampedArray(width * height * 4)
const hueRad = (normalizeHue(hue) * Math.PI) / 180
const aUnit = Math.cos(hueRad)
const bUnit = Math.sin(hueRad)
const cMax = findCusp(aUnit, bUnit).c
for (let x = 0; x < width; x += 1) {
const s01 = width === 1 ? 0 : x / (width - 1)
const chroma = s01 * cMax
const aChroma = chroma * aUnit
const bChroma = chroma * bUnit
const floor = planeLightnessFloor(s01)
for (let y = 0; y < height; y += 1) {
const v01 = height === 1 ? 1 : 1 - y / (height - 1)
const linear = oklabToLinearSrgb({
l: floor + v01 * (1 - floor),
a: aChroma,
b: bChroma,
})
const i = (y * width + x) * 4
data[i] = linearToSrgb(linear.r)
data[i + 1] = linearToSrgb(linear.g)
data[i + 2] = linearToSrgb(linear.b)
data[i + 3] = 255
}
}
return data
}
export function formatNumber(value: number, maxDecimals: number): string {
const fixed = value.toFixed(maxDecimals)
if (!fixed.includes(".")) return fixed
const trimmed = fixed.replace(/0+$/, "").replace(/\.$/, "")
return trimmed === "-0" ? "0" : trimmed
}
function toHexPair(channel: number): string {
return clamp(Math.round(channel), 0, 255)
.toString(16)
.padStart(2, "0")
.toUpperCase()
}
export function rgbaToHex(rgb: RgbColor, alpha = 1): string {
const base = `#${toHexPair(rgb.r)}${toHexPair(rgb.g)}${toHexPair(rgb.b)}`
return alpha >= 1 ? base : `${base}${toHexPair(alpha * 255)}`
}
export function formatColorValue(
format: ColorFormat,
parts: ColorParts
): string {
const { rgb, hsl, oklch } = parts
switch (format) {
case "hex":
return rgbaToHex(rgb).slice(1)
case "rgb":
return `${Math.round(clamp(rgb.r, 0, 255))} ${Math.round(clamp(rgb.g, 0, 255))} ${Math.round(
clamp(rgb.b, 0, 255)
)}`
case "hsl":
return `${formatNumber(hsl.h, 1)}° ${formatNumber(hsl.s, 1)}% ${formatNumber(hsl.l, 1)}%`
case "oklch":
return `${formatNumber(oklch.l * 100, 1)}% ${formatNumber(oklch.c, 3)} ${formatNumber(oklch.h, 1)}`
}
}
export function formatCssColor(
format: ColorFormat,
parts: ColorParts,
options?: { includeAlpha?: boolean }
): string {
const includeAlpha = (options?.includeAlpha ?? true) && parts.alpha < 1
const alphaNumber = formatNumber(clamp(parts.alpha, 0, 1), 3)
switch (format) {
case "hex":
return rgbaToHex(parts.rgb, includeAlpha ? parts.alpha : 1)
case "rgb": {
const channels = `${Math.round(clamp(parts.rgb.r, 0, 255))}, ${Math.round(
clamp(parts.rgb.g, 0, 255)
)}, ${Math.round(clamp(parts.rgb.b, 0, 255))}`
return includeAlpha
? `rgba(${channels}, ${alphaNumber})`
: `rgb(${channels})`
}
case "hsl": {
const channels = `${formatNumber(parts.hsl.h, 2)}, ${formatNumber(parts.hsl.s, 2)}%, ${formatNumber(
parts.hsl.l,
2
)}%`
return includeAlpha
? `hsla(${channels}, ${alphaNumber})`
: `hsl(${channels})`
}
case "oklch": {
const channels = `${formatNumber(parts.oklch.l * 100, 2)}% ${formatNumber(parts.oklch.c, 4)} ${formatNumber(
parts.oklch.h,
2
)}`
return includeAlpha
? `oklch(${channels} / ${alphaNumber})`
: `oklch(${channels})`
}
}
}
interface NumericToken {
value: number
percent: boolean
}
function parseNumericToken(token: string): NumericToken | null {
const match = /^([+-]?\d*\.?\d+)(%|deg|°)?$/i.exec(token.trim())
if (!match) return null
const value = Number.parseFloat(match[1] ?? "")
if (!Number.isFinite(value)) return null
return { value, percent: match[2] === "%" }
}
function parseAlphaToken(token: string): number | null {
const parsed = parseNumericToken(token)
if (!parsed) return null
const value =
parsed.percent || parsed.value > 1 ? parsed.value / 100 : parsed.value
return clamp(value, 0, 1)
}
function rgbFromTokens(
tokens: NumericToken[],
allowUnitScale: boolean
): RgbColor | null {
if (tokens.length !== 3) return null
const scaleUnit =
allowUnitScale &&
tokens.every(
(token) => !token.percent && token.value >= 0 && token.value <= 1
)
const channels = tokens.map((token) => {
if (token.percent) return (token.value / 100) * 255
return scaleUnit ? token.value * 255 : token.value
})
return clampRgb({
r: channels[0] ?? 0,
g: channels[1] ?? 0,
b: channels[2] ?? 0,
})
}
function hslFromTokens(tokens: NumericToken[]): RgbColor | null {
if (tokens.length !== 3) return null
return hslToRgb({
h: normalizeHue(tokens[0]?.value ?? 0),
s: clamp(tokens[1]?.value ?? 0, 0, 100),
l: clamp(tokens[2]?.value ?? 0, 0, 100),
})
}
function oklchFromTokens(tokens: NumericToken[]): RgbColor | null {
if (tokens.length !== 3) return null
const [lToken, cToken, hToken] = tokens
if (!lToken || !cToken || !hToken) return null
const l =
lToken.percent || lToken.value > 1 ? lToken.value / 100 : lToken.value
const c = cToken.percent ? (cToken.value / 100) * 0.4 : cToken.value
return oklchToRgb({
l: clamp(l, 0, 1),
c: Math.max(0, c),
h: normalizeHue(hToken.value),
})
}
function splitAlpha(
body: string
): { main: string; alpha: number | null } | null {
const slashParts = body.split("/")
if (slashParts.length > 2) return null
if (slashParts.length === 2) {
const alpha = parseAlphaToken(slashParts[1] ?? "")
if (alpha === null) return null
return { main: slashParts[0] ?? "", alpha }
}
return { main: body, alpha: null }
}
function tokenize(main: string): NumericToken[] | null {
const rawTokens = main
.split(/[\s,]+/)
.map((token) => token.trim())
.filter(Boolean)
const tokens: NumericToken[] = []
for (const raw of rawTokens) {
const parsed = parseNumericToken(raw)
if (!parsed) return null
tokens.push(parsed)
}
return tokens
}
function parseHexBody(raw: string): ParsedColor | null {
const body = raw.startsWith("#") ? raw.slice(1) : raw
if (!/^[0-9a-f]+$/i.test(body)) return null
let expanded: string
if (body.length === 3 || body.length === 4) {
expanded = body
.split("")
.map((char) => char + char)
.join("")
} else if (body.length === 6 || body.length === 8) {
expanded = body
} else {
return null
}
const channels: number[] = []
for (let i = 0; i < expanded.length; i += 2) {
channels.push(Number.parseInt(expanded.slice(i, i + 2), 16))
}
return {
rgb: { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 },
alpha: channels.length === 4 ? (channels[3] ?? 255) / 255 : null,
format: "hex",
}
}
function parseFunctional(name: string, body: string): ParsedColor | null {
const withAlpha = splitAlpha(body)
if (!withAlpha) return null
const tokens = tokenize(withAlpha.main)
if (!tokens) return null
let alpha = withAlpha.alpha
let componentTokens = tokens
if (tokens.length === 4 && alpha === null) {
componentTokens = tokens.slice(0, 3)
const last = tokens[3]
if (!last) return null
alpha = clamp(last.percent ? last.value / 100 : last.value, 0, 1)
}
let rgb: RgbColor | null = null
let format: ColorFormat | null = null
if (name === "rgb" || name === "rgba") {
rgb = rgbFromTokens(componentTokens, false)
format = "rgb"
} else if (name === "hsl" || name === "hsla") {
rgb = hslFromTokens(componentTokens)
format = "hsl"
} else if (name === "oklch") {
rgb = oklchFromTokens(componentTokens)
format = "oklch"
}
return rgb ? { rgb, alpha, format } : null
}
let cssParserContext: CanvasRenderingContext2D | null | undefined
function parseWithBrowser(raw: string): ParsedColor | null {
if (typeof document === "undefined") return null
if (cssParserContext === undefined) {
try {
cssParserContext = document.createElement("canvas").getContext("2d")
} catch {
cssParserContext = null
}
}
if (!cssParserContext) return null
const sentinels = ["#010203", "#030201"]
let normalized: string | null = null
for (const sentinel of sentinels) {
cssParserContext.fillStyle = sentinel
cssParserContext.fillStyle = raw
const result = cssParserContext.fillStyle
if (typeof result !== "string" || result === sentinel) return null
normalized = result
}
if (!normalized) return null
// Browser fallback should not change the selected format.
if (normalized.startsWith("#")) {
const hex = parseHexBody(normalized)
return hex ? { ...hex, format: null } : null
}
const functional = /^(rgba?)\(\s*(.+?)\s*\)$/i.exec(normalized)
if (functional) {
const parsed = parseFunctional(
(functional[1] ?? "").toLowerCase(),
functional[2] ?? ""
)
return parsed ? { ...parsed, format: null } : null
}
return null
}
/** Parses supported color syntax, then falls back to the browser parser. */
export function parseColorInput(
input: string,
assumeFormat: ColorFormat = "hex"
): ParsedColor | null {
const raw = input.trim()
if (!raw) return null
const functional = /^([a-z]+)\(\s*(.+?)\s*\)$/i.exec(raw)
if (functional) {
const name = (functional[1] ?? "").toLowerCase()
if (["rgb", "rgba", "hsl", "hsla", "oklch"].includes(name)) {
return parseFunctional(name, functional[2] ?? "")
}
return parseWithBrowser(raw)
}
const withAlpha = splitAlpha(raw)
if (withAlpha) {
const main = withAlpha.main.trim()
if (/^#?[0-9a-f]{3,8}$/i.test(main)) {
const hex = parseHexBody(main)
if (hex)
return {
rgb: hex.rgb,
alpha: withAlpha.alpha ?? hex.alpha,
format: "hex",
}
}
const tokens = tokenize(main)
if (tokens && tokens.length === 3) {
let rgb: RgbColor | null = null
if (assumeFormat === "hsl") rgb = hslFromTokens(tokens)
else if (assumeFormat === "oklch") rgb = oklchFromTokens(tokens)
else rgb = rgbFromTokens(tokens, true)
if (rgb) return { rgb, alpha: withAlpha.alpha, format: null }
}
}
return parseWithBrowser(raw)
}
export function rgbTupleToHex(tuple: [number, number, number]): string {
return rgbaToHex({ r: tuple[0], g: tuple[1], b: tuple[2] })
}
export function hexToRgbTuple(value: string): [number, number, number] | null {
const parsed = parseColorInput(value, "hex")
if (!parsed) return null
return [
Math.round(parsed.rgb.r),
Math.round(parsed.rgb.g),
Math.round(parsed.rgb.b),
]
}