component — color

Color Thumb

The draggable handle inside a color area or color wheel, and the component you pass as ColorArea's children to replace the default one. Built on react-aria-components and styled with the quebi design system: the picked color is its fill, focus grows it and adds the focus ring.

  • form
  • color
  • picker
  • thumb
  • handle
  • interactive

02 examples

The default handle, and one of your own

A ColorThumb only means anything inside a color picker, and the reason to reach for it directly is ColorArea's `children`: the area renders `{children ?? <ColorThumb />}`, so a thumb you pass replaces its default rather than joining it. On the left the area draws that default; on the right the same component is larger, square-cornered and ringed in the focus ink. Both grow and take the focus ring when you tab to them.

The area's default
A thumb you pass

Disabled, without dimming twice

A disabled ColorArea mutes its whole subtree with `opacity-50`, and ColorThumb carries a `disabled:opacity-50` of its own for the surfaces that do not — compounded, a nested thumb lands at a quarter and reads as broken rather than disabled. So a thumb passed to an area takes `disabled:opacity-100`, which is exactly what the area's own default carries. Here the fill stays legible under the muted gradient.

source

color-thumb.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  ColorThumb as ColorThumbPrimitive,
  type ColorThumbProps,
  composeRenderProps,
} from "react-aria-components"
import { cn } from "@/lib/utils"

/**
 * ColorThumb — quebi design system
 *
 * The draggable handle inside a ColorArea or a ColorWheel. Built on
 * react-aria-components. Round by nature: a white border so the picked color
 * (user data, left untouched) reads on any field, inside a muted-ink ring.
 * Focus grows the thumb and swaps the ring for the focus ink.
 *
 * It is a slot, not only a fixture: `ColorArea` renders its `children` and falls
 * back to this thumb, so styling the handle means passing one of these with a
 * `className` rather than reaching past the area. A thumb nested in an area wants
 * `disabled:opacity-100` — the area's own `disabled:opacity-50` already covers
 * its subtree, and the two compound to a quarter.
 *
 * `ColorSlider` does *not* use this component: its track is 1.5rem tall, so its
 * handle is the smaller `ColorSliderThumb` declared beside it.
 */
export function ColorThumb({ className, ...props }: ColorThumbProps) {
  return (
    <ColorThumbPrimitive
      {...props}
      className={composeRenderProps(className, (resolved) =>
        cn(
          "top-[50%] left-[50%] size-6 rounded-full border-2 border-white",
          "ring-1 ring-quebi-fg-muted",
          "transition-[width,height] duration-150",
          "focus-visible:size-8 focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-0",
          "disabled:opacity-50 disabled:forced-colors:border-[GrayText] disabled:forced-colors:bg-[GrayText]",
          "forced-colors:border-[ButtonBorder]",
          resolved,
        ),
      )}
    />
  )
}