Loom Slider

A row of dashes where the one you are holding stands tallest, with the rise travelling the track as you drag.

Loading preview

Installation

npx shadcn@latest add @loomui/loom-slider

Usage

import { LoomSlider } from "@/components/ui/loom-slider"
<LoomSlider label="Thread tension" defaultValue={50} />

There is no knob. The dash sitting at the value is the tallest thing on the track, its neighbours rise behind it, and that shape is what says where to take hold. Drag anywhere on the track and the rise follows the pointer.

The rise

The rise is centred on a dash rather than on the raw value. A peak landing between two dashes lifts one side harder than the other and leaves no middle to read the value off. Reading the value is the one thing the peak is there for. Snapping it to the nearest dash means the same number rise on each side, always, with the readout sitting over the dash in the middle of them.

Each dash is then measured in whole dashes from that middle and put through a cosine bell. Full rise at the middle, nothing reach dashes out, eased across the gap so the peak has no corner at either end.

It stays deliberately small. Three dashes either side move, growing by about a third, which says where the value is without turning an input into a chart.

reach is how far it spreads:

<LoomSlider reach={6} tickCount={49} />

A wider reach gives a long, shallow swell. A reach of 2 lifts one dash each side of the value and nothing else.

The wave

Every dash is drawn from a single animation frame loop rather than from a CSS transition. A transition would be restarted on every pointer move and never arrive, which is what makes a slider feel like it is lagging behind the finger.

On each frame the loop eases the value toward the target and hands back how fast it is travelling. That speed drives a ripple, sin(distance - time) falling away along the row. A fast drag throws a strong wave, a slow one barely disturbs the dashes, and the row settles the moment the value does.

Dashes grow from the middle of the track in both directions, not up from its floor. The peak reads as a shape rather than a bar chart.

The number

The readout sits over the dash the rise is centred on, so the number and the tallest dash never drift a few pixels apart. The number itself counts rather than snapping, which is what turns a jump across the track into the number travelling there.

<LoomSlider
  min={0}
  max={5000}
  step={50}
  defaultValue={1500}
  formatValue={(value) => `$${value.toLocaleString()}`}
/>

formatValue also becomes the aria-valuetext, so the number a screen reader announces is the one on screen.

Controlled

const [value, setValue] = React.useState(50)
 
<LoomSlider value={value} onValueChange={setValue} />

Leave value out and the slider owns it. defaultValue sets where it starts. Every value is snapped to step from min, whether it arrived from a key, a drag, or a prop.

Props

PropTypeDefaultDescription
valuenumbernoneThe current value. Leave out to run uncontrolled.
defaultValuenumber50Starting value when uncontrolled.
onValueChange(value: number) => voidnoneCalled with the value moved to.
minnumber0Low end of the range.
maxnumber100High end of the range.
stepnumber1Smallest movement the value can make.
tickCountnumber41How many dashes are strung across the track.
reachnumber4How many dashes either side the rise covers.
showValuebooleantrueShow the number above the track.
formatValue(value: number) => stringnoneTurns the value into the text shown.
labelstring"Value"Name for the slider.
disabledbooleanfalseRender at the value, refuse input.
classNamestringnoneMerged onto the wrapper. Width goes here.

Any other <div> prop is forwarded.

Accessibility

The track is a real role="slider", reachable by tab and moved with the arrows, Page Up, Page Down, Home and End. Shift moves ten steps at once. Give it a label that says what is being set. The dashes are decoration and are hidden from assistive tech.

Reduced motion

With reduced motion the frame loop never runs: the value lands where it is put, the wave never appears, and the dashes take their lengths at once. What is left is a still row with the peak sitting on the value.