# Price range

> A React price range filter: a histogram of the results over a two-thumb slider, synced min and max inputs, preset chips and a live count.

- Type: React component (Commerce)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add price-range`
- Docs: https://wingo-ui.com/components/price-range

## Interaction

Drag either thumb along the histogram: the bars inside the range light up, the thumb shows its price in a bubble and the inputs follow, snapped to a round step. Drag a thumb all the way to an end to open that bound (the input empties to its Min or Max placeholder). On a phone the thumbs have 44px hit areas and a vertical swipe over the slider still scrolls the sheet it sits in. Type in Min or Max for an exact amount, even one beyond the slider's ends (the thumb stays pinned at the end); Enter or leaving both fields applies it, and a min above the max swaps. Tap a preset chip to set both ends at once, tap it again to clear the filter; on phones the chips scroll sideways. Keyboard: Tab to a thumb, Left / Right or Up / Down move by one step, Page Up / Page Down (or Shift with an arrow) by ten, Home and End jump the thumb to the start or the end of the track but never past the other thumb (Home on the minimum and End on the maximum open that bound); arrows move between preset chips and Space selects one. The count under the filter changes with a short fade and is read once by screen readers. Under reduced motion the bars appear without growing.

## Installation

```bash
npx wingo-ui@latest add price-range
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/price-range` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/price-range.tsx`

npm dependencies: `npm i motion`

Wingo UI dependencies (installed automatically):

- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Tag](https://wingo-ui.com/components/tag) ([Markdown](https://wingo-ui.com/components/tag.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { PriceRange, type PriceRangeValue } from "@/components/ui/price-range"

const PRICES = [320, 450, 890, 1150, 1200, 1490, 1650, 2100, 2450, 2900, 3400, 4300, 5200, 7800]

export function PriceFilter({ onApply }: { onApply: (value: PriceRangeValue) => void }) {
  const [value, setValue] = useState<PriceRangeValue>([null, 3000])
  const count = PRICES.filter((p) => (value[0] === null || p >= value[0]) && (value[1] === null || p <= value[1])).length
  return (
    <PriceRange
      label="Price"
      value={value}
      onValueChange={setValue}
      onValueCommit={onApply}
      min={0}
      max={8000}
      prices={PRICES}
      presets={[
        { min: null, max: 500 },
        { min: 500, max: 1500 },
        { min: 1500, max: null },
      ]}
      count={count}
      labels={{
        results: (n, formatted) => (n === 1 ? "1 listing" : `${formatted} listings`),
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `PriceRangeValue` |  | The controlled range, [min, max] in major units (dollars). null is an open end: [null, 3000] means under $3,000. A value outside the slider pins its thumb to the end while the inputs keep the real number. The type is exported as PriceRangeValue ([min: number \| null, max: number \| null]), shared with filter-panel. |
| `defaultValue?` | `PriceRangeValue` | `"[null, null]"` | The starting range while uncontrolled. The default leaves both ends open. |
| `onValueChange?` | `(value: PriceRangeValue) => void` |  | Fires while dragging (at most once per frame), while typing and on presets. Use it for the live readout. |
| `onValueCommit?` | `(value: PriceRangeValue) => void` |  | Fires when a thumb is released or moved by a key, when an input is left or confirmed with Enter, and when a preset is tapped. Fetch results here. A min above the max arrives swapped. |
| `min?` | `number` | `0` | The slider's low end. A thumb dragged here opens the lower bound. |
| `max?` | `number` | `10000` | The slider's high end. A thumb dragged here opens the upper bound; prices above it count in the last bar. Equal to min it becomes a single price with locked thumbs. |
| `step?` | `number` | `0` | The price step thumbs, arrows and inputs snap to. 0 picks a round one for the range (1, 5, 10, 50, 100, 500...), about a hundredth of it; on a log scale it grows with the price. Currencies without decimals always step in whole units. |
| `scale?` | `"linear" \| "log"` | `"linear"` | log spreads cheap prices out and squeezes the expensive tail, for wide ranges like real estate ($100 to $500,000). The histogram's buckets follow the same scale. |
| `prices?` | `readonly number[]` |  | Raw prices of the results, counted into the histogram. To send histogram facets from the server instead, count them there with binPrices (see Parts). |
| `histogram?` | `readonly number[]` |  | Precomputed bucket counts (server facets), one per equal-width bucket of the track (log-spaced on a log scale). Wins over prices. |
| `bins?` | `number` | `24` | How many bars prices are binned into. Empty buckets leave a gap. |
| `showHistogram?` | `boolean` | `true` | The bars above the slider. Without prices or a histogram there is nothing to draw and it stays hidden. |
| `showInputs?` | `boolean` | `true` | The Min and Max currency inputs under the slider. |
| `presets?` | `PriceRangePreset[]` |  | Chips that set both ends at once: PriceRangePreset = { label?: ReactNode; min: number \| null; max: number \| null }, e.g. { min: null, max: 500 }. A chip without a label reads labels.under / between / over ("Under $500", "$500 to $1,500"). It stays selected while the value matches it. |
| `count?` | `number` | `0` | Results in the current range, shown under the filter as labels.results ("832 results") in a polite live region. 0 hides it. |
| `currency?` | `string` | `"USD"` | ISO 4217 code for the inputs, the bubbles and the chips: "$", "€", "lei". |
| `locale?` | `string` | `""` | Formats every price ("$1,500" in en-US, "1.500 $" in de-DE). Empty uses UIProvider, then the page. |
| `label?` | `ReactNode` | `""` | The field label above the histogram; it names the whole group. |
| `description?` | `ReactNode` | `""` | Helper text under the filter, like "per month". |
| `error?` | `ReactNode` | `""` | Replaces the description and marks both inputs invalid. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Scales the slider (4 / 6 / 8px track), the histogram (40 / 48 / 56px), the inputs (32 / 40 / 48px, 44px on touch for md) and the chips together. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Colors the slider's range, the lit bars (at 55%) and the selected chip. neutral is black in light mode and white in dark mode. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone everywhere. Leave empty to use the tone. |
| `disabled?` | `boolean` | `false` | Dims the filter and blocks the thumbs, inputs and chips, e.g. while results load. |
| `labels?` | `Partial<PriceRangeLabels>` |  | Every string, English by default: min "Min", max "Max", any "Any", thumbMin "Minimum price", thumbMax "Maximum price", under (p) => `Under ${p}`, over (p) => `Over ${p}`, between (a, b) => `${a} to ${b}`, results (n, formatted) => `${formatted} results`, presets "Price presets". Pass only the keys you translate. |
| `classNames?` | `Partial<Record<"histogram" \| "bar" \| "slider" \| "inputs" \| "input" \| "presets" \| "count", string>>` |  | Extra classes for the inner parts. className stays on the root (the field wrapper when a label, description or error is set). |

## Parts

### binPrices (function)

binPrices(prices, { min?, max?, bins?, scale? }) returns the histogram counts as number[]: equal-width or log-spaced buckets, prices outside min..max counted in the first or last one. The same counting the component does, for facets computed on the server.

### DEFAULT_PRICE_RANGE_LABELS (constant)

The English copy: min "Min", max "Max", any "Any", the thumb names, under / over / between preset text and the results count.

### PriceRangePreset (type)

One preset chip: { label?, min, max }, with null for an open end. Without a label the chip reads Under, Over or between.

## Examples

- **In a filter sheet**: A bottom sheet keeps a draft; the footer shows the live count and applies it.
- **Real estate in EUR**: A log scale from €20,000 to €500,000 with a step of €1,000.
- **Rent per month**: $500 to $5,000 with a caption and no histogram.
- **Narrow sidebar**: Slider and inputs only, size sm, in a 256px filter column.
- **Open ends**: Under $3,000 and over $1,000: one bound left open.
- **Sizes in the brand tone**: sm, md and lg with tone="brand" lighting the bars and the range.
- **Disabled while loading**: Each commit locks the filter until the results come back.

## See also

- [Filter panel](https://wingo-ui.com/components/filter-panel) ([Markdown](https://wingo-ui.com/components/filter-panel.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Price](https://wingo-ui.com/components/price) ([Markdown](https://wingo-ui.com/components/price.md))
- [Listing grid](https://wingo-ui.com/components/listing-grid) ([Markdown](https://wingo-ui.com/components/listing-grid.md))

---

Canonical: https://wingo-ui.com/components/price-range
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
