ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

With Marks

Vertical with Marks

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]).