Command Palette
The ⌘K switcher done properly: focus stays in the input while aria-activedescendant moves, arrows wrap, and the highlight resets on every query change.
The facet that is a number: a distribution over a dual-thumb slider built from two real range inputs, with typed bounds clamped on blur rather than per keystroke.
components/search-facet-range.tsxnpx hoverlab add search-facet-range
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <SearchFacetRange> — the facet that is a number, not a checkbox.
*
* Checkbox facets are the easy half of faceted search. Price, date and
* duration are the half that gets shipped as two bare number inputs labelled
* Min and Max, and that control is unusable for the thing people actually do
* with a range: work out where the results are before choosing a bound.
*
* THE HISTOGRAM IS THE CONTROL, NOT AN ORNAMENT
*
* Bars over the track show how many items fall in each bucket, so "under
* £200" stops being a guess. Bars outside the selected range are dimmed
* rather than hidden — the same argument `search-facet-panel` makes about
* zero-count options: a distribution that reshapes itself as you drag is
* disorienting, and knowing what you excluded is most of the value.
*
* TWO SLIDERS, NOT ONE `<input type="range">` WITH A HACK
*
* A dual-thumb range has no native element. The honest implementation is two
* overlaid sliders, each a real `<input type="range">` with its own label,
* clamped so the lower cannot cross the upper. That keeps keyboard support,
* arrow-key stepping and screen-reader announcement for free — all of which a
* div-with-pointer-events reimplementation loses and rarely puts back.
*
* THE NUMBER INPUTS STAY. Dragging is for exploring; typing is for "I have a
* budget of exactly £750". Both write the same state, and the typed value is
* clamped on blur rather than on every keystroke, so typing "1" on the way to
* "1200" does not snap the thumb to the floor.
*
* PRESETS ARE FOR THE COMMON CASES ONLY. Three, drawn from what people
* actually filter by, not a decile split of the data. A preset row that
* mirrors the histogram is a second copy of a control that already exists.
*
* ACCESSIBILITY: each thumb is a labelled slider with `aria-valuetext` in
* currency, because "420" announced bare is not a price. The result count is
* a polite live region; the histogram is `aria-hidden` and its information is
* available as the per-bucket counts in the visually hidden summary list.
*/
import * as React from 'react'
export interface RangeBucket {
/** Lower bound of the bucket. */
from: number
count: number
}
export interface SearchFacetRangeProps {
label?: string
unit?: string
min?: number
max?: number
step?: number
buckets?: RangeBucket[]
presets?: { label: string; from: number; to: number }[]
className?: string
}
const DEFAULT_BUCKETS: RangeBucket[] = [
{ from: 0, count: 4 },
{ from: 100, count: 11 },
{ from: 200, count: 26 },
{ from: 300, count: 38 },
{ from: 400, count: 31 },
{ from: 500, count: 19 },
{ from: 600, count: 12 },
{ from: 700, count: 7 },
{ from: 800, count: 5 },
{ from: 900, count: 2 },
]
const DEFAULT_PRESETS = [
{ label: 'Under £300', from: 0, to: 300 },
{ label: '£300–£600', from: 300, to: 600 },
{ label: 'Over £600', from: 600, to: 1000 },
]
function money(value: number, unit: string) {
return `${unit}${value.toLocaleString('en-GB')}`
}
export function SearchFacetRange({
label = 'Price',
unit = '£',
min = 0,
max = 1000,
step = 10,
buckets = DEFAULT_BUCKETS,
presets = DEFAULT_PRESETS,
className = '',
}: SearchFacetRangeProps) {
const uid = React.useId()
const [from, setFrom] = React.useState(200)
const [to, setTo] = React.useState(600)
const [fromText, setFromText] = React.useState('200')
const [toText, setToText] = React.useState('600')
const width = buckets.length > 1 ? buckets[1]!.from - buckets[0]!.from : 100
const tallest = Math.max(...buckets.map((b) => b.count), 1)
const inRange = buckets
.filter((b) => b.from + width > from && b.from < to)
.reduce((sum, b) => sum + b.count, 0)
function commitFrom(raw: string) {
const value = Number.parseInt(raw, 10)
const next = Number.isNaN(value) ? min : Math.min(Math.max(value, min), to - step)
setFrom(next)
setFromText(String(next))
}
function commitTo(raw: string) {
const value = Number.parseInt(raw, 10)
const next = Number.isNaN(value) ? max : Math.max(Math.min(value, max), from + step)
setTo(next)
setToText(String(next))
}
function apply(a: number, b: number) {
setFrom(a)
setTo(b)
setFromText(String(a))
setToText(String(b))
}
const pct = (value: number) => ((value - min) / (max - min)) * 100
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<div className="mx-auto max-w-sm rounded-2xl border border-border bg-card p-5">
<fieldset className="border-0 p-0">
<legend className="text-sm font-semibold">{label}</legend>
<p aria-live="polite" className="mt-0.5 text-xs text-muted-foreground">
{inRange} products between {money(from, unit)} and {money(to, unit)}
</p>
{/* Decorative: the same numbers are in the summary list below. */}
<div aria-hidden className="mt-4 flex h-16 items-end gap-0.5">
{buckets.map((bucket) => {
const inside = bucket.from + width > from && bucket.from < to
return (
<span
key={bucket.from}
style={{ height: `${Math.max(6, (bucket.count / tallest) * 100)}%` }}
className={`flex-1 rounded-t-sm border border-transparent transition-colors ${
inside ? 'bg-primary/70' : 'bg-muted'
}`}
/>
)
})}
</div>
{/* Two real sliders, overlaid. Both keep keyboard support. */}
<div className="relative mt-2 h-6">
<span
aria-hidden
className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted border border-transparent"
/>
<span
aria-hidden
style={{ insetInlineStart: `${pct(from)}%`, width: `${pct(to) - pct(from)}%` }}
className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary border border-transparent"
/>
<label htmlFor={`${uid}-from`} className="sr-only">
Lowest {label}
</label>
<input
id={`${uid}-from`}
type="range"
min={min}
max={max}
step={step}
value={from}
aria-valuetext={money(from, unit)}
onChange={(e) => {
const next = Math.min(Number(e.target.value), to - step)
setFrom(next)
setFromText(String(next))
}}
className="absolute inset-x-0 top-1/2 h-6 w-full -translate-y-1/2 appearance-none bg-transparent accent-primary"
/>
<label htmlFor={`${uid}-to`} className="sr-only">
Highest {label}
</label>
<input
id={`${uid}-to`}
type="range"
min={min}
max={max}
step={step}
value={to}
aria-valuetext={money(to, unit)}
onChange={(e) => {
const next = Math.max(Number(e.target.value), from + step)
setTo(next)
setToText(String(next))
}}
className="absolute inset-x-0 top-1/2 h-6 w-full -translate-y-1/2 appearance-none bg-transparent accent-primary"
/>
</div>
{/* Typing is for a known budget; clamped on blur, not per keystroke. */}
<div className="mt-4 flex items-center gap-2">
<div className="flex-1">
<label htmlFor={`${uid}-from-text`} className="sr-only">
Lowest {label}, typed
</label>
<input
id={`${uid}-from-text`}
inputMode="numeric"
value={fromText}
onChange={(e) => setFromText(e.target.value)}
onBlur={(e) => commitFrom(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<span aria-hidden className="text-sm text-muted-foreground">
to
</span>
<div className="flex-1">
<label htmlFor={`${uid}-to-text`} className="sr-only">
Highest {label}, typed
</label>
<input
id={`${uid}-to-text`}
inputMode="numeric"
value={toText}
onChange={(e) => setToText(e.target.value)}
onBlur={(e) => commitTo(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
<ul className="mt-3 flex flex-wrap gap-1.5">
{presets.map((preset) => {
const on = from === preset.from && to === preset.to
return (
<li key={preset.label}>
<button
type="button"
onClick={() => apply(preset.from, preset.to)}
aria-pressed={on}
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
on
? 'border-primary bg-primary/10 text-primary'
: 'border-border hover:bg-muted'
}`}
>
{preset.label}
</button>
</li>
)
})}
</ul>
{/* The histogram, as text. */}
<ul className="sr-only">
{buckets.map((bucket) => (
<li key={bucket.from}>
{money(bucket.from, unit)} to {money(bucket.from + width, unit)}:{' '}
{bucket.count} products
</li>
))}
</ul>
</fieldset>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/search-facet-range.tsx and import it where you need the section:
import { SearchFacetRange } from '@/components/search-facet-range'5 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
label | string | 'Price' |
unit | string | '£' |
min | number | 0 |
max | number | 1000 |
step | number | 10 |
buckets | RangeBucket[] | DEFAULT_BUCKETS |
presets | { label: string; from: number; to: number }[] | DEFAULT_PRESETS |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Range Facet with Histogram — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="bg-background px-4 py-10 sm:px-6 ">
<div class="mx-auto max-w-sm rounded-2xl border border-border bg-card p-5">
<fieldset class="border-0 p-0">
<legend class="text-sm font-semibold">Price</legend>
<p aria-live="polite" class="mt-0.5 text-xs text-muted-foreground">114 products between £200 and £600</p>
<div aria-hidden="true" class="mt-4 flex h-16 items-end gap-0.5">
<span style="height:10.526315789473683%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-muted"></span>
<span style="height:28.947368421052634%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-muted"></span>
<span style="height:68.42105263157895%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-primary/70"></span>
<span style="height:100%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-primary/70"></span>
<span style="height:81.57894736842105%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-primary/70"></span>
<span style="height:50%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-primary/70"></span>
<span style="height:31.57894736842105%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-muted"></span>
<span style="height:18.421052631578945%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-muted"></span>
<span style="height:13.157894736842104%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-muted"></span>
<span style="height:6%" class="flex-1 rounded-t-sm border border-transparent transition-colors bg-muted"></span>
</div>
<div class="relative mt-2 h-6">
<span aria-hidden="true" class="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted border border-transparent"></span>
<span aria-hidden="true" style="inset-inline-start:20%;width:40%" class="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary border border-transparent"></span>
<label for="_R_0_-from" class="sr-only">Lowest Price</label>
<input id="_R_0_-from" type="range" min="0" max="1000" step="10" aria-valuetext="£200" class="absolute inset-x-0 top-1/2 h-6 w-full -translate-y-1/2 appearance-none bg-transparent accent-primary" value="200" />
<label for="_R_0_-to" class="sr-only">Highest Price</label>
<input id="_R_0_-to" type="range" min="0" max="1000" step="10" aria-valuetext="£600" class="absolute inset-x-0 top-1/2 h-6 w-full -translate-y-1/2 appearance-none bg-transparent accent-primary" value="600" />
</div>
<div class="mt-4 flex items-center gap-2">
<div class="flex-1">
<label for="_R_0_-from-text" class="sr-only">Lowest Price, typed</label>
<input id="_R_0_-from-text" inputMode="numeric" class="w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="200" />
</div>
<span aria-hidden="true" class="text-sm text-muted-foreground">to</span>
<div class="flex-1">
<label for="_R_0_-to-text" class="sr-only">Highest Price, typed</label>
<input id="_R_0_-to-text" inputMode="numeric" class="w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="600" />
</div>
</div>
<ul class="mt-3 flex flex-wrap gap-1.5">
<li>
<button type="button" aria-pressed="false" class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted">Under £300</button>
</li>
<li>
<button type="button" aria-pressed="false" class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted">£300–£600</button>
</li>
<li>
<button type="button" aria-pressed="false" class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted">Over £600</button>
</li>
</ul>
<ul class="sr-only">
<li>£0 to £100: 4 products</li>
<li>£100 to £200: 11 products</li>
<li>£200 to £300: 26 products</li>
<li>£300 to £400: 38 products</li>
<li>£400 to £500: 31 products</li>
<li>£500 to £600: 19 products</li>
<li>£600 to £700: 12 products</li>
<li>£700 to £800: 7 products</li>
<li>£800 to £900: 5 products</li>
<li>£900 to £1,000: 2 products</li>
</ul>
</fieldset>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Command & SearchCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
The ⌘K switcher done properly: focus stays in the input while aria-activedescendant moves, arrows wrap, and the highlight resets on every query change.
Results grouped by section with visible breadcrumb paths and <mark>-highlighted matches — users recognise where a result lives faster than they read its snippet.
The "?" overlay, with chords written once in one notation and rendered per platform — mod becomes Command on Apple and Ctrl everywhere else, decided after mount so the markup never mismatches.
The strip above a result set that answers "why am I only seeing four things" — every active constraint as a removable chip, with the excluded count beside it.
The field in the header, not the palette over it: Enter searches for what you typed unless you arrowed to a suggestion, late responses are dropped by sequence number, and recent searches are removable one at a time.
Faceted search with result counts on every option, zero-count options kept visible and disabled so the list stays stable, and a note explaining why the counts do not sum.
One query counted across every scope it could have matched, as a real tablist with arrow-key movement — so an answer that is nowhere is visible without opening four tabs.
The empty state of a search box, filled with the searches that are worth repeating and the ones worth saving.
The whole faceted search assembled - rail, chips, live count and sort - with the intersection actually applied and relevance offered only when there is a query.