Skip to main content

Slider

Choose a number or range by moving one or more thumbs.

Use Slider when a numeric value or range is best chosen spatially. Pair it with a labeled numeric field when users need precise text entry.

Basic usage#

import { Field, Slider } from 'moraine'

export function Example() {
  return (
    <Field label="Volume">
      <Slider defaultValue={50} />
    </Field>
  )
}

Playground#

Volume60.00%
Props
Slots

Anatomy#

Slider [component; slot=root]
├── track [slot]
│   ├── range [slot]
│   └── marker [slot]
└── thumb [slot]

Usage#

Single values and ranges#

Use a scalar value for a single thumb and an ordered pair for a range. Pair value with onValueChange for controlled state; onValueChange also reports immediate updates during drag when the slider owns its value.

import { Slider } from 'moraine'
import { createSignal } from 'solid-js'

export function Values() {
  const [singleVal, setSingleVal] = createSignal(40)
  const [rangeVal, setRangeVal] = createSignal<[number, number]>([20, 80])

  return (
    <div class="max-w-md w-full space-y-6">
      <div class="space-y-2">
        <div class="text-xs text-muted-foreground flex justify-between">
          <span>Single volume</span>
          <span class="text-foreground font-mono">{singleVal()}%</span>
        </div>
        <Slider value={singleVal()} onValueChange={setSingleVal} />
      </div>

      <div class="space-y-2">
        <div class="text-xs text-muted-foreground flex justify-between">
          <span>Budget range</span>
          <span class="text-foreground font-mono">
            ${rangeVal()[0]} - ${rangeVal()[1]}
          </span>
        </div>
        <Slider value={rangeVal()} onValueChange={setRangeVal} />
      </div>
    </div>
  )
}

Step and markers#

Set step to snap pointer drag and keyboard navigation to fixed increments. Enable marker to render visual tick marks at each step interval across the track rail.

import { Slider } from 'moraine'
import { createSignal } from 'solid-js'

export function StepMarker() {
  const [value, setValue] = createSignal(40)
  const [range, setRange] = createSignal([20, 60])

  return (
    <div class="max-w-md w-full space-y-6">
      <div class="space-y-2">
        <div class="text-xs text-muted-foreground flex justify-between">
          <span>Stepped single (step = 10)</span>
          <span class="text-foreground font-mono">{value()}</span>
        </div>
        <Slider
          value={value()}
          min={0}
          max={100}
          step={10}
          marker
          onValueChange={(val) => {
            if (typeof val === 'number') {
              setValue(val)
            }
          }}
        />
      </div>

      <div class="space-y-2">
        <div class="text-xs text-muted-foreground flex justify-between">
          <span>Stepped range with bold track (step = 20)</span>
          <span class="text-foreground font-mono">
            {range()[0]} - {range()[1]}
          </span>
        </div>
        <Slider
          defaultValue={[20, 60]}
          value={range()}
          min={0}
          max={100}
          step={20}
          marker
          variant="bold"
          onValueChange={(val) => {
            if (Array.isArray(val)) {
              setRange(val)
            }
          }}
        />
      </div>
    </div>
  )
}

Keyboard and forms#

Add name for native form submission, or place the slider inside form.Field from createForm for schema binding. Use standalone Field when you only need a label and messages.

import { Slider } from 'moraine'

export function KeyboardForms() {
  return (
    <div class="max-w-md w-full">
      <Slider name="brightness" defaultValue={75} step={5} min={0} max={100} />
    </div>
  )
}

Keyboard interaction#

When step is omitted, keyboard stepping defaults to 1% of the configured range. Page-sized movement is approximately 10% of the range and is snapped to step when one is defined.

Key Description
→ / ↑ Moves in the increasing direction by one keyboard step.
← / ↓ Moves in the decreasing direction by one keyboard step.
Shift + Arrow Moves by the page-sized increment.
⇞ / ⇟ Moves by the page-sized increment.
↖ Moves the focused thumb to its minimum permitted value.
↘ Moves the focused thumb to its maximum permitted value.

Examples#

Controlled value#

import { Button, Icon, Slider } from 'moraine'
import { createSignal } from 'solid-js'

export function ControlledSingle() {
  const [volume, setVolume] = createSignal(75)

  const volumeIcon = () => {
    if (volume() === 0) {
      return 'i-lucide:volume-x'
    }
    if (volume() < 50) {
      return 'i-lucide:volume-1'
    }
    return 'i-lucide:volume-2'
  }

  return (
    <div class="p-4 b-1 b-border rounded-xl max-w-md w-full space-y-2">
      <div class="flex items-center justify-between">
        <div class="flex gap-2 items-center">
          <Button
            variant="ghost"
            size="sm"
            class="p-0 size-8"
            onClick={() => setVolume((v) => (v === 0 ? 75 : 0))}
            aria-label="Mute / Unmute"
          >
            <Icon name={volumeIcon()} class="size-4" />
          </Button>
          <span class="text-sm font-medium">Output Volume</span>
        </div>
        <span class="text-xs text-primary font-mono font-semibold">{volume()}%</span>
      </div>

      <Slider value={volume()} min={0} max={100} step={1} onValueChange={setVolume} class="mb-2" />
    </div>
  )
}

Range#

import { Checkbox, Slider } from 'moraine'
import { createSignal } from 'solid-js'

export function RangeSlider() {
  const [rangeValue, setRangeValue] = createSignal<number[]>([20, 75])
  const [minStepsBetweenThumbs, setMinStepsBetweenThumbs] = createSignal(0)
  const [allowThumbCrossing, setAllowThumbCrossing] = createSignal(true)

  return (
    <div class="max-w-xl space-y-3">
      <Checkbox
        checked={allowThumbCrossing()}
        onCheckedChange={setAllowThumbCrossing}
        label="Allow dragging across overlapping thumbs"
      />
      <Checkbox
        checked={minStepsBetweenThumbs() > 0}
        onCheckedChange={(isChecked) => setMinStepsBetweenThumbs(isChecked ? 10 : 0)}
        label="Min steps between thumbs"
      />
      <Slider
        value={rangeValue()}
        min={0}
        max={100}
        step={1}
        minStepsBetweenThumbs={minStepsBetweenThumbs()}
        allowThumbCrossing={allowThumbCrossing()}
        onValueChange={(next) => {
          if (Array.isArray(next)) {
            setRangeValue(next)
          }
        }}
      />
      <p class="text-xs text-muted-foreground w-50">
        Range: {rangeValue()[0]} - {rangeValue()[1]}
      </p>
      <p class="text-xs text-muted-foreground w-50">
        Thumb crossing:{' '}
        {allowThumbCrossing() && minStepsBetweenThumbs() === 0 ? 'Enabled' : 'Constrained'}
      </p>
      <p class="text-xs text-muted-foreground w-50">Min steps between: {minStepsBetweenThumbs()}</p>
    </div>
  )
}

Form integration#

import { Button, createForm, Slider } from 'moraine'
import type { SliderT } from 'moraine'
import { createSignal, untrack } from 'solid-js'
import * as v from 'valibot'

export function FormIntegration() {
  const [formState, setFormState] = createSignal({
    volume: 10,
  })

  const updateFormVolume = (nextValue: SliderT.Value) => {
    const next = Array.isArray(nextValue) ? (nextValue[0] ?? 0) : nextValue
    setFormState((prev) => ({ ...prev, volume: next }))
  }
  const form = createForm({
    schema: v.object({ volume: v.pipe(v.number(), v.minValue(20, 'Volume must be at least 20.')) }),
    initialInput: untrack(formState),
    validate: 'input',
  })

  return (
    <form.Form>
      <div class="max-w-xl space-y-4">
        <form.Field name="volume" label="Volume" description="Keep it at least 20.">
          <Slider value={formState().volume} onValueChange={updateFormVolume} />
        </form.Field>
        <div class="flex gap-3 items-center">
          <Button type="submit" variant="secondary" size="sm">
            Validate
          </Button>
          <p class="text-xs text-muted-foreground">Current volume: {formState().volume}</p>
        </div>
      </div>
    </form.Form>
  )
}

Blur slider#

import { Slider } from 'moraine'
import { createSignal } from 'solid-js'

export function BlurSlider() {
  const [value, setValue] = createSignal(32)

  return (
    <div class="py-6 flex w-full justify-center">
      <Slider
        variant="bold"
        value={value()}
        onValueChange={(val: number | number[]) => {
          if (typeof val === 'number') {
            setValue(val)
          }
        }}
        min={0}
        max={100}
        step={1}
        aria-label="Blur"
        class="h-12 max-w-md w-full isolate"
        style={{ '--s-size': '48px', '--s-len': '28px', '--s-offset': '4px' }}
        classes={{
          track: 'rounded-xl',
          thumb: 'rounded-xl',
        }}
      >
        <div class="text-sm font-semibold px-5 flex pointer-events-none items-center inset-0 justify-between absolute">
          <span class="text-white mix-blend-difference">Blur</span>
          <span class="text-white font-mono mix-blend-difference">{Math.round(value())}px</span>
        </div>
      </Slider>
    </div>
  )
}

Attributes#

Attributes
data-disabledSlot: slider, slider-thumbDescription: Present when the component, slot, or item is disabled.
data-draggingSlot: slider, slider-thumbDescription: Present while the related thumb or handle is being dragged.
data-invalidSlot: slider, slider-thumbDescription: Present when the field or form has a validation error.
data-invertedSlot: slider, slider-range, slider-thumbDescription: Present when the component direction or range is inverted.
data-multipleSlot: slider, slider-rangeDescription: Present when the control contains or selects multiple values.
data-readonlySlot: slider, slider-thumbDescription: Present when the field is in read-only mode.
data-requiredSlot: slider, slider-thumbDescription: Present when the field input is required.

Props#

Props for the Slider component. Renders a <div> element by default.

Prop