(null)
+ const [stripWidth, setStripWidth] = React.useState(300)
+
+ React.useLayoutEffect(() => {
+ if (containerRef.current) {
+ setStripWidth(containerRef.current.offsetWidth)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => {
+ setStripWidth(Math.floor(entry.contentRect.width))
+ })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ const currentColor = isControlled ? value : internalColor
+
+ const handleHueChange = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChange?.(pureHue)
+ }
+
+ const handleHueChangeComplete = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChangeComplete?.(pureHue)
+ }
+
+ const handleShadeChange = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChange?.(colorVal.hex)
+ }
+
+ const handleShadeChangeComplete = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChangeComplete?.(colorVal.hex)
+ }
+
+ return (
+
+
+ {showShade && (
+
+ )}
+
+ )
+}
+
+export { ColorPicker }
+export type { ColorPickerProps, ColorValue }
diff --git a/apps/v4/registry/bases/radix/ui/color-picker.tsx b/apps/v4/registry/bases/radix/ui/color-picker.tsx
new file mode 100644
index 00000000000..01289c493f8
--- /dev/null
+++ b/apps/v4/registry/bases/radix/ui/color-picker.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import ColorStrip from "react-color-strip"
+
+import { cn } from "@/lib/utils"
+
+type ColorValue = {
+ hex: string
+ rgb: { r: number; g: number; b: number }
+ hsl: { h: number; s: number; l: number }
+}
+
+interface ColorPickerProps {
+ value?: string
+ defaultValue?: string
+ onChange?: (value: string) => void
+ onChangeComplete?: (value: string) => void
+ disabled?: boolean
+ showShade?: boolean
+ className?: string
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+ s /= 100
+ l /= 100
+ const k = (n: number) => (n + h / 30) % 12
+ const a = s * Math.min(l, 1 - l)
+ const f = (n: number) =>
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
+ const toHex = (x: number) =>
+ Math.round(255 * x)
+ .toString(16)
+ .padStart(2, "0")
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`
+}
+
+const POINTER_STYLE = {
+ width: 14,
+ height: 14,
+ backgroundColor: "white",
+ border: "1.5px solid hsl(var(--border))",
+ borderRadius: "50%",
+ boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)",
+ scaleOnDrag: true,
+ dragScale: 1.15,
+}
+
+function ColorPicker({
+ value,
+ defaultValue = "#ef4444",
+ onChange,
+ onChangeComplete,
+ disabled = false,
+ showShade = false,
+ className,
+}: ColorPickerProps) {
+ const isControlled = value !== undefined
+ const initial = value ?? defaultValue
+
+ const [internalColor, setInternalColor] = React.useState(initial)
+ const [hueColor, setHueColor] = React.useState(initial)
+
+ const containerRef = React.useRef(null)
+ const [stripWidth, setStripWidth] = React.useState(300)
+
+ React.useLayoutEffect(() => {
+ if (containerRef.current) {
+ setStripWidth(containerRef.current.offsetWidth)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => {
+ setStripWidth(Math.floor(entry.contentRect.width))
+ })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ const currentColor = isControlled ? value : internalColor
+
+ const handleHueChange = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChange?.(pureHue)
+ }
+
+ const handleHueChangeComplete = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChangeComplete?.(pureHue)
+ }
+
+ const handleShadeChange = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChange?.(colorVal.hex)
+ }
+
+ const handleShadeChangeComplete = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChangeComplete?.(colorVal.hex)
+ }
+
+ return (
+
+
+ {showShade && (
+
+ )}
+
+ )
+}
+
+export { ColorPicker }
+export type { ColorPickerProps, ColorValue }
diff --git a/apps/v4/registry/new-york-v4/examples/_registry.ts b/apps/v4/registry/new-york-v4/examples/_registry.ts
index ac78c238fc0..03230e3d73f 100644
--- a/apps/v4/registry/new-york-v4/examples/_registry.ts
+++ b/apps/v4/registry/new-york-v4/examples/_registry.ts
@@ -584,6 +584,17 @@ export const examples: Registry["items"] = [
},
],
},
+ {
+ name: "color-picker",
+ type: "registry:example",
+ registryDependencies: ["color-picker"],
+ files: [
+ {
+ path: "examples/color-picker.tsx",
+ type: "registry:example",
+ },
+ ],
+ },
{
name: "combobox-demo",
type: "registry:example",
diff --git a/apps/v4/registry/new-york-v4/examples/color-picker.tsx b/apps/v4/registry/new-york-v4/examples/color-picker.tsx
new file mode 100644
index 00000000000..956ff35a54e
--- /dev/null
+++ b/apps/v4/registry/new-york-v4/examples/color-picker.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import { useState } from "react"
+
+import { ColorPicker } from "@/registry/new-york-v4/ui/color-picker"
+
+export default function ColorPickerDemo() {
+ const [color, setColor] = useState("#ef4444")
+
+ return (
+
+ )
+}
diff --git a/apps/v4/registry/new-york-v4/ui/_registry.ts b/apps/v4/registry/new-york-v4/ui/_registry.ts
index b31e372ede0..a0d9c3ce092 100644
--- a/apps/v4/registry/new-york-v4/ui/_registry.ts
+++ b/apps/v4/registry/new-york-v4/ui/_registry.ts
@@ -168,6 +168,17 @@ export const ui: Registry["items"] = [
},
],
},
+ {
+ name: "color-picker",
+ type: "registry:ui",
+ dependencies: ["react-color-strip"],
+ files: [
+ {
+ path: "ui/color-picker.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
{
name: "combobox",
type: "registry:ui",
diff --git a/apps/v4/registry/new-york-v4/ui/color-picker.tsx b/apps/v4/registry/new-york-v4/ui/color-picker.tsx
new file mode 100644
index 00000000000..01289c493f8
--- /dev/null
+++ b/apps/v4/registry/new-york-v4/ui/color-picker.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import ColorStrip from "react-color-strip"
+
+import { cn } from "@/lib/utils"
+
+type ColorValue = {
+ hex: string
+ rgb: { r: number; g: number; b: number }
+ hsl: { h: number; s: number; l: number }
+}
+
+interface ColorPickerProps {
+ value?: string
+ defaultValue?: string
+ onChange?: (value: string) => void
+ onChangeComplete?: (value: string) => void
+ disabled?: boolean
+ showShade?: boolean
+ className?: string
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+ s /= 100
+ l /= 100
+ const k = (n: number) => (n + h / 30) % 12
+ const a = s * Math.min(l, 1 - l)
+ const f = (n: number) =>
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
+ const toHex = (x: number) =>
+ Math.round(255 * x)
+ .toString(16)
+ .padStart(2, "0")
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`
+}
+
+const POINTER_STYLE = {
+ width: 14,
+ height: 14,
+ backgroundColor: "white",
+ border: "1.5px solid hsl(var(--border))",
+ borderRadius: "50%",
+ boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)",
+ scaleOnDrag: true,
+ dragScale: 1.15,
+}
+
+function ColorPicker({
+ value,
+ defaultValue = "#ef4444",
+ onChange,
+ onChangeComplete,
+ disabled = false,
+ showShade = false,
+ className,
+}: ColorPickerProps) {
+ const isControlled = value !== undefined
+ const initial = value ?? defaultValue
+
+ const [internalColor, setInternalColor] = React.useState(initial)
+ const [hueColor, setHueColor] = React.useState(initial)
+
+ const containerRef = React.useRef(null)
+ const [stripWidth, setStripWidth] = React.useState(300)
+
+ React.useLayoutEffect(() => {
+ if (containerRef.current) {
+ setStripWidth(containerRef.current.offsetWidth)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => {
+ setStripWidth(Math.floor(entry.contentRect.width))
+ })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ const currentColor = isControlled ? value : internalColor
+
+ const handleHueChange = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChange?.(pureHue)
+ }
+
+ const handleHueChangeComplete = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChangeComplete?.(pureHue)
+ }
+
+ const handleShadeChange = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChange?.(colorVal.hex)
+ }
+
+ const handleShadeChangeComplete = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChangeComplete?.(colorVal.hex)
+ }
+
+ return (
+
+
+ {showShade && (
+
+ )}
+
+ )
+}
+
+export { ColorPicker }
+export type { ColorPickerProps, ColorValue }
diff --git a/apps/v4/styles/base-nova/ui/color-picker.tsx b/apps/v4/styles/base-nova/ui/color-picker.tsx
new file mode 100644
index 00000000000..01289c493f8
--- /dev/null
+++ b/apps/v4/styles/base-nova/ui/color-picker.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import ColorStrip from "react-color-strip"
+
+import { cn } from "@/lib/utils"
+
+type ColorValue = {
+ hex: string
+ rgb: { r: number; g: number; b: number }
+ hsl: { h: number; s: number; l: number }
+}
+
+interface ColorPickerProps {
+ value?: string
+ defaultValue?: string
+ onChange?: (value: string) => void
+ onChangeComplete?: (value: string) => void
+ disabled?: boolean
+ showShade?: boolean
+ className?: string
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+ s /= 100
+ l /= 100
+ const k = (n: number) => (n + h / 30) % 12
+ const a = s * Math.min(l, 1 - l)
+ const f = (n: number) =>
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
+ const toHex = (x: number) =>
+ Math.round(255 * x)
+ .toString(16)
+ .padStart(2, "0")
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`
+}
+
+const POINTER_STYLE = {
+ width: 14,
+ height: 14,
+ backgroundColor: "white",
+ border: "1.5px solid hsl(var(--border))",
+ borderRadius: "50%",
+ boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)",
+ scaleOnDrag: true,
+ dragScale: 1.15,
+}
+
+function ColorPicker({
+ value,
+ defaultValue = "#ef4444",
+ onChange,
+ onChangeComplete,
+ disabled = false,
+ showShade = false,
+ className,
+}: ColorPickerProps) {
+ const isControlled = value !== undefined
+ const initial = value ?? defaultValue
+
+ const [internalColor, setInternalColor] = React.useState(initial)
+ const [hueColor, setHueColor] = React.useState(initial)
+
+ const containerRef = React.useRef(null)
+ const [stripWidth, setStripWidth] = React.useState(300)
+
+ React.useLayoutEffect(() => {
+ if (containerRef.current) {
+ setStripWidth(containerRef.current.offsetWidth)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => {
+ setStripWidth(Math.floor(entry.contentRect.width))
+ })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ const currentColor = isControlled ? value : internalColor
+
+ const handleHueChange = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChange?.(pureHue)
+ }
+
+ const handleHueChangeComplete = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChangeComplete?.(pureHue)
+ }
+
+ const handleShadeChange = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChange?.(colorVal.hex)
+ }
+
+ const handleShadeChangeComplete = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChangeComplete?.(colorVal.hex)
+ }
+
+ return (
+
+
+ {showShade && (
+
+ )}
+
+ )
+}
+
+export { ColorPicker }
+export type { ColorPickerProps, ColorValue }
diff --git a/apps/v4/styles/radix-nova/ui/color-picker.tsx b/apps/v4/styles/radix-nova/ui/color-picker.tsx
new file mode 100644
index 00000000000..01289c493f8
--- /dev/null
+++ b/apps/v4/styles/radix-nova/ui/color-picker.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import ColorStrip from "react-color-strip"
+
+import { cn } from "@/lib/utils"
+
+type ColorValue = {
+ hex: string
+ rgb: { r: number; g: number; b: number }
+ hsl: { h: number; s: number; l: number }
+}
+
+interface ColorPickerProps {
+ value?: string
+ defaultValue?: string
+ onChange?: (value: string) => void
+ onChangeComplete?: (value: string) => void
+ disabled?: boolean
+ showShade?: boolean
+ className?: string
+}
+
+function hslToHex(h: number, s: number, l: number): string {
+ s /= 100
+ l /= 100
+ const k = (n: number) => (n + h / 30) % 12
+ const a = s * Math.min(l, 1 - l)
+ const f = (n: number) =>
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
+ const toHex = (x: number) =>
+ Math.round(255 * x)
+ .toString(16)
+ .padStart(2, "0")
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`
+}
+
+const POINTER_STYLE = {
+ width: 14,
+ height: 14,
+ backgroundColor: "white",
+ border: "1.5px solid hsl(var(--border))",
+ borderRadius: "50%",
+ boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.15), 0 0 0 1px rgb(0 0 0 / 0.04)",
+ scaleOnDrag: true,
+ dragScale: 1.15,
+}
+
+function ColorPicker({
+ value,
+ defaultValue = "#ef4444",
+ onChange,
+ onChangeComplete,
+ disabled = false,
+ showShade = false,
+ className,
+}: ColorPickerProps) {
+ const isControlled = value !== undefined
+ const initial = value ?? defaultValue
+
+ const [internalColor, setInternalColor] = React.useState(initial)
+ const [hueColor, setHueColor] = React.useState(initial)
+
+ const containerRef = React.useRef(null)
+ const [stripWidth, setStripWidth] = React.useState(300)
+
+ React.useLayoutEffect(() => {
+ if (containerRef.current) {
+ setStripWidth(containerRef.current.offsetWidth)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => {
+ setStripWidth(Math.floor(entry.contentRect.width))
+ })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ const currentColor = isControlled ? value : internalColor
+
+ const handleHueChange = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChange?.(pureHue)
+ }
+
+ const handleHueChangeComplete = (colorVal: ColorValue) => {
+ const pureHue = hslToHex(colorVal.hsl.h, 100, 50)
+ setHueColor(pureHue)
+ if (!isControlled) setInternalColor(pureHue)
+ onChangeComplete?.(pureHue)
+ }
+
+ const handleShadeChange = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChange?.(colorVal.hex)
+ }
+
+ const handleShadeChangeComplete = (colorVal: ColorValue) => {
+ if (!isControlled) setInternalColor(colorVal.hex)
+ onChangeComplete?.(colorVal.hex)
+ }
+
+ return (
+
+
+ {showShade && (
+
+ )}
+
+ )
+}
+
+export { ColorPicker }
+export type { ColorPickerProps, ColorValue }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3067dc6f898..d58f07c8500 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -253,6 +253,9 @@ importers:
react:
specifier: 19.2.3
version: 19.2.3
+ react-color-strip:
+ specifier: ^2.0.1
+ version: 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-day-picker:
specifier: ^9.7.0
version: 9.8.1(react@19.2.3)
@@ -7016,6 +7019,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ react-color-strip@2.0.1:
+ resolution: {integrity: sha512-+ybjx4EkETKRiZTBMzc8VTXPfPCeATU8Y7AFF/eBXgzppYb0JXn7nDGCqUu1kPLEee03TnE6vgNaOIaMdJ5r3Q==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+
react-day-picker@9.8.1:
resolution: {integrity: sha512-kMcLrp3PfN/asVJayVv82IjF3iLOOxuH5TNFWezX6lS/T8iVRFPTETpHl3TUSTH99IDMZLubdNPJr++rQctkEw==}
engines: {node: '>=18'}
@@ -15436,6 +15445,12 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
+ react-color-strip@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ rollup: 4.60.1
+
react-day-picker@9.8.1(react@19.2.3):
dependencies:
'@date-fns/tz': 1.3.1