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/vanillaPin @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
render Components::UI::ColorPicker.new(value: "#3b82f6", name: "brand") do
render Components::UI::ColorPickerControl.new do
render Components::UI::ColorPickerTrigger.new do
render Components::UI::ColorPickerValueSwatch.new
render Components::UI::ColorPickerValueText.new
end
end
render Components::UI::ColorPickerContent.new do
render Components::UI::ColorPickerArea.new
render Components::UI::ColorPickerChannelSlider.new(channel: "hue")
render Components::UI::ColorPickerChannelSlider.new(channel: "alpha")
render Components::UI::ColorPickerChannelInput.new(channel: "hex")
render Components::UI::ColorPickerSwatchGroup.new do
%w[#ef4444 #f59e0b #10b981 #3b82f6 #8b5cf6].each do |hex|
render Components::UI::ColorPickerSwatch.new(value: hex)
end
end
end
end
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("✓")) }
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.