All components
Color Picker
Swatch trigger and checkerboard swatch grid with a selection indicator, from 2xs to 2xl.
Brand color
123456789101112131415161718192021222324252627282930import { ColorPicker } from "#components/ui/base-ui/color-picker.tsx";
const swatches = ["#ef4444", "#22c55e", "#6366f1", "#a855f7"];
export function Example() { return ( // Base UI has no color-picker machine, so Root owns the color value // (value/defaultValue/onValueChange); the selected SwatchTrigger rings // its Swatch and reveals its SwatchIndicator, and parts compose through // render <ColorPicker.Root defaultValue="#6366f1" variant="outline" size="md"> <ColorPicker.Label>Brand color</ColorPicker.Label> <ColorPicker.Control> <ColorPicker.Trigger> <ColorPicker.Swatch /> <ColorPicker.ValueText /> </ColorPicker.Trigger> </ColorPicker.Control> <ColorPicker.SwatchGroup> {swatches.map((hex) => ( <ColorPicker.SwatchTrigger key={hex} value={hex}> <ColorPicker.Swatch value={hex}> <ColorPicker.SwatchIndicator /> </ColorPicker.Swatch> </ColorPicker.SwatchTrigger> ))} </ColorPicker.SwatchGroup> </ColorPicker.Root> );}Variants
Brand color
outlineBrand color
subtleSizes
Brand color
2xsBrand color
xsBrand color
smBrand color
mdBrand color
lgBrand color
xlBrand color
2xlInstall
1npx stylexblocks add color-pickerSource
components/ui/base-ui/color-picker.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, type SlotRecipeVariants,} from "#lib/recipe-props.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { colors } from '@stylexblocks/ui/tokens/colors.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { semanticShadows } from '@stylexblocks/ui/semantic-tokens/shadows.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'import { zIndices } from '@stylexblocks/ui/tokens/z-indices.stylex.ts'
const colorPickerRoot = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._1_5, },})
const colorPickerLabel = stylex.create({ base: { color: semanticColors.fg, fontWeight: fontWeights.medium, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, },})
const colorPickerValueText = stylex.create({ base: { textAlign: 'start', },})
const colorPickerControl = stylex.create({ base: { display: 'flex', alignItems: 'center', flexDirection: 'row', gap: spacing._2, position: 'relative', },})
const colorPickerSwatchTrigger = stylex.create({ base: { display: 'flex', alignItems: 'center', justifyContent: 'center', },})
const colorPickerTrigger = stylex.create({ base: { display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'row', flexShrink: 0, gap: spacing._2, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, minHeight: 'var(--input-height)', minWidth: 'var(--input-height)', paddingInline: spacing._1, borderRadius: semanticRadii.controlMd, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, '--focus-color': colorPalette.focusRing, },})
const colorPickerTriggerSizes = stylex.create({ '2xs': { '--input-height': sizes._5, }, xs: { '--input-height': sizes._6, }, sm: { '--input-height': sizes._7, }, md: { '--input-height': sizes._8, }, lg: { '--input-height': sizes._10, }, xl: { '--input-height': sizes._12, }, '2xl': { '--input-height': sizes._16, },})
const colorPickerTriggerVariants = stylex.create({ outline: { borderWidth: '1px', }, subtle: { borderWidth: '1px', borderColor: 'transparent', backgroundColor: semanticColors.bgMuted, },})
const colorPickerContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', width: sizes._64, padding: spacing._4, gap: spacing._3, '--color-picker-z-index': zIndices.layer3, zIndex: 'calc(var(--color-picker-z-index) + var(--layer-index, 0))', },})
const colorPickerArea = stylex.create({ base: { height: '180px', borderRadius: semanticRadii.controlMd, overflow: 'hidden', },})
const colorPickerAreaSizes = stylex.create({ '2xs': { '--thumb-size': sizes._3, }, xs: { '--thumb-size': sizes._3_5, }, sm: { '--thumb-size': sizes._3_5, }, md: { '--thumb-size': sizes._3_5, }, lg: { '--thumb-size': sizes._3_5, }, xl: { '--thumb-size': sizes._3_5, }, '2xl': { '--thumb-size': sizes._3_5, },})
const colorPickerAreaThumb = stylex.create({ base: { borderRadius: radii.full, height: 'var(--thumb-size)', width: 'var(--thumb-size)', borderWidth: '2px', borderColor: colors.white, boxShadow: semanticShadows.sm, '--focus-ring-color': colors.white, },})
const colorPickerAreaBackground = stylex.create({ base: { height: sizes.full, },})
const colorPickerChannelSlider = stylex.create({ base: { borderRadius: semanticRadii.controlMd, flex: 1, },})
const colorPickerChannelSliderSizes = stylex.create({ '2xs': { '--slider-height': sizes._3, '--thumb-size': sizes._3, }, xs: { '--slider-height': sizes._3_5, '--thumb-size': sizes._3_5, }, sm: { '--slider-height': sizes._3_5, '--thumb-size': sizes._3_5, }, md: { '--slider-height': sizes._3_5, '--thumb-size': sizes._3_5, }, lg: { '--slider-height': sizes._3_5, '--thumb-size': sizes._3_5, }, xl: { '--slider-height': sizes._3_5, '--thumb-size': sizes._3_5, }, '2xl': { '--slider-height': sizes._3_5, '--thumb-size': sizes._3_5, },})
const colorPickerChannelSliderTrack = stylex.create({ base: { height: 'var(--slider-height)', borderRadius: 'inherit', boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.1)', },})
const colorPickerChannelText = stylex.create({ base: { fontSize: fontSizes.xs, lineHeight: lineHeights.xs, color: semanticColors.fgMuted, fontWeight: fontWeights.medium, textTransform: 'capitalize', },})
const colorPickerSwatchGroup = stylex.create({ base: { display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: spacing._2, },})
const colorPickerSwatch = stylex.create({ base: { width: 'var(--swatch-size)', height: 'var(--swatch-size)', boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)', '--checker-size': '8px', '--checker-bg': semanticColors.bg, '--checker-fg': semanticColors.bgEmphasized, backgroundImage: 'linear-gradient(var(--color), var(--color)), repeating-conic-gradient(var(--checker-fg) 0%, var(--checker-fg) 25%, var(--checker-bg) 0%, var(--checker-bg) 50%) 0% 50% / var(--checker-size) var(--checker-size) !important', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, borderRadius: semanticRadii.controlSm, },})
const colorPickerSwatchSizes = stylex.create({ '2xs': { '--swatch-size': sizes._4_5, }, xs: { '--swatch-size': sizes._5, }, sm: { '--swatch-size': sizes._6, }, md: { '--swatch-size': sizes._7, }, lg: { '--swatch-size': sizes._7, }, xl: { '--swatch-size': sizes._8, }, '2xl': { '--swatch-size': sizes._10, },})
const colorPickerSwatchIndicator = stylex.create({ base: { color: colors.white, borderRadius: radii.full, },})
const colorPickerChannelSliderThumb = stylex.create({ base: { borderRadius: radii.full, height: 'var(--thumb-size)', width: 'var(--thumb-size)', borderWidth: '2px', borderColor: colors.white, boxShadow: semanticShadows.sm, transform: 'translate(-50%, -50%)', outlineWidth: { default: null, ':focus-visible': '1px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, focusRingOffset: '1px', },})
const colorPickerChannelInput = stylex.create({ base: { width: '100%', minWidth: 'var(--input-height)', outline: 'none', position: 'relative', appearance: 'none', textAlign: 'start', opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, cursor: { default: null, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, height: 'var(--input-height)', '--focus-color': colorPalette.focusRing, '--error-color': semanticColors.borderError, '--focus-ring-color': { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, borderColor: { default: null, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, },})
const colorPickerChannelInputSizes = stylex.create({ '2xs': { borderRadius: semanticRadii.control, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, paddingInline: spacing._2, '--input-height': sizes.controlXs, }, xs: { borderRadius: semanticRadii.control, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, paddingInline: spacing._2, '--input-height': sizes.controlXs, }, sm: { borderRadius: semanticRadii.control, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInline: spacing._2_5, '--input-height': sizes.controlSm, }, md: { borderRadius: semanticRadii.control, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, paddingInline: spacing._3, '--input-height': sizes.controlMd, }, lg: { borderRadius: semanticRadii.control, fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: spacing._4_5, '--input-height': sizes.controlLg, }, xl: { borderRadius: semanticRadii.control, fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: spacing._6, '--input-height': sizes.controlXl, }, '2xl': { borderRadius: semanticRadii.control, fontSize: fontSizes.md, lineHeight: lineHeights.md, paddingInline: spacing._6, '--input-height': sizes.controlXl, },})
const colorPickerChannelInputVariants = stylex.create({ outline: { backgroundColor: semanticColors.bg, borderWidth: '1px', borderColor: { default: semanticColors.border, ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', ':hover': { default: semanticColors.borderEmphasized, ':focus-visible': 'var(--focus-ring-color)', }, }, outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, focusRingWidth: 0, }, subtle: { borderWidth: '1px', borderColor: { default: 'transparent', ':is([data-invalid], [aria-invalid="true"])': 'var(--error-color)', }, backgroundColor: semanticColors.bgMuted, outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, },})
const colorPickerFormatSelect = stylex.create({ base: { fontSize: fontSizes.xs, lineHeight: lineHeights.xs, textTransform: 'uppercase', borderWidth: '1px', minHeight: sizes._6, outlineWidth: { default: null, ':focus': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus': '0px', }, outlineStyle: { default: null, ':focus': 'solid', }, outlineColor: { default: null, ':focus': colorPalette.focusRing, }, borderRadius: semanticRadii.controlMd, },})
const colorPickerTransparencyGrid = stylex.create({ base: { borderRadius: semanticRadii.controlMd, },})
const colorPickerView = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._2, },})
export type ColorPickerSize = keyof typeof colorPickerTriggerSizes
export type ColorPickerVariant = keyof typeof colorPickerTriggerVariants
const colorPickerRecipe = defineSlotRecipe({ slots: { root: { styles: colorPickerRoot }, label: { styles: colorPickerLabel }, valueText: { styles: colorPickerValueText }, control: { styles: colorPickerControl }, swatchTrigger: { styles: colorPickerSwatchTrigger }, trigger: { styles: colorPickerTrigger, sizes: colorPickerTriggerSizes, variants: colorPickerTriggerVariants, }, swatchGroup: { styles: colorPickerSwatchGroup }, swatch: { styles: colorPickerSwatch, sizes: colorPickerSwatchSizes }, swatchIndicator: { styles: colorPickerSwatchIndicator }, }, defaultVariants: { size: "md", variant: "outline" },});
const chrome = stylex.create({ btnReset: { appearance: "none", backgroundColor: "transparent", borderWidth: 0, borderStyle: "none", padding: 0, margin: 0, fontFamily: "inherit", fontSize: "inherit", color: "inherit", cursor: "pointer", textAlign: "inherit", }, swatchRing: { outlineWidth: { default: null, ":is([data-checked])": "2px" }, outlineStyle: { default: null, ":is([data-checked])": "solid" }, outlineColor: { default: null, ":is([data-checked])": semanticColors.fg }, outlineOffset: { default: null, ":is([data-checked])": "1px" }, },});
function CheckIcon(props: React.ComponentProps<"svg">) { return ( <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <path d="m3.5 8.5 3 3 6-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}
interface ColorPickerContextValue { value: string; select: (value: string) => void;}
const ColorPickerContext = createContext<ColorPickerContextValue>({ value: "#000000", select: () => {},});
const SwatchTriggerContext = createContext<{ value: string; selected: boolean }>({ value: "", selected: false,});
const { withProvider, withContext } = createSlotRecipeContext(colorPickerRecipe);
export type ColorPickerVariantProps = Partial<SlotRecipeVariants<typeof colorPickerRecipe>>;
function RootNode({ render, value, defaultValue = "#000000", onValueChange, ...rest}: useRender.ComponentProps<"div"> & { value?: string; defaultValue?: string; onValueChange?: (value: string) => void;}) { const [inner, setInner] = useState(defaultValue); const current = value !== undefined ? value : inner; const ctx = useMemo<ColorPickerContextValue>( () => ({ value: current, select: (next: string) => { setInner(next); onValueChange?.(next); }, }), [current, onValueChange], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: rest, }); return <ColorPickerContext.Provider value={ctx}>{element}</ColorPickerContext.Provider>;}
function TriggerNode({ render, ...rest }: useRender.ComponentProps<"button">) { return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps({ type: "button" as const }, rest), });}
function ValueTextNode({ render, children, ...rest}: useRender.ComponentProps<"span">) { const ctx = useContext(ColorPickerContext); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, props: mergeProps({ children: children ?? ctx.value }, rest), });}
function SwatchNode({ render, value, style, ...rest}: useRender.ComponentProps<"span"> & { value?: string }) { const ctx = useContext(ColorPickerContext); const trigger = useContext(SwatchTriggerContext); const color = value ?? (trigger.value || ctx.value); return useRender({ defaultTagName: "span", render: render as useRender.RenderProp, props: mergeProps( { "data-checked": trigger.selected ? "" : undefined, style: { ...(style as React.CSSProperties), "--color": color } as React.CSSProperties, }, rest, ), });}
function SwatchTriggerNode({ render, value, onClick, ...rest}: useRender.ComponentProps<"button"> & { value: string }) { const ctx = useContext(ColorPickerContext); const selected = ctx.value.toLowerCase() === value.toLowerCase(); const triggerCtx = useMemo(() => ({ value, selected }), [value, selected]); const element = useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps( { type: "button" as const, "aria-pressed": selected, "data-checked": selected ? "" : undefined, onClick: (event: React.MouseEvent<HTMLButtonElement>) => { (onClick as ((event: React.MouseEvent<HTMLButtonElement>) => void) | undefined)?.(event); ctx.select(value); }, }, rest, ), }); return ( <SwatchTriggerContext.Provider value={triggerCtx}>{element}</SwatchTriggerContext.Provider> );}
function SwatchIndicatorNode({ children, ...props}: React.ComponentProps<"span">) { const { selected } = useContext(SwatchTriggerContext); if (!selected) return null; return <span {...props}>{children ?? <CheckIcon />}</span>;}
/** * ColorPicker — swatch-based color selection. Base UI ships no color-picker * machine, so Root owns the color value (controlled via * `value`/`onValueChange` or uncontrolled via `defaultValue`). * `<ColorPicker.Root variant size>` resolves the recipe once; every part * inherits it through context. The selected SwatchTrigger stamps * `data-checked` so Swatch rings via `:is([data-checked])` and * SwatchIndicator reveals. Composition per part via `render`. */export const ColorPicker = { Root: withProvider(RootNode, "root", "ColorPickerRoot"), Label: withContext("span", "label", "ColorPickerLabel"), Control: withContext("div", "control", "ColorPickerControl"), Trigger: withContext( TriggerNode, (v) => [chrome.btnReset, ...resolveSlotStyles(colorPickerRecipe.slots.trigger, v)], "ColorPickerTrigger", ), ValueText: withContext(ValueTextNode, "valueText", "ColorPickerValueText"), Swatch: withContext( SwatchNode, (v) => [...resolveSlotStyles(colorPickerRecipe.slots.swatch, v), chrome.swatchRing], "ColorPickerSwatch", ), SwatchGroup: withContext("div", "swatchGroup", "ColorPickerSwatchGroup"), SwatchTrigger: withContext( SwatchTriggerNode, (v) => [chrome.btnReset, ...resolveSlotStyles(colorPickerRecipe.slots.swatchTrigger, v)], "ColorPickerSwatchTrigger", ), SwatchIndicator: withContext( SwatchIndicatorNode, "swatchIndicator", "ColorPickerSwatchIndicator", ),};
export type ColorPickerRootProps = React.ComponentProps<typeof ColorPicker.Root>;export type ColorPickerSwatchProps = React.ComponentProps<typeof ColorPicker.Swatch>;export type ColorPickerSwatchTriggerProps = React.ComponentProps<typeof ColorPicker.SwatchTrigger>;