GrSlider

Package: @feugene/granularitycoreGroup: forms

Pick a number or a range by dragging a thumb, with steps, marks and keyboard support.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • an exact number does not matter — volume, brightness, opacity: “more or less” matters more;
  • the range is illustrativemin/max show the bounds, which are invisible in an input field;
  • a “from — to” range is neededrange gives two thumbs that do not pass through each other;
  • the scale has reference pointsmarks label the steps;
  • the value is applied on releaselazy does not send a request on every movement.

When to take something else

NeedTake
An exact number is neededGrNumberInput
A rating in starsGrRating
There are few steps and they are namedGrSegmented / GrRadioGroup
The value is shown rather than setGrProgressBar

The range

<GrSlider v-model="range" range :min="0" :max="100" aria-label="Budget" />

The model is a [lo, hi] tuple; the thumbs do not jump over each other. A click on the track moves the nearest thumb, and when both have converged into one point — the one in whose direction the click happened: otherwise a collapsed range could not be pulled apart with the mouse.

Every thumb gets a name of its own: ariaLabel plus the bound from the locale (gr.slider.min / gr.slider.max) — “Budget (minimum)”.

The orientation

<GrSlider v-model="volume" orientation="vertical" :style="{ '--gr-slider-length': '12rem' }" />

In a vertical track the minimum is at the bottom. The length is set by --gr-slider-length (10rem by default), and the thickness is still held by --gr-slider-track-height. The tooltip moves to the side — above the thumb it would lie on the track — and the labels of the marks stand on the right. aria-orientation follows the prop, and the keyboard does not change: / increase, / decrease.

When the value goes outside

lazy holds update:modelValue back until the end of a gesture: during the drag the thumb is run by an internal value, and one event goes out on release (together with change). That is for expensive recomputations that have no reason to fire on every movement of the mouse.

The keyboard commits at once even with lazy: a key press is discrete, and there is nothing to hold back.

A modelValue that did not arrive puts the thumb at min and is explained with a warning in dev mode: otherwise the offset and aria-valuenow would go to NaN, and a screen reader would read it aloud.

The marks and the tooltip

marks is a { [value]: label } dictionary or an array of values. The labels of the marks are hidden from a screen reader (aria-hidden): it reads the value from the thumb itself, and the labels would become random text inside the slider.

formatTooltip governs both the tooltip and aria-valuetext — “$1,200” instead of a bare “1200”. Without a format of your own aria-valuetext is not set: the number is self-sufficient.

Styling

size is xslg and is read from GrConfigProvider. Pointed customisation goes through the variables: --gr-slider-rail, --gr-slider-fill, --gr-slider-thumb-bg, --gr-slider-thumb-border, --gr-slider-thumb-size, --gr-slider-track-height, --gr-slider-length.

disabled dims the fill, the thumb and the labels of the marks with the --gr-disabled-* tokens rather than with transparency: opacity dilutes text colours tuned to AA.

The native form

The name prop renders an input[type="hidden"] with the value; with range there are two inputs with one name (lo, hi). What is serialised is the model (after snapping to step) rather than the draft of a gesture with lazy.

Playground 13

Loading…

Code
<GrSlider />

Install

npm i @feugene/granularity

Import

import { GrSlider } from '@feugene/granularity/components/GrSlider'

API

Props

PropTypedefaultDescription
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead only: the value is visible but does not change.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefinedThe name for a native form: a hidden input for the value, and with `range` two of them under one name.
maxnumber | undefined100
orientation"horizontal" | "vertical" | undefined"horizontal"The orientation of the track. In the vertical one the minimum is at the bottom.
lazyboolean | undefinedfalseEmit `update:modelValue` only at the end of the gesture: during the drag the value is run by the slider itself. The keyboard commits at once — a key press is discrete, and there is nothing to hold back.
stepnumber | undefined1
minnumber | undefined0
rangeboolean | undefinedfalseA range with two thumbs; the model is a `[lo, hi]` tuple.
marksGrSliderMarks | undefinedundefinedThe marks of the steps: `{ [value]: label }` or an array of values.
showTooltipboolean | "hover" | "always" | undefinedfalseA popping value above the thumb: `true`/`'hover'` — on hover, drag and focus, `'always'` — always.
formatTooltip((value: number) => string) | undefinedundefinedThe formatting of the value in the tooltip. The same goes into `aria-valuetext`.
modelValuerequiredGrSliderModelValue`number` is a single value; `[lo, hi]` is a range (with `range=true`).

Events

EventTypeDescription
update:modelValue[value: GrSliderModelValue]
change[value: GrSliderModelValue]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void
blur() => void

Examples 4

Single value with tooltip

Value: 40 — drag the thumb or use arrow keys, Home / End, PageUp / PageDown.

Basic
<script setup lang="ts">
import { ref } from 'vue'

import { GrSlider } from '@feugene/granularity'

const volume = ref(40)
</script>

<template>
  <div class="grid gap-4">
    <GrSlider
      v-model="volume"
      :min="0"
      :max="100"
      show-tooltip
      :format-tooltip="(v) => `${v}%`"
      aria-label="Volume"
    />

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Value: <code>{{ volume }}</code> — drag the thumb or use arrow keys, Home / End, PageUp / PageDown.
    </p>
  </div>
</template>

Range with two thumbs

$200
$700

From $200 to $700 — two thumbs that never cross.

Range
<script setup lang="ts">
import { ref } from 'vue'

import { GrSlider } from '@feugene/granularity'

const price = ref<[number, number]>([200, 700])
</script>

<template>
  <div class="grid gap-4">
    <GrSlider
      v-model="price"
      range
      :min="0"
      :max="1000"
      :step="50"
      show-tooltip="always"
      :format-tooltip="(v) => `$${v}`"
      aria-label="Price range"
    />

    <p class="text-sm text-[var(--gr-muted-fg)]">
      From <code>${{ price[0] }}</code> to <code>${{ price[1] }}</code> — two thumbs that never cross.
    </p>
  </div>
</template>

Marks, steps, sizes and disabled

Marks
<script setup lang="ts">
import { ref } from 'vue'

import { GrSlider } from '@feugene/granularity'

const quality = ref(50)

const marks = {
  0: 'Low',
  25: 'Fair',
  50: 'Good',
  75: 'High',
  100: 'Max',
}
</script>

<template>
  <div class="grid gap-8">
    <GrSlider
      v-model="quality"
      :min="0"
      :max="100"
      :step="25"
      :marks="marks"
      aria-label="Quality"
    />

    <div class="grid gap-6">
      <GrSlider :model-value="30" size="sm" aria-label="Small" />
      <GrSlider :model-value="60" size="lg" disabled aria-label="Large disabled" />
    </div>
  </div>
</template>

Custom colors & size (CSS variables)

Committed value: $1,200

Custom
<script setup lang="ts">
import { ref } from 'vue'

import { GrSlider } from '@feugene/granularity'

const brand = ref(65)
const accent = ref(40)
const large = ref(70)
const volume = ref(35)
const budget = ref(1200)
</script>

<template>
  <div class="grid gap-8">
    <!-- Свой цвет заливки + подложка дорожки. -->
    <GrSlider
      v-model="brand"
      aria-label="Brand color"
      show-tooltip
      :style="{
        '--gr-slider-fill': '#8b5cf6',
        '--gr-slider-rail': 'color-mix(in srgb, #8b5cf6 20%, transparent)',
      }"
    />

    <!-- Сплошной бегунок: заливка = цвет, окантовка = фон. -->
    <GrSlider
      v-model="accent"
      aria-label="Accent"
      :style="{
        '--gr-slider-fill': '#f97316',
        '--gr-slider-thumb-bg': '#f97316',
        '--gr-slider-thumb-border': 'var(--gr-bg)',
      }"
    />

    <!-- Крупнее бегунок и толще дорожка. -->
    <GrSlider
      v-model="large"
      aria-label="Large"
      :style="{
        '--gr-slider-fill': 'var(--gr-success)',
        '--gr-slider-thumb-size': '1.5rem',
        '--gr-slider-track-height': '0.75rem',
      }"
    />

    <div class="flex items-start gap-10">
      <!-- Вертикальная дорожка: минимум внизу, длина — через --gr-slider-length. -->
      <GrSlider
        v-model="volume"
        orientation="vertical"
        aria-label="Volume"
        show-tooltip
        :marks="{ 0: 'Mute', 50: 'Half', 100: 'Max' }"
        :style="{ '--gr-slider-length': '12rem' }"
      />

      <!-- lazy: значение уезжает наружу только на отпускании. -->
      <div class="grid flex-1 gap-2">
        <GrSlider
          v-model="budget"
          lazy
          :min="0"
          :max="5000"
          :step="50"
          aria-label="Monthly budget"
          show-tooltip
          :format-tooltip="(value) => `$${value.toLocaleString('en-US')}`"
        />
        <div class="text-sm text-[var(--gr-muted-fg)]">
          Committed value: <span class="font-medium text-[var(--gr-fg)]">${{ budget.toLocaleString('en-US') }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

Accessibility

APG pattern
slider

Full keyboard contract of the package

Component documentationAll components