Skip to content

Commit

Permalink
Color Picker (#58)
Browse files Browse the repository at this point in the history
* Scaffold initial components

* Add Eyedropper tool, implement colors lib

* Finish drafting color picker

* Add value, defaultValue and onChange

* Allow editing formatter

* Try / catch color manipulation errors

* Remove ability to modify inputs, misc fixes

* Remove unused dep

* Update color-picker.mdx
  • Loading branch information
haydenbleasel authored Jan 27, 2025
1 parent 4859be9 commit 84ffe02
Show file tree
Hide file tree
Showing 7 changed files with 680 additions and 5 deletions.
98 changes: 98 additions & 0 deletions apps/docs/content/docs/color-picker.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: Color Picker
description: A color picker is a UI component that allows users to select a color. It is modeled after the color picker in Figma, considered to be best in class.
icon: Palette
---

import { AutoTypeTable } from 'fumadocs-typescript/ui';

<PoweredBy packages={[
{ name: 'Lucide', url: 'https://lucide.dev/' },
{ name: 'color', url: 'https://github.com/Qix-/color' },
]} />

<Preview name="color-picker" code={`'use client';
import {
ColorPicker,
ColorPickerSelection,
ColorPickerHue,
ColorPickerAlpha,
ColorPickerOutput,
ColorPickerEyeDropper,
ColorPickerFormat,
} from '@/components/ui/kibo-ui/color-picker';
const Example = () => (
<div className="flex w-full h-screen items-center justify-center gap-4 bg-secondary">
<ColorPicker className="w-full max-w-[300px] rounded-md border bg-background p-4 shadow-sm">
<ColorPickerSelection />
<div className="flex items-center gap-4">
<ColorPickerEyeDropper />
<div className="w-full grid gap-1">
<ColorPickerHue />
<ColorPickerAlpha />
</div>
</div>
<div className="flex items-center gap-2">
<ColorPickerOutput />
<ColorPickerFormat />
</div>
</ColorPicker>
</div>
);
export default Example;`} />

## Installation

<Installer packageName="color-picker" />

## Features

- Interactive color selection with drag and drop functionality
- Hue and alpha sliders for precise color adjustments
- EyeDropper tool for picking colors from anywhere on screen
- Multiple color format outputs (HEX, RGB, CSS, HSL)
- Real-time color preview
- Fully customizable through className and props

## Props

The color picker is made up of the following subcomponents:

### ColorPicker

The `ColorPicker` component is used to display a color picker.

<AutoTypeTable path="node_modules/@repo/color-picker/index.tsx" name="ColorPickerProps" />

### ColorPickerSelection

The `ColorPickerSelection` component is used to display the selected color.

<AutoTypeTable path="node_modules/@repo/color-picker/index.tsx" name="ColorPickerSelectionProps" />

### ColorPickerHue

The `ColorPickerHue` component is used to display the hue slider.

<AutoTypeTable path="node_modules/@repo/color-picker/index.tsx" name="ColorPickerHueProps" />

### ColorPickerAlpha

The `ColorPickerAlpha` component is used to display the alpha slider.

<AutoTypeTable path="node_modules/@repo/color-picker/index.tsx" name="ColorPickerAlphaProps" />

### ColorPickerOutput

The `ColorPickerOutput` component is used to display the output of the selected color.

<AutoTypeTable path="node_modules/@repo/color-picker/index.tsx" name="ColorPickerOutputProps" />

### ColorPickerFormat

The `ColorPickerFormat` component is used to display the format of the selected color.

<AutoTypeTable path="node_modules/@repo/color-picker/index.tsx" name="ColorPickerFormatProps" />
1 change: 1 addition & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@repo/announcement": "workspace:*",
"@repo/calendar": "workspace:*",
"@repo/color-picker": "workspace:*",
"@repo/dialog-stack": "workspace:*",
"@repo/dropzone": "workspace:*",
"@repo/gantt": "workspace:*",
Expand Down
18 changes: 18 additions & 0 deletions apps/docs/public/registry/color-picker.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"homepage": "https://www.kibo-ui.com/color-picker",
"name": "color-picker",
"type": "registry:ui",
"author": "Hayden Bleasel <hello@haydenbleasel.com>",
"registryDependencies": ["button", "input", "select"],
"dependencies": ["@radix-ui/react-slider", "color", "lucide-react"],
"devDependencies": ["@types/color"],
"files": [
{
"type": "registry:ui",
"path": "index.tsx",
"content": "import { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/ui/select';\nimport { cn } from '@/lib/utils';\nimport { Range, Root, Thumb, Track } from '@radix-ui/react-slider';\nimport Color from 'color';\nimport { PipetteIcon } from 'lucide-react';\nimport {\n type ChangeEventHandler,\n type ComponentProps,\n type HTMLAttributes,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from 'react';\nimport { createContext, useContext } from 'react';\n\ninterface ColorPickerContextValue {\n hue: number;\n saturation: number;\n lightness: number;\n alpha: number;\n mode: string;\n setHue: (hue: number) => void;\n setSaturation: (saturation: number) => void;\n setLightness: (lightness: number) => void;\n setAlpha: (alpha: number) => void;\n setMode: (mode: string) => void;\n}\n\nconst ColorPickerContext = createContext<ColorPickerContextValue | undefined>(\n undefined\n);\n\nexport const useColorPicker = () => {\n const context = useContext(ColorPickerContext);\n\n if (!context) {\n throw new Error('useColorPicker must be used within a ColorPickerProvider');\n }\n\n return context;\n};\n\nexport type ColorPickerProps = HTMLAttributes<HTMLDivElement> & {\n value?: Parameters<typeof Color>[0];\n defaultValue?: Parameters<typeof Color>[0];\n onChange?: (value: Parameters<typeof Color.rgb>[0]) => void;\n};\n\nexport const ColorPicker = ({\n value,\n defaultValue = '#000000',\n onChange,\n className,\n ...props\n}: ColorPickerProps) => {\n const selectedColor = Color(value);\n const defaultColor = Color(defaultValue);\n\n const [hue, setHue] = useState(\n selectedColor.hue() || defaultColor.hue() || 0\n );\n const [saturation, setSaturation] = useState(\n selectedColor.saturationl() || defaultColor.saturationl() || 100\n );\n const [lightness, setLightness] = useState(\n selectedColor.lightness() || defaultColor.lightness() || 50\n );\n const [alpha, setAlpha] = useState(\n selectedColor.alpha() * 100 || defaultColor.alpha() * 100\n );\n const [mode, setMode] = useState('hex');\n\n // Update color when controlled value changes\n useEffect(() => {\n if (value) {\n const color = Color.rgb(value).rgb().object();\n\n setHue(color.r);\n setSaturation(color.g);\n setLightness(color.b);\n setAlpha(color.a);\n }\n }, [value]);\n\n // Notify parent of changes\n useEffect(() => {\n if (onChange) {\n const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);\n const rgba = color.rgb().array();\n\n onChange([rgba[0], rgba[1], rgba[2], alpha / 100]);\n }\n }, [hue, saturation, lightness, alpha, onChange]);\n\n return (\n <ColorPickerContext.Provider\n value={{\n hue,\n saturation,\n lightness,\n alpha,\n mode,\n setHue,\n setSaturation,\n setLightness,\n setAlpha,\n setMode,\n }}\n >\n <div className={cn('grid w-full gap-4', className)} {...props} />\n </ColorPickerContext.Provider>\n );\n};\n\nexport type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ColorPickerSelection = ({\n className,\n ...props\n}: ColorPickerSelectionProps) => {\n const containerRef = useRef<HTMLDivElement>(null);\n const [isDragging, setIsDragging] = useState(false);\n const [position, setPosition] = useState({ x: 0, y: 0 });\n const { hue, setSaturation } = useColorPicker();\n\n const handlePointerMove = useCallback(\n (event: PointerEvent) => {\n if (!isDragging || !containerRef.current) {\n return;\n }\n\n const rect = containerRef.current.getBoundingClientRect();\n const x = Math.max(\n 0,\n Math.min(1, (event.clientX - rect.left) / rect.width)\n );\n const y = Math.max(\n 0,\n Math.min(1, (event.clientY - rect.top) / rect.height)\n );\n\n setPosition({ x, y });\n setSaturation((1 - y) * 100);\n },\n [isDragging, setSaturation]\n );\n\n useEffect(() => {\n if (isDragging) {\n window.addEventListener('pointermove', handlePointerMove);\n window.addEventListener('pointerup', () => setIsDragging(false));\n }\n return () => {\n window.removeEventListener('pointermove', handlePointerMove);\n window.removeEventListener('pointerup', () => setIsDragging(false));\n };\n }, [isDragging, handlePointerMove]);\n\n return (\n <div\n ref={containerRef}\n className={cn(\n 'relative aspect-[4/3] w-full cursor-crosshair rounded',\n `bg-[linear-gradient(0deg,rgb(0,0,0),transparent),linear-gradient(90deg,rgb(255,255,255),hsl(${hue},100%,50%))]`,\n className\n )}\n onPointerDown={(e) => {\n e.preventDefault();\n setIsDragging(true);\n handlePointerMove(e.nativeEvent);\n }}\n {...props}\n >\n <div\n className=\"-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white\"\n style={{\n left: `${position.x * 100}%`,\n top: `${position.y * 100}%`,\n boxShadow: '0 0 0 1px rgba(0,0,0,0.5)',\n }}\n />\n </div>\n );\n};\n\nexport type ColorPickerHueProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ColorPickerHue = ({\n className,\n ...props\n}: ColorPickerHueProps) => {\n const { hue, setHue } = useColorPicker();\n\n return (\n <Root\n value={[hue]}\n max={360}\n step={1}\n className={cn('relative flex h-4 w-full touch-none', className)}\n onValueChange={([hue]) => setHue(hue)}\n {...props}\n >\n <Track className=\"relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]\">\n <Range className=\"absolute h-full\" />\n </Track>\n <Thumb className=\"block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\" />\n </Root>\n );\n};\n\nexport type ColorPickerAlphaProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ColorPickerAlpha = ({\n className,\n ...props\n}: ColorPickerAlphaProps) => {\n const { alpha, setAlpha } = useColorPicker();\n\n return (\n <Root\n value={[alpha]}\n max={100}\n step={1}\n className={cn('relative flex h-4 w-full touch-none', className)}\n onValueChange={([alpha]) => setAlpha(alpha)}\n {...props}\n >\n <Track\n className=\"relative my-0.5 h-3 w-full grow rounded-full\"\n style={{\n background:\n 'url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==\") left center',\n }}\n >\n <div className=\"absolute inset-0 rounded-full bg-gradient-to-r from-transparent to-primary/50\" />\n <Range className=\"absolute h-full rounded-full bg-transparent\" />\n </Track>\n <Thumb className=\"block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\" />\n </Root>\n );\n};\n\nexport type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;\n\nexport const ColorPickerEyeDropper = ({\n className,\n ...props\n}: ColorPickerEyeDropperProps) => {\n const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();\n\n const handleEyeDropper = async () => {\n try {\n // @ts-ignore - EyeDropper API is experimental\n const eyeDropper = new EyeDropper();\n const result = await eyeDropper.open();\n const color = Color(result.sRGBHex);\n const [h, s, l] = color.hsl().array();\n\n setHue(h);\n setSaturation(s);\n setLightness(l);\n setAlpha(100);\n } catch (error) {\n console.error('EyeDropper failed:', error);\n }\n };\n\n return (\n <Button\n variant=\"outline\"\n size=\"icon\"\n onClick={handleEyeDropper}\n className={cn('shrink-0 text-muted-foreground', className)}\n {...props}\n >\n <PipetteIcon size={16} />\n </Button>\n );\n};\n\nexport type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;\n\nconst formats = ['hex', 'rgb', 'css', 'hsl'];\n\nexport const ColorPickerOutput = ({\n className,\n ...props\n}: ColorPickerOutputProps) => {\n const { mode, setMode } = useColorPicker();\n\n return (\n <Select value={mode} onValueChange={setMode}>\n <SelectTrigger className=\"h-8 w-[4.5rem] shrink-0 text-xs\" {...props}>\n <SelectValue placeholder=\"Mode\" />\n </SelectTrigger>\n <SelectContent>\n {formats.map((format) => (\n <SelectItem key={format} value={format} className=\"text-xs\">\n {format.toUpperCase()}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n );\n};\n\ntype PercentageInputProps = ComponentProps<typeof Input>;\n\nconst PercentageInput = ({ className, ...props }: PercentageInputProps) => {\n return (\n <div className=\"relative\">\n <Input\n type=\"text\"\n {...props}\n className={cn(\n 'h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none',\n className\n )}\n />\n <span className=\"-translate-y-1/2 absolute top-1/2 right-2 text-muted-foreground text-xs\">\n %\n </span>\n </div>\n );\n};\n\nexport type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ColorPickerFormat = ({\n className,\n ...props\n}: ColorPickerFormatProps) => {\n const {\n hue,\n saturation,\n lightness,\n alpha,\n mode,\n setHue,\n setSaturation,\n setLightness,\n setAlpha,\n } = useColorPicker();\n const color = Color.hsl(hue, saturation, lightness, alpha / 100);\n\n if (mode === 'hex') {\n const hex = color.hex();\n\n const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {\n try {\n const newColor = Color(event.target.value);\n\n setHue(newColor.hue());\n setSaturation(newColor.saturationl());\n setLightness(newColor.lightness());\n setAlpha(newColor.alpha() * 100);\n } catch (error) {\n console.error('Invalid hex color:', error);\n }\n };\n\n return (\n <div\n className={cn(\n '-space-x-px relative flex items-center shadow-sm',\n className\n )}\n {...props}\n >\n <span className=\"-translate-y-1/2 absolute top-1/2 left-2 text-xs\">\n #\n </span>\n <Input\n type=\"text\"\n value={hex}\n className=\"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none\"\n />\n <PercentageInput value={alpha} />\n </div>\n );\n }\n\n if (mode === 'rgb') {\n const rgb = color\n .rgb()\n .array()\n .map((value) => Math.round(value));\n\n return (\n <div\n className={cn('-space-x-px flex items-center shadow-sm', className)}\n {...props}\n >\n {rgb.map((value, index) => (\n <Input\n key={index}\n type=\"text\"\n value={value}\n readOnly\n className={cn(\n 'h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none',\n index && 'rounded-l-none',\n className\n )}\n />\n ))}\n <PercentageInput value={alpha} />\n </div>\n );\n }\n\n if (mode === 'css') {\n const rgb = color\n .rgb()\n .array()\n .map((value) => Math.round(value));\n\n return (\n <div className={cn('w-full shadow-sm', className)} {...props}>\n <Input\n type=\"text\"\n className=\"h-8 w-full bg-secondary px-2 text-xs shadow-none\"\n value={`rgba(${rgb.join(', ')}, ${alpha}%)`}\n readOnly\n {...props}\n />\n </div>\n );\n }\n\n if (mode === 'hsl') {\n const hsl = color\n .hsl()\n .array()\n .map((value) => Math.round(value));\n\n return (\n <div\n className={cn('-space-x-px flex items-center shadow-sm', className)}\n {...props}\n >\n {hsl.map((value, index) => (\n <Input\n key={index}\n type=\"text\"\n value={value}\n readOnly\n className={cn(\n 'h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none',\n index && 'rounded-l-none',\n className\n )}\n />\n ))}\n <PercentageInput value={alpha} />\n </div>\n );\n }\n\n return null;\n};\n",
"target": "components/ui/kibo-ui/color-picker.tsx"
}
]
}
Loading

0 comments on commit 84ffe02

Please sign in to comment.