# Stardust Slider

> A slider in a labelled card whose blue fill glows with drifting dust, with a soft handle that firms up when grabbed and a readout whose digits roll to the value.

- Page: https://ui.devigner.cc/components/stardust-slider
- Category: Forms
- Requirements: React 18 or 19, Tailwind CSS v4
- Install: `npx devignerui add stardust-slider` (writes components/ui/stardust-slider.tsx)
- Packages it needs: cn, motion, devignerui

## What the demo shows

The live demo is a card labelled Select strike: at 28%. Grab the handle and it brightens and grows; fling it right and it stretches with the speed while the blue fill glows with drifting dust, and the percentage rolls up digit by digit after it. Click the number to type one.

## When to use it

- One headline value in a dashboard or trading style screen, such as a strike, a risk level or an allocation, where the control is the focus of the card.
- A value with a few meaningful stops: snap="marks" with labelled marks pulls the fill onto them like detents.
- A tall fader, such as volume or a level meter, with orientation="vertical", or the track alone inside your own layout with variant="bare".

## When to reach for something else

- Dense settings panels with many sliders: each one runs its own dust animation. Use Slider.
- Picking a min and max. It has one handle; use a two-thumb range slider.
- A value that is mostly typed. The readout accepts typing, but a number input is the better primary control.

## Usage

```tsx
import { StardustSlider } from "@/components/ui/stardust-slider";

<StardustSlider label="Select strike:" defaultValue={28} />
```

Custom composition:

```tsx
<StardustSlider label="Strike" min={90} max={130} step={5} snap="marks" marks={[{ value: 100, label: "ATM" }]} formatOptions={{ style: "currency", currency: "USD" }} />
```

### Strike with detents

Strikes from 90 to 130 in dollars, pulled onto the labelled round numbers.

```tsx
import { useState } from "react";
import { StardustSlider } from "@/components/ui/stardust-slider";

export function StrikePicker() {
  const [strike, setStrike] = useState(110);
  return (
    <StardustSlider
      label="Select strike:"
      value={strike}
      onValueChange={setStrike}
      min={90}
      max={130}
      step={1}
      snap="marks"
      marks={[
        { value: 100, label: "100" },
        { value: 110, label: "ATM" },
        { value: 120, label: "120" },
      ]}
      formatOptions={{ style: "currency", currency: "USD" }}
    />
  );
}
```

### Vertical fader

A tall, small fader without the card, saved on release.

```tsx
import { StardustSlider } from "@/components/ui/stardust-slider";

export function Volume({ save }: { save: (value: number) => void }) {
  return (
    <StardustSlider
      aria-label="Volume"
      variant="bare"
      orientation="vertical"
      size="sm"
      defaultValue={60}
      color="#0ea5e9"
      onValueCommit={save}
      classNames={{ track: "h-72" }}
    />
  );
}
```

### Budget on a log scale

From 100 to 1,000,000 in euros, German style, with room for the small amounts.

```tsx
import { StardustSlider } from "@/components/ui/stardust-slider";

export function Budget() {
  return (
    <StardustSlider
      label="Budget"
      min={100}
      max={1000000}
      step={100}
      defaultValue={5000}
      scale="log"
      marks={[]}
      locale="de-DE"
      formatOptions={{ style: "currency", currency: "EUR" }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | `number` | - | Controlled value. |
| defaultValue | `number` | 0 | Initial value. |
| onValueChange | `function` | - | Fires with the new value while dragging and on each key press. |
| onValueCommit | `function` | - | Fires once when the handle is let go, on each key press that changes the value, and when a typed value is committed. |
| min | `number` | 0 | Lowest value. |
| max | `number` | 100 | Highest value. |
| step | `number` | 1 | The arrow key step, and the grid values are rounded to. |
| snap | `"steps" \| "marks" \| false` | false | "steps" moves the fill from stop to stop. "marks" pulls it onto a mark when the pointer comes within about 10px of one, like a detent. |
| scale | `"linear" \| "log" \| { toRatio, fromRatio }` | linear | How values map onto the track. Log spreads small values out (min must be above 0); pass your own toRatio and fromRatio for anything else. |
| label | `ReactNode` | - | Text above the track on the left, also the slider's accessible name. |
| marks | `(number \| { value, label? })[]` | 0%, 30%, 70% and 100% of the track | Values where a faint mark is drawn. A label shows under the mark (beside it when vertical). Pass [] for none. |
| format | `(value: number) => string` | - | Turns the value into text for the readout and screen readers, e.g. v => `${v}°`. Wins over locale and formatOptions. |
| locale | `string` | the browser's | Locale for the number, such as "de-DE", which writes 28 % and 1.250. |
| formatOptions | `Intl.NumberFormatOptions` | a percent | How the number is written, such as { style: "currency", currency: "USD" }. Fraction digits follow step unless you set them here. |
| readout | `ReactNode` | - | Replaces the rolling number on the right of the header with your own content. |
| editable | `boolean` | true | Lets a click on the number turn it into a field to type an exact value. Enter or leaving the field commits, Escape cancels. |
| color | `string` | #365ee9 | Fill color, any CSS color. The fill fades it in from the start. |
| size | `"sm" \| "md" \| "lg"` | md | Track thickness 44, 54 or 64px. The card, text and handle scale with it. |
| orientation | `"horizontal" \| "vertical"` | horizontal | Vertical fills bottom to top, with the header above a tall track (h-56 at md; set the length with classNames.track). |
| dir | `"ltr" \| "rtl"` | the page's | Right-to-left fills from the right and swaps the left and right arrow keys. |
| variant | `"card" \| "bare"` | card | Bare drops the card and header and renders the track alone; give it an aria-label. |
| disabled | `boolean` | false | Disables interaction. |
| name | `string` | - | Form field name; submits the value. |
| classNames | `StardustSliderClassNames` | - | Slot classes: label, value, track, fill, handle, mark, markLabel. |

## Keyboard

| Keys | Action |
| --- | --- |
| Tab | Focuses the number when it is editable, then the track. The track draws a focus ring; the number turns into a field in place when clicked or activated with Enter. |
| Arrow Up / Arrow Right | Raises the value by one step, ten with Shift (Arrow Left raises it right-to-left). |
| Arrow Down / Arrow Left | Lowers the value by one step, ten with Shift (Arrow Right lowers it right-to-left). |
| Page Up / Page Down | Moves a tenth of the range (at least one step). |
| Home / End | Jumps to min or max. |
| Arrow Up / Arrow Down in the field | Steps the typed number by one step, ten with Shift; the fill follows. |
| Enter / Escape | In the typed value field: commits it, or puts back the value it opened with, and returns focus to the track. |

## Accessibility

- The track has role="slider" with aria-valuemin, aria-valuemax, aria-valuenow, aria-orientation and aria-valuetext, the same text the readout shows ("28%", "$110").
- label names the slider through aria-labelledby. Without a label, and always with variant="bare", pass aria-label or aria-labelledby.
- The rolling digits are hidden from screen readers; the editable number is a button named "Type a value, now 28%", and its field is named "Type a value".
- Pressing the track moves focus to it, so the arrow keys work right after a drag. Its focus ring uses var(--ring). The number has no ring by design: it edits in place, as a borderless field the width of its text, with its text selected so typing replaces it.
- Honors prefers-reduced-motion and an ancestor <MotionConfig reducedMotion="always">, so an in-app motion switch works too. Under reduced motion the dust stays still, the readout changes without rolling and the fill jumps instead of gliding.

## Theming

- The card is var(--card) with var(--card-foreground) text and the label is var(--muted-foreground). The track is var(--foreground) mixed 9% into var(--background) with the same bevel as Nav Notch, so both follow dark mode. Marks are hairlines of var(--border) that fade out at both ends, like a divider; mark labels are var(--muted-foreground).
- color sets the fill (#365ee9 by default). The fill fades it in from transparent at its start to full strength at its end, so a short fill still ends bright.
- The dust is white. At min the fill shrinks to a slim neutral socket around the handle (var(--foreground) at 8%) with a var(--foreground) handle, and the color and dust fade in over the first 8% of the track, so an empty slider reads as empty. The handle is see-through white with a light backdrop blur.
- Vertical cards stack the label, smaller, above the number, both centred over the track; mark labels hang beside the track with room kept on both sides, so the track stays centred. size picks a 44, 54 or 64px track, with a 320, 372 or 420px card and sm, base or lg text; everything inside scales with it. A w-* class in className changes the width, and a vertical track's length comes from an h-* class in classNames.track.
- classNames targets label, value, track, fill, handle, mark and markLabel.

## Edge cases

- Grabbing near the handle keeps the grab point, so it never jumps; pressing elsewhere on the track glides the fill there.
- snap="marks" pulls the fill onto a mark when the pointer comes within about 10px of one (scaled with size) and releases it past that; snap="steps" moves it from stop to stop. Free, it follows the pointer on a soft spring.
- The fill is revealed with a clip-path over a fixed layer and the handle moves on transforms, so a drag never changes layout. Its start follows the track's round end; its end keeps a tight corner and rounds into a half circle over the last 15% of the track.
- The readout rolls like an odometer: the ones digit turns with the value, each higher digit turns only while the one below rolls over, and each column blurs a little with its speed. With Intl formatting, group separators (1,250) and the locale's decimal mark sit between the columns and a leading digit or separator unfolds as the value grows into it, so text around the number never jumps. Compact notation (1.2K) or a format function that changes the digits shows plain text.
- Typed values are clamped to min and max and rounded to step; anything that isn't a number is ignored. Both a comma and a point work as the decimal mark.
- scale="log" needs min above 0 and otherwise falls back to linear. Arrow keys always move by step in values, so on a log scale a step is a short distance at the top and a long one at the bottom.
- Right-to-left comes from dir, or from the page's direction when dir is unset. The track mirrors; the numbers still read left to right. Vertical ignores it.
- The dust gathers toward the handle: half the motes drift in the last 20% of the fill around the handle, most of the rest along its middle and only a few in its dark first 30%, and a short fill shows fewer. It only animates while the slider is on screen.
- Marks outside the range are skipped. Only one pointer drags at a time.
- onValueChange fires on every move; save or fetch in onValueCommit, which fires once per release, per key press and per typed value.
- With name set, a hidden input submits the value.

## Troubleshooting

**The fill shows no color.**
The fill uses color-mix() and clip-path, which need a 2023 or newer browser. Check that color is a valid CSS color.

**The number doesn't match the value right away.**
That is the roll: the readout catches up with the value over about half a second. The value passed to onValueChange and read by screen readers is always current.

**The number shows as plain text instead of rolling.**
The digits couldn't be found in the formatted text: compact notation (1.2K), a numbering system with other digits, or a format function that rounds or abbreviates. Use formatOptions with standard notation to keep the roll.

**The component renders with no background or the wrong colors.**
Colors use the standard shadcn/ui tokens only (background, foreground, primary, secondary, muted, accent, border, input, ring, destructive), so a project set up with shadcn/ui needs nothing extra and the component follows its theme, dark mode included. Without shadcn/ui, define those CSS variables for :root and .dark and map them in your Tailwind v4 @theme, or run npx shadcn init.
