component — color

Color Slider

Single-channel color slider built on react-aria-components, styled with the quebi design system. Adjusts one channel (hue, saturation, lightness, or alpha) along a live gradient track with a draggable thumb and optional value output.

  • form
  • input
  • color
  • slider
  • interactive

05 examples

Hue

Adjust a single channel along a live gradient track.

Hue
200°

Alpha

The checkerboard shows transparency through the alpha channel.

Alpha
50 %

Disabled

Hue
280°

Vertical

Composed from track, thumb, and output sub-components.

140°

Controlled

Saturation
100 %

hsl(200, 100%, 50%)

source

color-slider.tsx

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

"use client"

import type {
  ColorSliderProps as PrimitiveColorSliderProps,
  SliderOutputProps,
  SliderTrackProps,
} from "react-aria-components"
import { use } from "react"
import {
  ColorSlider as PrimitiveColorSlider,
  ColorThumb,
  composeRenderProps,
  SliderOutput,
  SliderStateContext,
  SliderTrack,
} from "react-aria-components"
import { cn } from "@/lib/utils"

/**
 * ColorSlider — quebi design system
 *
 * Built on react-aria-components. A single-channel color slider (hue,
 * saturation, lightness, alpha, …) with a live gradient track, an optional
 * label/output row, and a self-contained draggable thumb. The gradient track
 * is user data; the chrome (label, output, focus ring, thumb) is restyled to
 * quebi tokens. Disabled keeps the gradient and mutes it with `opacity-50`.
 *
 * The slider's length along its own axis is a default, not a fixture: a
 * horizontal slider fills its container and a vertical track is 14rem tall
 * until `className` says otherwise. Those defaults are picked off the
 * `orientation` prop rather than written as `orientation-*:` variants, because
 * a variant compiles to `.orientation-horizontal\:w-full[data-orientation=…]`
 * — specificity (0,2,0) against a consumer's bare `.w-64` (0,1,0), which
 * tailwind-merge keeps (different group) and CSS then resolves in the
 * library's favour. The consumer's class loses in silence. Same trap as the
 * one `Slider` fell into (tasks #114, #151). The one variant left is the
 * grid/flex switch, which is not a thing a consumer overrides.
 *
 * A vertical track used to carry `ms-[50%] -translate-x-[50%]` on top of the
 * root's `items-center`. Two centrings do not compose: a 50% inline margin
 * makes the margin box half the root wider than the line it is centred in, so
 * the painted track ended up 6px left of the root and overhanging its edge
 * (measured, task #131). `items-center` alone is exact.
 */
export interface ColorSliderProps extends PrimitiveColorSliderProps {
  /** Optional eyebrow label rendered above the track. */
  label?: React.ReactNode
}

export function ColorSlider({
  className,
  label,
  children,
  orientation = "horizontal",
  ...props
}: ColorSliderProps) {
  return (
    <PrimitiveColorSlider
      data-slot="control"
      orientation={orientation}
      className={composeRenderProps(className, (resolved) =>
        cn(
          "orientation-vertical:flex orientation-horizontal:grid",
          "grid-cols-[1fr_auto] flex-col items-center gap-2",
          // Plain utility, not `orientation-horizontal:w-full` — see the docblock.
          // Vertical gets no width of its own: the track carries the size there,
          // and the root is a bare column around it.
          orientation === "horizontal" && "w-full",
          resolved,
        ),
      )}
      {...props}
    >
      {children ?? (
        <>
          {label != null && (
            <span className="quebi-eyebrow orientation-vertical:hidden col-span-2 self-start">
              {label}
            </span>
          )}
          <ColorSliderTrack>
            <ColorSliderThumb />
          </ColorSliderTrack>
          <ColorSliderOutput />
        </>
      )}
    </PrimitiveColorSlider>
  )
}

export function ColorSliderOutput({ className, ...props }: SliderOutputProps) {
  return (
    <SliderOutput
      className={composeRenderProps(className, (resolved) =>
        cn(
          "orientation-vertical:hidden font-mono text-quebi-caption text-quebi-fg-subtle tabular-nums",
          resolved,
        ),
      )}
      {...props}
    />
  )
}

export function ColorSliderTrack({ className, ...props }: SliderTrackProps) {
  // The orientation react-aria resolved, not one this component was told: the
  // track is exported on its own and composed inside `<ColorSlider>`, so the
  // prop lives on the root. `SliderStateContext` is what `SliderTrack` itself
  // reads, and `ColorSlider` provides it.
  const isVertical = use(SliderStateContext)?.orientation === "vertical"
  return (
    <SliderTrack
      className={composeRenderProps(className, (resolved) =>
        cn(
          // An inset ring rather than a border: react-aria's inline `background`
          // shorthand resets `background-clip` to `border-box`, so a border is
          // composited over the track's own gradient and thins out where it
          // meets the extremes. A ring is a box-shadow painted above it.
          "group col-span-2 rounded-(--q-radius-control) inset-ring-1 inset-ring-quebi-hairline",
          // Plain utilities so a consumer's `h-80` / `w-40` can win — see the
          // docblock on ColorSlider (task #151).
          isVertical ? "h-56 w-6" : "h-6 w-full",
          "disabled:opacity-50 disabled:forced-colors:bg-[GrayText]",
          resolved,
        ),
      )}
      {...props}
      style={({ defaultStyle }) => ({
        ...defaultStyle,
        background: `${defaultStyle.background}, repeating-conic-gradient(#262b30 0% 25%, #1a1e22 0% 50%) 50% / 16px 16px`,
      })}
    />
  )
}

export function ColorSliderThumb({ className }: { className?: string }) {
  return (
    <ColorThumb
      className={cn(
        // Both axes, always. react-aria inlines a percentage on the *main*
        // axis only (`left` when horizontal, `top` when vertical) and leaves
        // the cross axis to the class list; `translate(-50%, -50%)` then
        // centres the thumb on that point. With `left-[50%]` missing, a
        // vertical thumb fell back to `left: auto` and landed on the track's
        // start edge (task #131). This matches the shared ColorThumb.
        "top-[50%] left-[50%] size-5 rounded-full border-2 border-white ring-1 ring-quebi-fg-muted",
        "transition-[box-shadow] duration-150",
        "data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-offset-2 data-[focus-visible]:ring-offset-quebi-bg",
        "data-[dragging]:scale-110",
        className,
      )}
    />
  )
}