diff --git a/apps/www/src/app/examples/color-picker/page.tsx b/apps/www/src/app/examples/color-picker/page.tsx
new file mode 100644
index 000000000..3c31891f0
--- /dev/null
+++ b/apps/www/src/app/examples/color-picker/page.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { Button, ColorPicker, Flex, Popover, Text } from '@raystack/apsara';
+import { useState } from 'react';
+
+const cardStyle = {
+ width: 280,
+ padding: 16,
+ borderRadius: 8,
+ background: 'var(--rs-color-background-base-primary)',
+ border: '1px solid var(--rs-color-border-base-primary)'
+} as const;
+
+export default function ColorPickerExamplesPage() {
+ const [controlledValue, setControlledValue] = useState(
+ 'oklch(0.5438 0.191 267.01)'
+ );
+ const [controlledMode, setControlledMode] = useState<
+ 'hex' | 'rgb' | 'hsl' | 'oklch'
+ >('oklch');
+ const [popoverColor, setPopoverColor] = useState('#DA2929');
+
+ return (
+
+
+
+ ColorPicker
+
+
+ The picker supports hex, rgb, hsl, and oklch as input and output
+ formats. Internal sliders operate in HSL; oklch is a serialization
+ mode, not a perceptual editing mode.
+
+
+
+
+
+
+ Default (hex)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OKLCH mode
+
+
+ defaultValue='oklch(0.5438 0.191 267.01)'{' '}
+ with defaultMode='oklch'.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Controlled — emits live value
+
+ {
+ setControlledValue(value);
+ setControlledMode(mode as typeof controlledMode);
+ }}
+ onModeChange={mode =>
+ setControlledMode(mode as typeof controlledMode)
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ value:
+
+
+ {controlledValue}
+
+
+ mode: {controlledMode}
+
+
+
+
+
+
+ Popover trigger
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {popoverColor}
+
+
+
+
+ );
+}
diff --git a/apps/www/src/content/docs/components/color-picker/demo.ts b/apps/www/src/content/docs/components/color-picker/demo.ts
index 37e72e9bd..894c1e713 100644
--- a/apps/www/src/content/docs/components/color-picker/demo.ts
+++ b/apps/www/src/content/docs/components/color-picker/demo.ts
@@ -36,6 +36,28 @@ export const basicDemo = {
`
};
+export const oklchDemo = {
+ type: 'code',
+ code: `
+
+
+
+
+
+
+
+`
+};
+
export const popoverDemo = {
type: 'code',
previewCode: false,
diff --git a/apps/www/src/content/docs/components/color-picker/index.mdx b/apps/www/src/content/docs/components/color-picker/index.mdx
index 9722535ce..6e4a54551 100644
--- a/apps/www/src/content/docs/components/color-picker/index.mdx
+++ b/apps/www/src/content/docs/components/color-picker/index.mdx
@@ -7,6 +7,7 @@ source: packages/raystack/components/color-picker
import {
preview,
basicDemo,
+ oklchDemo,
popoverDemo,
} from "./demo.ts";
@@ -50,7 +51,7 @@ Provides a slider for selecting the alpha value of the color.
### Mode
-Lets users switch between different color models (e.g., HEX, RGB, HSL) via a dropdown menu.
+Lets users switch between different color models (HEX, RGB, HSL, OKLCH) via a dropdown menu.
@@ -64,6 +65,14 @@ Displays the current color value in the selected color model and allows direct t
+### OKLCH Mode
+
+The picker accepts and emits `oklch()` values. Pass an `oklch(...)` string as `defaultValue` and set `defaultMode='oklch'` to have the input render and emit oklch.
+
+Note that the picker's sliders still operate in HSL — `oklch` here is an input/output format, not a perceptual editing mode. The emitted oklch value is the result of an HSL → sRGB → OKLCH conversion, so a value-in will not be bit-identical to the value-out.
+
+
+
### Popover Integration
The `ColorPicker` can be embedded within a `Popover` component to create a more interactive and space-efficient color selection experience.
diff --git a/apps/www/src/content/docs/components/color-picker/props.ts b/apps/www/src/content/docs/components/color-picker/props.ts
index 7a399ebde..f67fdcc4b 100644
--- a/apps/www/src/content/docs/components/color-picker/props.ts
+++ b/apps/www/src/content/docs/components/color-picker/props.ts
@@ -3,7 +3,7 @@
*/
export interface ColorPickerProps {
/**
- * The controlled color value. Accepts hex, rgb, hsl, etc.
+ * The controlled color value. Accepts hex, rgb, hsl, oklch, etc.
*/
value?: string;
/**
@@ -16,14 +16,14 @@ export interface ColorPickerProps {
*/
onValueChange?: (value: string, mode: string) => void;
/**
- * The initial color mode (hex, rgb, hsl).
+ * The initial color mode (hex, rgb, hsl, oklch).
* @default 'hex'
*/
- defaultMode?: 'hex' | 'rgb' | 'hsl';
+ defaultMode?: 'hex' | 'rgb' | 'hsl' | 'oklch';
/**
* The controlled color mode.
*/
- mode?: 'hex' | 'rgb' | 'hsl';
+ mode?: 'hex' | 'rgb' | 'hsl' | 'oklch';
/**
* Callback fired when the color mode changes.
*/
@@ -36,7 +36,7 @@ export interface ColorPickerProps {
export interface ColorPickerModeProps {
/**
* Supported color modes for the picker.
- * @default ['hex', 'rgb', 'hsl']
+ * @default ['hex', 'rgb', 'hsl', 'oklch']
*/
- options?: Array<'hex' | 'rgb' | 'hsl'>;
+ options?: Array<'hex' | 'rgb' | 'hsl' | 'oklch'>;
}
diff --git a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx
index 5d685f2a5..a2c8020d3 100644
--- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx
+++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx
@@ -202,6 +202,42 @@ describe('ColorPicker', () => {
input = screen.getByTestId('color-input');
expect(input).toHaveValue('#00FF00');
});
+
+ it('accepts oklch input', () => {
+ render(
+
+
+
+ );
+ const input = screen.getByTestId('color-input');
+ // Should render *some* hex value without throwing; exact bytes depend on
+ // HSL round-trip so we only assert shape.
+ expect((input as HTMLInputElement).value).toMatch(/^#[0-9A-F]{6}$/);
+ });
+
+ it('emits oklch when mode is oklch', () => {
+ render(
+
+
+
+ );
+ const input = screen.getByTestId('color-input');
+ expect((input as HTMLInputElement).value).toMatch(
+ /^oklch\([\d.]+ [\d.]+ [\d.]+\)$/
+ );
+ });
+
+ it('emits oklch with alpha tail when alpha < 1', () => {
+ render(
+
+
+
+ );
+ const input = screen.getByTestId('color-input');
+ expect((input as HTMLInputElement).value).toMatch(
+ /^oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)$/
+ );
+ });
});
describe('ColorPicker.Mode', () => {
diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx
index 450db0599..e1c35c293 100644
--- a/packages/raystack/components/color-picker/color-picker-area.tsx
+++ b/packages/raystack/components/color-picker/color-picker-area.tsx
@@ -1,7 +1,6 @@
'use client';
import { cx } from 'class-variance-authority';
-import Color from 'color';
import {
ComponentProps,
PointerEvent as ReactPointerEvent,
@@ -12,6 +11,7 @@ import {
} from 'react';
import styles from './color-picker.module.css';
import { useColorPicker } from './color-picker-root';
+import { getColorString } from './utils';
export type ColorPickerAreaProps = ComponentProps<'div'>;
@@ -25,7 +25,10 @@ export const ColorPickerArea = ({
const isThumbVisible = useRef(false);
const { hue, saturation, lightness, setColor } = useColorPicker();
- const color = Color.hsl(hue, saturation, lightness);
+ const thumbColor = getColorString(
+ { h: hue, s: saturation, l: lightness, alpha: 1 },
+ 'hex'
+ );
const backgroundGradient = useMemo(() => {
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
@@ -107,7 +110,7 @@ export const ColorPickerArea = ({
className={cx(styles.sliderThumb, styles.selectionThumb)}
ref={thumbRef}
style={{
- background: color.hex().toString(),
+ background: thumbColor,
opacity: 0
}}
/>
diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx
index 79ba97e21..4541e06f5 100644
--- a/packages/raystack/components/color-picker/color-picker-input.tsx
+++ b/packages/raystack/components/color-picker/color-picker-input.tsx
@@ -1,6 +1,5 @@
'use client';
-import Color from 'color';
import { ComponentProps } from 'react';
import { Input } from '../input';
import { useColorPicker } from './color-picker-root';
@@ -8,9 +7,12 @@ import { getColorString } from './utils';
export const ColorPickerInput = (props: ComponentProps) => {
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
- const color = Color.hsl(hue, saturation, lightness, alpha ?? 1);
+ const value = getColorString(
+ { h: hue, s: saturation, l: lightness, alpha: alpha ?? 1 },
+ mode
+ );
- return ;
+ return ;
};
ColorPickerInput.displayName = 'ColorPicker.Input';
diff --git a/packages/raystack/components/color-picker/color-picker-mode.tsx b/packages/raystack/components/color-picker/color-picker-mode.tsx
index 4ba509ee5..b178c8faf 100644
--- a/packages/raystack/components/color-picker/color-picker-mode.tsx
+++ b/packages/raystack/components/color-picker/color-picker-mode.tsx
@@ -9,7 +9,7 @@ import { ModeType, SUPPORTED_MODES } from './utils';
export interface ColorPickerModeProps
extends ComponentProps {
- options?: ModeType[];
+ options?: readonly ModeType[];
}
export const ColorPickerMode = ({
diff --git a/packages/raystack/components/color-picker/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx
index 97c59b4d2..6cc9f2536 100644
--- a/packages/raystack/components/color-picker/color-picker-root.tsx
+++ b/packages/raystack/components/color-picker/color-picker-root.tsx
@@ -1,6 +1,5 @@
'use client';
-import Color, { type ColorLike } from 'color';
import {
ComponentProps,
createContext,
@@ -9,7 +8,7 @@ import {
useState
} from 'react';
import { Flex } from '../flex';
-import { ColorObject, getColorString, ModeType } from './utils';
+import { ColorObject, getColorString, ModeType, parseColor } from './utils';
type ColorPickerContextValue = {
hue: number;
@@ -35,8 +34,8 @@ export const useColorPicker = () => {
export interface ColorPickerProps
extends Omit, 'defaultValue'> {
- value?: ColorLike;
- defaultValue?: ColorLike;
+ value?: string;
+ defaultValue?: string;
onValueChange?: (value: string, mode: string) => void;
defaultMode?: ModeType;
mode?: ModeType;
@@ -51,11 +50,9 @@ export const ColorPickerRoot = ({
onModeChange,
...props
}: ColorPickerProps) => {
- const providedColor = value && (Color(value).hsl().object() as ColorObject);
+ const providedColor = value ? parseColor(value) : undefined;
- const [internalColor, setInternalColor] = useState(
- Color(defaultValue).hsl().object() as ColorObject
- );
+ const [internalColor, setInternalColor] = useState(parseColor(defaultValue));
const [internalMode, setInternalMode] = useState(defaultMode);
const hue = providedColor ? providedColor.h : internalColor.h;
@@ -73,14 +70,7 @@ export const ColorPickerRoot = ({
if (!onValueChange) return updatedColor;
- const color = Color.hsl(
- updatedColor.h,
- updatedColor.s,
- updatedColor.l,
- updatedColor?.alpha ?? 1
- );
-
- onValueChange(getColorString(color, mode), mode);
+ onValueChange(getColorString(updatedColor, mode), mode);
return updatedColor;
});
diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts
index fe7a6d696..be88e9e38 100644
--- a/packages/raystack/components/color-picker/utils.ts
+++ b/packages/raystack/components/color-picker/utils.ts
@@ -1,17 +1,13 @@
-import { type ColorInstance } from 'color';
+import {
+ converter,
+ formatHex,
+ formatHex8,
+ formatHsl,
+ formatRgb,
+ parse
+} from 'culori';
-export const getColorString = (color: ColorInstance, mode: string) => {
- let string;
- if (mode === 'hex')
- string =
- color.alpha() === 1 ? color.hex().toString() : color.hexa().toString();
- else if (mode === 'hsl') string = color.hsl().toString();
- else string = color.rgb().toString();
-
- return string;
-};
-
-export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb'];
+export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch'] as const;
export type ModeType = (typeof SUPPORTED_MODES)[number];
@@ -21,3 +17,82 @@ export type ColorObject = {
l: number;
alpha?: number;
};
+
+const toHsl = converter('hsl');
+const toOklch = converter('oklch');
+
+// culori stores HSL with s/l in 0-1; the picker stores them in 0-100 to keep
+// the existing context contract intact.
+const HSL_PERCENT = 100;
+
+const FALLBACK: ColorObject = { h: 0, s: 0, l: 100, alpha: 1 };
+
+const round = (n: number, p: number) => Number.parseFloat(n.toFixed(p));
+
+/**
+ * Serializes a culori-shaped HSL color as oklch(L C H[ / A]).
+ * Matches the design system's token format: 4-decimal L/C, 2-decimal H,
+ * H pinned to 0 for achromatic colors (culori would emit `none` per CSS
+ * Color 4, which is correct but inconsistent with how tokens are written).
+ */
+const formatOklch = (hsl: {
+ mode: 'hsl';
+ h: number;
+ s: number;
+ l: number;
+ alpha: number;
+}): string => {
+ const oklch = toOklch(hsl);
+ if (!oklch) return '';
+ const L = round(oklch.l ?? 0, 4);
+ const C = round(oklch.c ?? 0, 4);
+ const H = C === 0 || !Number.isFinite(oklch.h) ? 0 : round(oklch.h ?? 0, 2);
+ const body = `${L} ${C} ${H}`;
+ return hsl.alpha === 1
+ ? `oklch(${body})`
+ : `oklch(${body} / ${round(hsl.alpha, 4)})`;
+};
+
+/**
+ * Parses any CSS color string into the picker's `{h, s, l, alpha}` shape.
+ * Returns a white fallback (matching the picker's previous default) when the
+ * input fails to parse, so the picker never throws on bad consumer input.
+ */
+export const parseColor = (value: string): ColorObject => {
+ const parsed = parse(value);
+ if (!parsed) return FALLBACK;
+ const hsl = toHsl(parsed);
+ if (!hsl) return FALLBACK;
+ return {
+ h: hsl.h ?? 0,
+ s: (hsl.s ?? 0) * HSL_PERCENT,
+ l: (hsl.l ?? 0) * HSL_PERCENT,
+ alpha: hsl.alpha ?? 1
+ };
+};
+
+/**
+ * Serializes `{h, s, l, alpha}` to a CSS string in the requested mode.
+ * Hex output is uppercase and uses 8-digit form only when alpha < 1, mirroring
+ * the previous `color@5` behavior the tests depend on.
+ */
+export const getColorString = (color: ColorObject, mode: ModeType): string => {
+ const culoriColor = {
+ mode: 'hsl' as const,
+ h: color.h,
+ s: color.s / HSL_PERCENT,
+ l: color.l / HSL_PERCENT,
+ alpha: color.alpha ?? 1
+ };
+
+ if (mode === 'hex') {
+ const hex =
+ culoriColor.alpha === 1
+ ? formatHex(culoriColor)
+ : formatHex8(culoriColor);
+ return hex.toUpperCase();
+ }
+ if (mode === 'hsl') return formatHsl(culoriColor);
+ if (mode === 'oklch') return formatOklch(culoriColor);
+ return formatRgb(culoriColor);
+};
diff --git a/packages/raystack/package.json b/packages/raystack/package.json
index f26c641f4..f40d3f650 100644
--- a/packages/raystack/package.json
+++ b/packages/raystack/package.json
@@ -89,6 +89,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
+ "@types/culori": "^4.0.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/ui": "^3.2.4",
@@ -121,7 +122,7 @@
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/table-core": "^8.9.2",
"class-variance-authority": "^0.7.1",
- "color": "^5.0.0",
+ "culori": "^4.0.2",
"dayjs": "^1.11.11",
"prism-react-renderer": "^2.4.1",
"react-day-picker": "^9.6.7"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d56606f33..634274492 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -199,9 +199,9 @@ importers:
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
- color:
- specifier: ^5.0.0
- version: 5.0.0
+ culori:
+ specifier: ^4.0.2
+ version: 4.0.2
dayjs:
specifier: ^1.11.11
version: 1.11.11
@@ -242,6 +242,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.5.2(@testing-library/dom@10.4.0)
+ '@types/culori':
+ specifier: ^4.0.1
+ version: 4.0.1
'@types/react':
specifier: ^19.0.0
version: 19.1.9
@@ -3708,6 +3711,9 @@ packages:
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
+ '@types/culori@4.0.1':
+ resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
+
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -4598,6 +4604,10 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+ culori@4.0.2:
+ resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -12704,7 +12714,7 @@ snapshots:
'@testing-library/dom@10.4.0':
dependencies:
'@babel/code-frame': 7.26.2
- '@babel/runtime': 7.28.6
+ '@babel/runtime': 7.29.2
'@types/aria-query': 5.0.4
aria-query: 5.3.0
chalk: 4.1.2
@@ -12829,6 +12839,8 @@ snapshots:
dependencies:
'@types/deep-eql': 4.0.2
+ '@types/culori@4.0.1': {}
+
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.34
@@ -13810,6 +13822,8 @@ snapshots:
csstype@3.1.3: {}
+ culori@4.0.2: {}
+
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0