131 / Controls
Range Histogram
A two-handle range filter drawn over the distribution it filters, so you can see where the results are before you cut them off.
Filters
Stays in Lisbon, 3 nights, 2 guests.
291 of 412 stays
"use client"
import * as React from "react"
import { RangeHistogram } from "mischief-ui/range-histogram"
/** A repeatable spread of nightly rates: private rooms, then whole homes. */
function nightlyRates() {
let seed = 412
const random = () => {
seed = (seed * 16807) % 2147483647
return seed / 2147483647
}
const normal = () =>
Math.sqrt(-2 * Math.log(random() || 1e-9)) *
Math.cos(2 * Math.PI * random())
return Array.from({ length: 412 }, (_, index) => {
const wholeHome = index % 3 === 0
const rate = wholeHome ? 240 + normal() * 70 : 95 + normal() * 32
return Math.round(Math.min(Math.max(rate, 35), 480))
})
}
const rates = nightlyRates()
const dollars = (value: number) => `$${value}`
export function RangeHistogramDemo() {
const [range, setRange] = React.useState<[number, number]>([70, 260])
return (
<div className="bg-background border-border w-full max-w-[28rem] rounded-[1.25rem] border p-5 shadow-sm sm:p-6">
<h3 className="text-lg font-semibold">Filters</h3>
<p className="text-muted-foreground mt-1 text-sm">
Stays in Lisbon, 3 nights, 2 guests.
</p>
<RangeHistogram
className="mt-6"
label="Price per night"
values={rates}
min={30}
max={480}
step={5}
value={range}
onValueChange={setRange}
formatValue={dollars}
formatCount={(count, total) => `${count} of ${total} stays`}
inputs
/>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/range-histogramimport { RangeHistogram } from "mischief-ui/range-histogram"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type RangeHistogramBin = { /** Inclusive lower edge. */ start: number /** Upper edge. Only the last bin includes it. */ end: number count: number} Usage
export function PriceFilter({ listings }: { listings: Listing[] }) {
const [range, setRange] = React.useState<[number, number]>([50, 300])
return (
<RangeHistogram
label="Price per night"
values={listings.map((listing) => listing.price)}
value={range}
onValueChange={setRange}
onValueCommit={(next) => refetch({ minPrice: next[0], maxPrice: next[1] })}
step={5}
formatValue={(value) => `$${value}`}
inputs
/>
)
}Raw values or counted bins
Pass values when you have the numbers themselves, such as the prices of the listings already on the page. The component groups them into binCount bars and counts the ones inside the range exactly.
Pass bins when the server has already counted, which is the usual case for a search over more results than you would send to the browser. Each bin is a start, an end, and a count. The count line is then an estimate wherever a handle sits inside a bin, on the assumption that the bin's items are spread evenly across it. A handle resting on a bin edge gives an exact count.
<RangeHistogram
label="Duration"
bins={facets.duration}
min={0}
max={180}
formatValue={(minutes) => `${minutes} min`}
formatCount={(count, total) => `${count} of ${total} episodes`}
/>Change and commit
onValueChange fires on every movement, so the bars and the count can follow the handle. onValueCommit fires once a drag ends, a key is released, or a typed bound is entered. Fetch on commit, not on change, or a single drag sends a request per pixel.
API
labelstringNames the group and both handles, as Label minimum and Label maximum.valuesreadonly number[]The raw values being filtered. The count is exact.binsreadonly RangeHistogramBin[]Counts the host already has. Used when values is not given.binCountnumberHow many bars values are grouped into. Defaults to 24.min, maxnumberThe ends of the scale. Default to the data's extent.stepnumberThe smallest movement. Defaults to 1.largeStepnumberOne Page Up, Page Down, or Shift+arrow. Defaults to ten steps.value, defaultValue (RangeHistogramValue)[number, number]The selected range, controlled or uncontrolled.onValueChange(value: RangeHistogramValue) => voidFires on every movement.onValueCommit(value: RangeHistogramValue) => voidFires when a drag ends, a key is released, or a typed bound is entered.formatValue(value: number) => stringWrites the readout and each handle's aria-valuetext.formatCount(count: number, total: number) => stringThe line under the chart. Defaults to "128 of 412 results".inputsbooleanShows minimum and maximum number fields under the chart.disabledbooleanTakes the handles out of the tab order and ignores input.classNamestringClasses for placement and width....divProps (RangeHistogramProps)HTMLAttributesNative div attributes.RangeHistogramBin
startnumberThe bin's lower edge, included.endnumberThe upper edge. Only the last bin includes it.countnumberHow many results fall in the bin.Accessibility
Each handle is a focusable slider with its own name, its current bounds as aria-valuemin and aria-valuemax, and a formatted aria-valuetext, so a screen reader hears $120 rather than 120. The arrow keys move a step, Shift+arrow and Page Up or Page Down move a large step, and Home and End go as far as the other handle allows. The handles cannot cross. The visible count changes as you drag, and the new count is announced once, when the movement is committed, rather than on every step. The bars are decorative and hidden from assistive technology. Each handle has a hit area larger than its 20px dot, and the drag works anywhere on the chart, which picks the nearer handle.