ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Color Picker

Popover color picker with a saturation/lightness area, hue and alpha sliders, a hex input, and preset swatches.

Installation

bin/rails g wabi:add color_picker
bin/importmap pin @zag-js/color-picker
bin/importmap pin @zag-js/color-utils
bin/importmap pin @zag-js/vanilla

Pin @zag-js/color-picker, @zag-js/color-utils, and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.

Example

Source

app/components/ui/color_picker.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPicker < Wabi::Base
      def initialize(id: nil, value: "#000000", format: :rgba, name: nil, **attrs)
        @id     = id
        @value  = value
        @format = format
        @name   = name
        @attrs  = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          id: @id,
          data: {
            controller: "wabi--color-picker",
            "wabi--color-picker-value-value":  @value.to_s,
            "wabi--color-picker-format-value": @format.to_s,
            "wabi--color-picker-name-value":   @name.to_s,
          },
          class: merge_class("relative inline-block", user_class)
        ) do
          yield if block_given?
          if @name
            input(
              type: "hidden",
              name: @name,
              value: @value.to_s,
              "aria-label": "Color value",
              data: { "wabi--color-picker-target": "hiddenInput" }
            )
          end
        end
      end
    end
  end
end

app/components/ui/color_picker_label.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerLabel < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        label(
          data: { "wabi--color-picker-target": "label" },
          class: merge_class("text-sm font-medium", user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/color_picker_control.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerControl < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--color-picker-target": "control" },
          class: merge_class("inline-flex items-center gap-2", user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/color_picker_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerTrigger < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        button(
          type: "button",
          data: { "wabi--color-picker-target": "trigger" },
          class: merge_class(
            "inline-flex items-center gap-2 rounded-md border border-input bg-background " \
            "px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground " \
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
            user_class
          )
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/color_picker_value_swatch.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerValueSwatch < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        span(
          data: { "wabi--color-picker-target": "valueSwatch" },
          aria: { hidden: "true" },
          class: merge_class("h-4 w-4 rounded border border-input", user_class)
        )
      end
    end
  end
end

app/components/ui/color_picker_value_text.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerValueText < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        span(
          aria_live: "polite",
          aria_atomic: "true",
          data: { "wabi--color-picker-target": "valueText" },
          class: merge_class("text-sm tabular-nums", user_class)
        )
      end
    end
  end
end

app/components/ui/color_picker_content.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerContent < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--color-picker-target": "positioner" },
          class: "z-50 pointer-events-none"
        ) do
          div(
            data: { "wabi--color-picker-target": "content" },
            "data-state": "closed",
            inert: true,
            class: merge_class(
              "z-50 w-64 rounded-md border border-input bg-popover p-3 text-popover-foreground shadow-md outline-none " \
              "transition-opacity duration-200 ease-out motion-reduce:transition-none " \
              "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none",
              user_class
            )
          ) do
            yield if block_given?
          end
        end
      end
    end
  end
end

app/components/ui/color_picker_area.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerArea < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--color-picker-target": "area" },
          class: merge_class("relative h-40 w-full rounded-md", user_class)
        ) do
          # Fill the area with h-full/w-full, NOT `absolute inset-0`: Zag's
          # getAreaBackgroundProps() sets an inline `position: relative` that
          # overrides Tailwind `absolute`, so `inset-0` would no longer stretch
          # this element and it collapses to height 0 (gradient invisible).
          div(
            data: { "wabi--color-picker-target": "areaBackground" },
            class: "h-full w-full rounded-md"
          )
          div(
            data: { "wabi--color-picker-target": "areaThumb" },
            class: "absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow focus-visible:ring-2 focus-visible:ring-white"
          )
        end
      end
    end
  end
end

app/components/ui/color_picker_channel_slider.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerChannelSlider < Wabi::Base
      # channel: "hue" | "alpha"
      def initialize(channel:, **attrs)
        @channel = channel
        @attrs   = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--color-picker-target": "channelSlider", "wabi-channel": @channel.to_s },
          class: merge_class("relative mt-3 h-3 w-full rounded-full", user_class)
        ) do
          div(
            data: { "wabi--color-picker-target": "channelSliderTrack", "wabi-channel": @channel.to_s },
            class: "absolute inset-0 rounded-full"
          )
          div(
            data: { "wabi--color-picker-target": "channelSliderThumb", "wabi-channel": @channel.to_s },
            class: "absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow focus-visible:ring-2 focus-visible:ring-white"
          )
        end
      end
    end
  end
end

app/components/ui/color_picker_channel_input.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerChannelInput < Wabi::Base
      def initialize(channel: "hex", **attrs)
        @channel = channel
        @attrs   = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        input(
          data: { "wabi--color-picker-target": "channelInput", "wabi-channel": @channel.to_s },
          class: merge_class(
            "mt-3 w-full rounded border border-input bg-background px-2 py-1 text-sm uppercase",
            user_class
          )
        )
      end
    end
  end
end

app/components/ui/color_picker_swatch_group.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerSwatchGroup < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--color-picker-target": "swatchGroup" },
          class: merge_class("mt-3 flex flex-wrap gap-1.5", user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/color_picker_swatch.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ColorPickerSwatch < Wabi::Base
      def initialize(value:, **attrs)
        @value = value
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        button(
          type: "button",
          data: { "wabi--color-picker-target": "swatch", "wabi-value": @value.to_s },
          class: merge_class("relative h-6 w-6 rounded border border-input", user_class)
        ) do
          div(
            data: { "wabi--color-picker-swatch": "bg" },
            class: "h-full w-full rounded"
          )
          div(
            data: { "wabi--color-picker-swatch": "indicator" },
            class: "absolute inset-0 grid place-items-center text-xs text-white"
          ) { raw(safe("&check;")) }
        end
      end
    end
  end
end

Accessibility

  • Area and channel sliders are keyboard-operable (arrow keys); the trigger exposes aria-expanded.
  • Set name: to submit the color as a hidden form field.