# Dither slider

A range slider whose fill is a Bayer ramp that gets denser towards the thumb and ends in a solid edge, with a lit, dithered thumb. One thumb or two, steps and tick marks.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the dithercn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `DitherShader` ([Write your own dither](/docs/engine/write-your-own.md)), and read this example's source for technique. Its full source is in [https://dithercn.dev/r/dither-slider.json](https://dithercn.dev/r/dither-slider.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://dithercn.dev/r/dither-slider.json
```

## Usage [#usage]

```tsx
import { DitherSlider } from "@/components/dithercn/dither-slider";

export function Opacity() {
  return <DitherSlider label="Opacity" defaultValue={[64]} unit="%" />;
}
```

`DitherSlider` is built on the Radix `Slider`, so it takes the same `value`, `defaultValue`, `onValueChange`, `onValueCommit`, `min`, `max`, `step` and `name` props. Values are arrays, as in shadcn's slider: one value draws one thumb, two values draw a range.

The filled part of the track is a Bayer ramp worked out cell by cell. It is sparse far from the thumb, gets denser towards it, and is solid for the last two cells before a one-cell gap at the thumb, so the value reads at a glance. The cells sit on the track's own grid: dragging flips cells through their thresholds instead of sliding a texture along. With two thumbs the ramp runs in from both ends and thins out in the middle.

Hover, keyboard focus and dragging fade in a ramp twice as long with a denser floor. The thumb is a notched cap lit like [Dither button](/docs/interface/dither-button): a highlight dithered down from the top, a shade dithered up from the bottom and a grip line. Hover brightens it and dragging shades it from the top, so it looks pressed.

```tsx
<DitherSlider
  label="Price"
  defaultValue={[150, 600]}
  max={1000}
  step={50}
  format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
  showRange
/>
```

## Examples [#examples]

### Price filter [#price-filter]

Two values make a range. `format` takes `Intl.NumberFormat` options, here dollars, and is used for the readout, the `showRange` labels under the ends and each thumb's `aria-valuetext`. `minStepsBetweenThumbs={1}` keeps the two thumbs one step apart. The product count updates from `onValueChange`.

```tsx title="examples/dither-slider-price.tsx"
"use client";

import { useState } from "react";

import { DitherSlider } from "@/components/dithercn/dither-slider";
import { fillStyle } from "@/lib/dither";

// A made-up catalogue: how many products sit in each $50 band up to $1,000.
const BANDS = [
  4, 9, 16, 22, 27, 25, 21, 18, 14, 12, 10, 9, 7, 6, 5, 4, 3, 3, 2, 2,
] as const;
const BAND = 50;
const TALLEST = Math.max(...BANDS);
/** Half the width of a `md` thumb at the default cell: where its centre stops at each end. */
const THUMB_HALF = 9;

const IN_RANGE = fillStyle("gradient", {
  cell: 2,
  color: "green",
  edge: "bottom",
  length: 14,
  to: 4 / 16,
});
const OUT_OF_RANGE = fillStyle("dotted", { cell: 2, color: "grey" });

const count = ([low = 0, high = 0]: readonly number[]) => {
  let total = 0;
  for (const [index, products] of BANDS.entries()) {
    const start = index * BAND;
    if (start >= low && start < high) {
      total += products;
    }
  }
  return total;
};

/** Products per band, lit inside the selected range. */
const Histogram = ({ range }: { range: readonly number[] }) => {
  const [low = 0, high = 0] = range;
  return (
    <div
      aria-hidden="true"
      className="mb-1 flex h-14 items-end gap-0.5"
      style={{ marginInline: THUMB_HALF }}
    >
      {BANDS.map((products, index) => {
        const start = index * BAND;
        const inside = start >= low && start < high;
        return (
          <div
            // Bands are fixed and in order.
            // oxlint-disable-next-line no-array-index-key
            key={index}
            className="flex-1"
            style={{
              height: `${(products / TALLEST) * 100}%`,
              ...(inside ? IN_RANGE : OUT_OF_RANGE),
            }}
          />
        );
      })}
    </div>
  );
};

const dollars = new Intl.NumberFormat("en-US", {
  currency: "USD",
  maximumFractionDigits: 0,
  style: "currency",
});

export default function DitherSliderPrice() {
  const [range, setRange] = useState([150, 600]);

  return (
    <div className="bg-background border-border flex w-full max-w-sm flex-col gap-4 rounded-2xl border p-5 [--dither-paper:var(--background)]">
      <div className="flex items-baseline justify-between gap-4">
        <span className="font-medium">Price</span>
        <span className="text-muted-foreground font-mono text-xs tabular-nums">
          {range.map((price) => dollars.format(price)).join(" – ")}
        </span>
      </div>
      <div>
        <Histogram range={range} />
        <DitherSlider
          label="Price"
          value={range}
          onValueChange={setRange}
          min={0}
          max={1000}
          step={BAND}
          minStepsBetweenThumbs={1}
          format={{
            currency: "USD",
            maximumFractionDigits: 0,
            style: "currency",
          }}
          color="green"
          hideLabel
          showRange
        />
      </div>
      <div className="border-border flex items-center justify-between gap-4 border-t pt-4 text-sm">
        <span className="text-muted-foreground">
          <span className="text-foreground font-medium tabular-nums">
            {count(range)}
          </span>{" "}
          products in range
        </span>
        <button
          type="button"
          className="text-muted-foreground hover:text-foreground focus-visible:outline-ring cursor-pointer rounded-sm text-xs outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2"
          onClick={() => setRange([0, 1000])}
        >
          Reset
        </button>
      </div>
    </div>
  );
}
```

### Stepped volume [#stepped-volume]

`step={1}` on a 0 to 10 scale snaps the thumb to eleven positions, and `ticks` marks each one under the track. Ticks inside the selected range take the full colour. This one is `size="lg"` in `purple` with a low bloom, and the mute button sets the value from outside.

```tsx title="examples/dither-slider-volume.tsx"
"use client";

import {
  VolumeHighIcon,
  VolumeLowIcon,
  VolumeMute02Icon,
} from "@hugeicons/core-free-icons";
import { useState } from "react";

import { DitherSlider } from "@/components/dithercn/dither-slider";
import { Icon } from "@/components/icon";

const iconFor = (level: number) => {
  if (level === 0) {
    return VolumeMute02Icon;
  }
  return level < 6 ? VolumeLowIcon : VolumeHighIcon;
};

export default function DitherSliderVolume() {
  const [volume, setVolume] = useState([7]);
  const level = volume[0] ?? 0;

  return (
    <div className="bg-background border-border flex w-full max-w-sm items-end gap-3 rounded-2xl border p-5 [--dither-paper:var(--background)]">
      <button
        type="button"
        aria-label={level === 0 ? "Unmute" : "Mute"}
        className="text-muted-foreground hover:text-foreground focus-visible:outline-ring mb-[5px] flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md outline-hidden focus-visible:outline-2 [&_svg]:size-4.5"
        onClick={() => setVolume([level === 0 ? 7 : 0])}
      >
        <Icon icon={iconFor(level)} />
      </button>
      <DitherSlider
        label="Volume"
        value={volume}
        onValueChange={setVolume}
        min={0}
        max={10}
        step={1}
        ticks
        size="lg"
        color="purple"
        bloom="low"
        className="min-w-0 flex-1"
      />
    </div>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | What the slider sets. Shown above the track and used as the accessible name. |
| `value` | `number[]` |  | The controlled values. One value draws one thumb, two draw a range. |
| `defaultValue` | `number[]` | `[min]` | The starting values when uncontrolled. |
| `onValueChange` | `(value: number[]) => void` |  | Called on every change. onValueCommit fires once when a drag or key press ends. |
| `min` | `number` | `0` | The start of the scale. |
| `max` | `number` | `100` | The end of the scale. |
| `step` | `number` | `1` | The interval values snap to. |
| `ticks` | `boolean \| number[]` | `false` | Marks under the track: true for one per step (up to 101), or the values to mark. |
| `showValue` | `boolean` | `true` | Show the value, or both ends of a range, beside the label. |
| `showRange` | `boolean` | `false` | Show min and max under the ends of the track. |
| `hideLabel` | `boolean` | `false` | Hide the label and readout visually. Screen readers still get them. |
| `unit` | `string` | `""` | Shown after each value, such as "%". |
| `format` | `Intl.NumberFormatOptions` | `{ maximumFractionDigits: 1 }` | How values are formatted in the readout, range and aria-valuetext. |
| `thumbLabels` | `string[]` |  | Accessible names per thumb. Default: the label, or "<label>, minimum" and "<label>, maximum". |
| `color` | `DitherColor` | `"ink"` | A palette name such as "blue" or "green", a CSS variable name, or any CSS colour. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Track heights of 6, 8 and 12 pixels, with thumbs 14, 18 and 22 pixels wide, at the default `cell` of 2. Other cell sizes round each part to whole cells, so the sizes shift a little. |
| `bloom` | `"off" \| "low" \| "high"` | `"off"` | A soft glow in the slider's colour behind the fill and thumbs. |
| `matrix` | `"bayer2" \| "bayer4" \| "bayer8" \| "blue-noise"` | `"bayer4"` | Threshold pattern of the ramp. |
| `cell` | `number` | `2` | Size of one dither cell in CSS pixels. Track and thumb sizes snap to whole cells. |
| `disabled` | `boolean` | `false` | Ignore input and drop to 45% opacity. |

All other props, such as `name`, `onValueCommit` and `minStepsBetweenThumbs`, pass through to Radix `Slider.Root`. `className` and `style` go on the outer wrapper.

## Notes [#notes]

* Each thumb has `role="slider"` with `aria-valuenow`, `aria-valuemin`, `aria-valuemax` and an `aria-valuetext` that includes the unit or currency. Arrow keys step, Page Up and Page Down step by ten, and Home and End jump to the ends.
* Inside a `<form>`, the slider submits one hidden input per thumb under `name` (`name[]` for a range).
* Focus shows a notched ring around the thumb with `:focus-visible`, and thickens the ramp just as hover does, so keyboard users see the same states as pointer users.
* With reduced motion the ramp and thumb states switch without fading. Nothing else moves.
* The fill is one SVG path per state, rebuilt as the value changes; the thumb is CSS masks in one shared `<style>` tag. No canvas or WebGL. Before the track is measured, a CSS ramp stands in.
* The slider is horizontal and left to right only.