Gecko UIGecko UI

Slider

Pick a number, or a span, by dragging

Slider

Pick a number, or a span, by dragging.

Installation

import { Slider, RangeSlider } from '@geckoui/geckoui';

Basic Usage

value: 40

const [volume, setVolume] = useState(40);

<Slider value={volume} onChange={setVolume} label={({ value }) => `${value}%`} />

Props API

Slider

PropTypeDefaultDescription
valuenumberrequiredWhere the thumb sits
onChange(value: number) => voidrequiredCalled on every move
onChangeEnd(value: number) => void-Called once, when the drag or key press finishes
minnumber0Bottom of the range
maxnumber100Top of the range
stepnumber1How far one move goes
marksSliderMark[]-Ticks along the track
labelReactNode | ({ value, index }) => ReactNode-Bubble above the thumb while it is in use
renderThumb({ value, index, percent, dragging, focused }) => ReactNode-Draw the thumb yourself
colorkeyof SliderColorMap'primary'Accent on the filled part
sizekeyof SliderSizeMap'md'sm, md or lg
disabledbooleanfalse
aria-labelstring-Names the thumb
classNamestring-CSS class for the slider

RangeSlider

Everything above except value, onChange, onChangeEnd and aria-label, plus:

PropTypeDefaultDescription
value[number, number]requiredWhere the two thumbs sit, lower first
onChange(value: [number, number]) => voidrequiredCalled on every move
onChangeEnd(value: [number, number]) => void-Called once, on release
minGapnumber0How close the thumbs may get
thumbLabels[string, string]['Minimum', 'Maximum']Names each thumb

Examples

onChange against onChangeEnd

while dragging: 40 ยท on release: 40

<Slider value={volume} onChange={setVolume} onChangeEnd={save} />

onChange fires all the way through a drag, which is what keeps the slider controlled and following the pointer. onChangeEnd fires once, when it is let go or a key press finishes.

Put anything expensive in onChangeEnd: a request, a recalculation, a write to storage.

Steps and marks

OffHalfMax
<Slider
  value={volume}
  onChange={setVolume}
  step={25}
  marks={[
    { value: 0, label: 'Off' },
    { value: 25 },
    { value: 50, label: 'Half' },
    { value: 75 },
    { value: 100, label: 'Max' }
  ]}
/>

A mark with no label is a tick on its own. Marks outside min and max are dropped.

Marks and step are separate: marks are where the labels go, step is what the thumb lands on.

Fractional steps

value: 2.5

<Slider value={value} onChange={setValue} min={0} max={10} step={0.1} />

Steps are measured from min rather than from zero, so min={1} step={5} gives 1, 6, 11 and not 0, 5, 10. The result is rounded to the step's own precision, so 0.1 steps give 0.3 rather than 0.30000000000000004.

A span

value: [120,380]

const [price, setPrice] = useState<[number, number]>([120, 380]);

<RangeSlider
  value={price}
  onChange={setPrice}
  min={0}
  max={500}
  step={10}
  minGap={50}
  label={({ value }) => `$${value}`}
/>

The thumbs stop at each other rather than swapping, so the lower one stays the lower one and value is always in order. minGap holds them further apart than that.

Pressing the track moves whichever thumb is nearer.

A thumb of your own

40
function Volume() {
  const [volume, setVolume] = useState(40);

  return (
    <Slider
      value={volume}
      onChange={setVolume}
      renderThumb={({ value, dragging }) => (
        <span
          className={`flex h-7 min-w-9 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white shadow ${
            dragging ? 'bg-gray-900' : 'bg-blue-600'
          }`}>
          {value}
        </span>
      )}
    />
  );
}

Whatever it returns is the thumb, so an icon works as well as a label:

const GripIcon = ({ className }: { className?: string }) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className={className} aria-hidden="true">
    <path d="M9 5v14M15 5v14" strokeWidth="2" strokeLinecap="round" />
  </svg>
);

function Grip() {
  const [value, setValue] = useState(60);

  return (
    <Slider
      value={value}
      onChange={setValue}
      renderThumb={() => (
        <span className="flex size-6 items-center justify-center rounded-full bg-white shadow ring-1 ring-gray-300">
          <GripIcon className="size-3.5 text-gray-500" />
        </span>
      )}
    />
  );
}

renderThumb draws inside the thumb rather than in place of it. The drag, the arrow keys and the role="slider" all stay on the element that is positioned, so handing over what it looks like does not hand over how it works.

The component's own circle is dropped when you do, so there is nothing left behind whatever you draw โ€” the colour, the shape and the size are all yours.

Colours

Sizes

The keyboard

KeyDoes
โ† โ†“Back one step
โ†’ โ†‘On one step
Shift + arrowTen steps
Page Up Page DownA tenth of the range
Home EndTo either end

Every key press also fires onChangeEnd, so a keyboard user commits the same way a dragging one does.

Styling with CSS

VariableDefaultApplies to
--gecko-slider-accentper colorFilled track and thumb border
--gecko-slider-track--color-surface-hover-strongThe empty part
--gecko-slider-track-heightper sizeTrack thickness
--gecko-slider-thumb-sizeper sizeThumb width and height
--gecko-slider-thumb-bg--color-surface-primaryInside the thumb
--gecko-slider-radius9999pxTrack and thumb corners
--gecko-slider-mark-size0.25remTick size
.GeckoUISlider {
  --gecko-slider-track-height: 0.75rem;
  --gecko-slider-radius: 0.25rem;
}

Class names and data attributes

  • GeckoUISlider - The slider, with data-color, data-size and data-disabled
  • GeckoUISlider__track - The groove
  • GeckoUISlider__fill - The filled part
  • GeckoUISlider__thumb - One thumb, with data-dragging
  • GeckoUISlider__label - The bubble
  • GeckoUISlider__mark - One tick, with data-filled when it is behind the thumb
  • GeckoUISlider__mark-label - The text under a tick

Adding your own colours

Both axes are extensible maps, so you can add keys through module augmentation and style them with CSS:

declare module '@geckoui/geckoui' {
  interface SliderColorMap {
    brand: unknown;
  }
}

<Slider value={60} onChange={setValue} color="brand" />
.GeckoUISlider[data-color="brand"] {
  --gecko-slider-accent: oklch(0.62 0.21 320);
}

Accessibility

Each thumb is a role="slider" in the tab order, carrying aria-valuemin, aria-valuemax and aria-valuenow.

On a RangeSlider, each thumb reports its own room rather than the whole track: the lower thumb's aria-valuemax is wherever the upper one is. A screen reader then says how far this thumb can actually go.

Name a single slider with aria-label, and the two ends of a range with thumbLabels.

  • RHFSlider - Both sliders, wired to React Hook Form
  • CounterInput - For a number typed rather than dragged
  • Progress - For showing a number rather than picking one