Slider
Value picker with single thumb, range mode, and vertical orientation.
Installation
bin/rails g wabi:add slider
bin/importmap pin @zag-js/slider
bin/importmap pin @zag-js/vanillaPin @zag-js/slider 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::Slider.new(name: "volume", value: 50, min: 0, max: 100) do
render Components::UI::SliderLabel.new { "Volume" }
render Components::UI::SliderControl.new do
render Components::UI::SliderTrack.new do
render Components::UI::SliderRange.new
end
render Components::UI::SliderThumb.new(index: 0)
end
end
# Range
render Components::UI::Slider.new(name: "price", value: [20, 80]) do
render Components::UI::SliderLabel.new { "Price Range" }
render Components::UI::SliderControl.new do
render Components::UI::SliderTrack.new do
render Components::UI::SliderRange.new
end
render Components::UI::SliderThumb.new(index: 0)
render Components::UI::SliderThumb.new(index: 1)
end
end
With Marks
0%
50%
100%
render Components::UI::Slider.new(name: "volume_marks", value: 50, min: 0, max: 100) do
render Components::UI::SliderLabel.new { "Volume" }
render Components::UI::SliderControl.new(marks: [{ value: 0, label: "0%" }, { value: 50, label: "50%" }, { value: 100, label: "100%" }]) do
render Components::UI::SliderTrack.new do
render Components::UI::SliderRange.new
end
render Components::UI::SliderThumb.new(index: 0)
end
end
Vertical with Marks
Low
Mid
High
render Components::UI::Slider.new(name: "level", value: 50, orientation: :vertical) do
render Components::UI::SliderLabel.new { "Level" }
render Components::UI::SliderControl.new(orientation: :vertical, marks: [{ value: 0, label: "Low" }, { value: 50, label: "Mid" }, { value: 100, label: "High" }]) do
render Components::UI::SliderTrack.new do
render Components::UI::SliderRange.new
end
render Components::UI::SliderThumb.new(index: 0)
end
end
Source
app/components/ui/slider.rb
# frozen_string_literal: true
require "json"
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class Slider < Wabi::Base
def initialize(name: nil, value:, min: 0, max: 100, step: 1, orientation: :horizontal, disabled: false, **attrs)
@name = name
@value = Array(value).compact
@min = min
@max = max
@step = step
@orientation = orientation
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
**@attrs,
data: {
controller: "wabi--slider",
"wabi--slider-name-value": @name,
"wabi--slider-value-value": @value.to_json,
"wabi--slider-min-value": @min.to_s,
"wabi--slider-max-value": @max.to_s,
"wabi--slider-step-value": @step.to_s,
"wabi--slider-orientation-value": @orientation.to_s,
"wabi--slider-disabled-value": @disabled.to_s,
},
class: merge_class(layout_class, user_class)
) do
yield if block
end
end
private
def layout_class
@orientation == :vertical ? "relative flex flex-col items-center h-48 w-5" : "relative flex flex-col w-full"
end
end
end
end
app/components/ui/slider_label.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class SliderLabel < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
label(
data: { "wabi--slider-target": "label" },
class: merge_class("text-sm font-medium leading-none mb-2", user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/slider_control.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
# Positioning context for the track + thumb(s). The root is a flex column
# (label on top, marks below), so thumbs — which Zag positions absolutely —
# need a wrapper whose size matches the track's, otherwise they anchor to
# the tall root and float off the bar. No overflow-hidden, so the thumb knob
# can extend past the thin track.
#
# Horizontal: a `flex items-center w-full` row whose height collapses to the
# track's. Vertical: h-full flex-col fills the root's height — otherwise
# the control collapses to height 0 and the vertical track (h-full) renders
# 0px tall.
#
# Marks render INSIDE the control (the track's positioning context) and are
# absolutely positioned relative to it: horizontal marks sit just below the
# track (top-full), vertical marks sit beside it (left-full, inset-y-0).
# Pass marks: (Array of Hashes {value:, label:} or bare integers) and
# orientation: (:horizontal/:vertical, default :horizontal) to enable.
class SliderControl < Wabi::Base
def initialize(orientation: :horizontal, marks: [], **attrs)
@vertical = orientation.to_sym == :vertical
@marks = Array(marks).map { |m| m.is_a?(Hash) ? m : { value: m, label: nil } }
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Base control classes — built from @vertical so we know orientation at
# SSR without relying on data-[orientation=vertical]: CSS variants.
base = "relative flex w-full items-center"
base += " h-full flex-col" if @vertical
# Reserve space for the absolutely-positioned marker group so it doesn't
# overlap content outside the control.
if @marks.any?
base += @vertical ? " mr-12" : " mb-8"
end
div(
data: { "wabi--slider-target": "control" },
class: merge_class(base, user_class)
) do
yield if block_given?
if @marks.any?
# Zag's getMarkerGroupProps spreads `position:relative` inline, which
# would override an `absolute` Tailwind class and collapse the group to
# zero size. To keep our absolute-positioning intact we wrap the Zag
# target in an outer div that does the absolute placement, then let the
# inner Zag target be `relative w-full h-full` so it fills the wrapper.
outer_class = @vertical \
? "absolute left-full inset-y-0 ml-2 pointer-events-none" \
: "absolute top-full left-0 w-full mt-1 pointer-events-none"
inner_class = "relative w-full h-full"
marker_class = @vertical \
? "absolute flex items-center" \
: "absolute flex flex-col items-center"
tick_class = @vertical \
? "block h-0.5 w-2 rounded-full bg-muted-foreground" \
: "block h-2 w-0.5 rounded-full bg-muted-foreground"
label_class = @vertical \
? "ml-2 block text-xs text-muted-foreground whitespace-nowrap" \
: "mt-1 block text-xs text-muted-foreground whitespace-nowrap"
div(class: outer_class) do
div(data: { "wabi--slider-target": "markerGroup" }, class: inner_class) do
@marks.each do |mark|
div(
data: { "wabi--slider-target": "marker", "wabi-mark-value": mark[:value].to_s },
class: marker_class
) do
span(class: tick_class) {}
span(class: label_class) { mark[:label] } if mark[:label]
end
end
end
end
end
end
end
end
end
end
app/components/ui/slider_track.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class SliderTrack < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--slider-target": "track" },
class: merge_class(
"relative h-2 w-full grow overflow-hidden rounded-full bg-secondary " \
"data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2",
user_class
)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/slider_range.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class SliderRange < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
# Cross-axis fill. Horizontal: h-full (Zag sets the width from the
# value). Vertical (Zag stamps data-orientation): w-full instead, and
# cancel h-full so Zag's top/bottom define the fill height — otherwise
# the range has zero width (invisible) and ignores the value.
div(
data: { "wabi--slider-target": "range" },
class: merge_class(
"absolute h-full bg-primary " \
"data-[orientation=vertical]:h-auto data-[orientation=vertical]:w-full",
user_class
)
)
end
end
end
end
app/components/ui/slider_thumb.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class SliderThumb < Wabi::Base
# Cross-axis centering on the rail. Horizontal: center vertically
# (top-1/2 -translate-y-1/2). Vertical (Zag stamps data-orientation):
# center horizontally instead (left-1/2 -translate-x-1/2), undoing the
# horizontal offsets.
THUMB_CLASS = "block h-3 w-3 rounded-full border border-primary bg-foreground shadow-sm " \
"top-1/2 -translate-y-1/2 " \
"data-[orientation=vertical]:top-auto data-[orientation=vertical]:left-1/2 " \
"data-[orientation=vertical]:translate-y-0 data-[orientation=vertical]:-translate-x-1/2 " \
"ring-offset-background transition-colors motion-reduce:transition-none " \
"focus-visible:outline-none focus-visible:ring-2 " \
"focus-visible:ring-ring focus-visible:ring-offset-2 " \
"disabled:pointer-events-none disabled:opacity-50"
def initialize(index:, **attrs)
@index = index
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
span(
role: "slider",
data: {
"wabi--slider-target": "thumb",
"wabi-index": @index.to_s,
},
class: merge_class(THUMB_CLASS, user_class)
)
end
end
end
end
Accessibility
- role="slider" per thumb with aria-valuemin / aria-valuemax / aria-valuenow.
- Arrow keys (←/→/↑/↓) adjust the focused thumb by one step.
- Home / End jump the focused thumb to the min / max.
- PageUp / PageDown move the focused thumb by a larger step (Zag default = 10× step).
- Single-thumb sliders submit as `name=<value>`. Range sliders submit as `name[min]=...&name[max]=...`, which Rails parses to nested params (e.g. params[:price][:min]).