'use client' import * as React from 'react' import { type VariantProps } from 'class-variance-authority' import { cn } from '../../internal/utils' import { describeColor, normalizeColor } from '../../internal/color-control' import { ColorPickerContext, type ColorPickerContextValue, useRequiredColorPickerContext, } from '../../internal/color-picker-context' import { ColorArea } from '../color-area/color-area' import { ColorSlider } from '../color-slider/color-slider' import { ColorSwatch, type ColorSwatchProps } from '../color-swatch/color-swatch' import { buttonVariants } from '../button/button-variants' import { Input, type InputProps } from '../input' import { Popover, PopoverContent, PopoverTrigger, type PopoverContentProps, type PopoverProps, } from '../popover/popover' import { type Color, type ColorFormat, formatColor, safeParseColor, withChannelValue } from '../../lib/color' const DEFAULT_VALUE = '#ffffff' type ColorPickerSize = 'sm' | 'md' | 'lg' type ColorPickerVariant = 'ghost' | 'outline' | 'soft' | 'flush' type ColorPickerSwatchPosition = 'start' | 'end' type ColorPickerSwatchShape = NonNullable const TRIGGER_SIZES: Record = { // A swatch-only trigger is square, so its swatch takes the next size up. sm: { button: 'sm', swatch: 20, iconSwatch: 24 }, md: { button: 'md', swatch: 24, iconSwatch: 28 }, lg: { button: 'lg', swatch: 28, iconSwatch: 32 }, } const ICON_BUTTON_SIZES: Record = { sm: 'icon-sm', md: 'icon-md', lg: 'icon-lg', } /** * `flush` is `ghost` with the button shell taken off: no box, no padding, no corner and * no hover fill, so the swatch lines up with whatever sits above or below it. */ const FLUSH_CLASSES = 'size-auto rounded-none p-0 hover:before:bg-transparent data-popup-open:before:bg-transparent data-pressed:before:bg-transparent' /** * Pulls the swatch back out of the button's `px-*` so the gap beside it matches the one * the button's height leaves above and below it: half the difference between the two, * at every size. A swatch-only trigger is already square, so it needs none of this. */ const SWATCH_OFFSETS: Record> = { sm: { start: '-ms-2.5', end: '-me-2.5' }, md: { start: '-ms-3', end: '-me-3' }, lg: { start: '-ms-3.5', end: '-me-3.5' }, } const FORMAT_LABELS: Record = { hex: 'Hex', hexa: 'Hex', rgb: 'RGB', rgba: 'RGB', hsl: 'HSL', hsla: 'HSL', hsb: 'HSB', hsba: 'HSB', oklch: 'OKLCH', oklcha: 'OKLCH', } const OPAQUE_FORMATS: Partial> = { hexa: 'hex', rgba: 'rgb', hsla: 'hsl', hsba: 'hsb', oklcha: 'oklch', } /** * An alpha-carrying format drops back to its opaque twin at full opacity, so a solid * color is written `#a855f7` rather than `#a855f7ff` and only grows the alpha when * there is one to show. */ function displayFormat(format: ColorFormat, color: Color): ColorFormat { return color.alpha < 1 ? format : (OPAQUE_FORMATS[format] ?? format) } const panelClasses = 'flex w-fit min-w-50 flex-col gap-3' interface ColorPickerProps extends Omit< React.HTMLAttributes, 'color' | 'defaultValue' | 'onChange' | 'children' > { /** Selected color. Pass a `Color` or any CSS color string to control the component. */ value?: Color | string /** * Color selected before any interaction, when the component is uncontrolled. * @default '#ffffff' */ defaultValue?: Color | string /** Fires on every change, including each frame of a drag inside the panel. */ onValueChange?: (value: Color) => void /** Fires once a gesture ends, with the color that was landed on. */ onValueCommitted?: (value: Color) => void /** * Panel contents. Any `ColorArea`, `ColorSlider`, `ColorSwatch` or `ColorSwatchPicker` * in here reads and writes the picker's color, so no value wiring is needed. Leave it * off for the default panel: an HSB area, a hue slider and a text input, plus a preview * swatch when `inline` leaves no trigger to show one. */ children?: React.ReactNode /** * Format the trigger and the text input write the color in. Defaults to `'hexa'` when * `alpha` is set. An alpha format collapses to its opaque twin whenever the color is * fully opaque, so a solid color never picks up a trailing `ff`. * @default 'hex' */ format?: ColorFormat /** * Add an alpha slider to the default panel, and back the preview with a checkerboard. * @default false */ alpha?: boolean /** * Add a screen color-sampling button to the default panel. It renders only where the * browser supports the EyeDropper API, so there is no dead control on Firefox or Safari. * @default false */ eyedropper?: boolean /** * Render the panel in place instead of behind a trigger and a popover. The trigger * props (`label`, `trigger`, `size`, the popover ones) do nothing in this mode. * @default false */ inline?: boolean /** * Prevent interaction and dim the trigger and every control in the panel. * @default false */ disabled?: boolean /** * Height and text scale of the default trigger. * @default 'md' */ size?: ColorPickerSize /** * Visual style of the default trigger, from `Button`'s set. `flush` takes the button * shell off entirely, leaving the swatch and the label on their own. * @default 'ghost' */ variant?: ColorPickerVariant /** * Text beside the swatch in the default trigger. Defaults to the color, formatted with * `format`. Pass `null` for a swatch-only square button, and pair a non-string node * with `aria-label`. */ label?: React.ReactNode /** * Rounded square or full circle, for the swatch on the default trigger. * @default 'rounded' */ swatchShape?: ColorPickerSwatchShape /** * Which side of the label the default trigger's swatch sits on. It is inset from that * edge by the same gap the button's height leaves above and below it. * @default 'start' */ swatchPosition?: ColorPickerSwatchPosition /** * Element to open the panel from, in place of the default swatch button. `null` renders * no trigger at all, for a panel driven by `open` and pointed at something you own with * `popoverProps.anchor`. `className` and the forwarded attributes land on the trigger, * so they have nowhere to go in that mode. */ trigger?: React.ReactElement | null /** Controlled open state of the popover. Pair with `onOpenChange`. */ open?: boolean /** * Uncontrolled initial open state of the popover. * @default false */ defaultOpen?: boolean /** * Fires when the popover opens or closes. The second argument carries the `reason` and * a `cancel()` that stops Base UI acting on the event. */ onOpenChange?: PopoverProps['onOpenChange'] /** * Preferred popover side. * @default 'bottom' */ side?: PopoverContentProps['side'] /** * Popover alignment. * @default 'start' */ align?: PopoverContentProps['align'] /** * Gap between the trigger and the popover. * @default 6 */ sideOffset?: number /** Escape hatch forwarded to the inner `PopoverContent` (collision props, `className`, …). */ popoverProps?: Partial /** Name of the hidden input, used when submitting an HTML form. */ name?: string /** `id` of the `
` the hidden input belongs to, when it sits outside it. */ form?: string } function ColorPicker({ value, defaultValue = DEFAULT_VALUE, onValueChange, onValueCommitted, children, format: formatProp, alpha = false, eyedropper = false, inline = false, disabled = false, size = 'md', variant = 'ghost', label, swatchShape = 'rounded', swatchPosition = 'start', trigger, open, defaultOpen = false, onOpenChange, side = 'bottom', align = 'start', sideOffset = 6, popoverProps, name, form, className, 'aria-label': ariaLabel, ...props }: ColorPickerProps) { const [uncontrolled, setUncontrolled] = React.useState(() => normalizeColor(defaultValue)) const [internalOpen, setInternalOpen] = React.useState(defaultOpen) const controlled = value !== undefined const color = controlled ? normalizeColor(value) : uncontrolled const format = formatProp ?? (alpha ? 'hexa' : 'hex') // Kept in a ref so the context object only changes with the color, not with a caller // that re-creates its handlers on every render. const latestRef = React.useRef({ controlled, onValueChange, onValueCommitted }) latestRef.current = { controlled, onValueChange, onValueCommitted } const context = React.useMemo( () => ({ value: color, format, disabled, setValue: (next) => { if (!latestRef.current.controlled) setUncontrolled(next) latestRef.current.onValueChange?.(next) }, commitValue: (next) => latestRef.current.onValueCommitted?.(next), }), [color, format, disabled], ) const panel = children ?? ( <>
{eyedropper && }
{alpha && }
{/* The trigger already previews the color; only an inline panel has to. */} {inline && }
) const valueText = formatColor(color, displayFormat(format, color)) const hiddenInput = name ? : null if (inline) { return (
{panel} {hiddenInput}
) } const description = describeColor(color) const iconOnly = label === null const sizes = TRIGGER_SIZES[size] const swatch = (