Histogram
import { useState } from 'react';
const W = 240;
const T = 18;
const MAX = 1200;
const GAP = 100;
const H = 46;
const BINS = [3, 5, 9, 14, 22, 30, 38, 41, 37, 33, 28, 24, 20, 17, 15, 12, 10, 9, 7, 6, 5, 4, 3, 2];
const PEAK = Math.max(...BINS);
const BW = (W - T) / BINS.length;
const RAIL = H + 14;
const at = (n: number) => T / 2 + (n / MAX) * (W - T);
const css = `
.slider-histogram { position: relative; width: ${W}px; height: 104px; -webkit-user-select: none; user-select: none; }
.slider-histogram__bar {
position: absolute;
bottom: calc(100% - ${H}px);
width: ${BW - 2}px;
border-radius: 2px 2px 0 0;
background: light-dark(#dbdde1, #2e3035);
transform-origin: bottom;
transform: scaleY(0.45);
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1), background-color 0.25s;
}
.slider-histogram__bar--in { background: light-dark(#50535b, #c6c8cd); transform: scaleY(1); }
.slider-histogram__rail { position: absolute; left: ${T / 2}px; right: ${T / 2}px; top: ${RAIL - 2}px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-histogram__band { position: absolute; top: ${RAIL - 2}px; height: 4px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); }
.slider-histogram__thumb {
position: absolute;
top: ${RAIL - T / 2}px;
width: ${T}px;
height: ${T}px;
margin-left: ${-T / 2}px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
}
.slider-histogram:has(.slider-histogram__input--lo:focus-visible) .slider-histogram__thumb--lo,
.slider-histogram:has(.slider-histogram__input--hi:focus-visible) .slider-histogram__thumb--hi { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-histogram__value { position: absolute; left: 0; right: 0; top: ${RAIL + 22}px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.slider-histogram__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.slider-histogram__input { position: absolute; left: 0; top: ${RAIL - 14}px; width: 100%; height: 28px; margin: 0; opacity: 0; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-histogram__input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: ${T}px; height: 28px; cursor: grab; }
.slider-histogram__input::-moz-range-thumb { pointer-events: auto; width: ${T}px; height: 28px; border: 0; cursor: grab; }
@media (prefers-reduced-motion: reduce) {
.slider-histogram__bar { transition: background-color 0.25s; }
}
`;
export function HistogramSlider() {
const [lo, setLo] = useState(300);
const [hi, setHi] = useState(750);
// A bin counts as in range when its middle price is.
const inside = BINS.map((_, i) => {
const mid = (i + 0.5) * (MAX / BINS.length);
return mid >= lo && mid <= hi;
});
const count = BINS.reduce((n, c, i) => n + (inside[i] ? c : 0), 0);
return (
<>
<style>
{css}
</style>
<div className="slider-histogram">
{BINS.map((c, i) => (
<span
key={i}
className={`slider-histogram__bar${inside[i] ? ' slider-histogram__bar--in' : ''}`}
style={{ left: T / 2 + i * BW + 1, height: (c / PEAK) * H }}
aria-hidden="true"
/>
))}
<span className="slider-histogram__rail" aria-hidden="true" />
<span className="slider-histogram__band" style={{ left: at(lo), width: at(hi) - at(lo) }} aria-hidden="true" />
<span className="slider-histogram__thumb slider-histogram__thumb--lo" style={{ left: at(lo) }} aria-hidden="true" />
<span className="slider-histogram__thumb slider-histogram__thumb--hi" style={{ left: at(hi) }} aria-hidden="true" />
<p className="slider-histogram__value" aria-hidden="true">
<b>
${lo} – ${hi}
</b>
<span>{count} results</span>
</p>
<input
className="slider-histogram__input slider-histogram__input--lo"
type="range"
min={0}
max={MAX}
step={50}
value={lo}
style={{ zIndex: lo > MAX / 2 ? 2 : 1 }}
onChange={(e) => setLo(Math.min(+e.target.value, hi - GAP))}
aria-label="Minimum price"
aria-valuetext={`$${lo}`}
/>
<input
className="slider-histogram__input slider-histogram__input--hi"
type="range"
min={0}
max={MAX}
step={50}
value={hi}
style={{ zIndex: lo > MAX / 2 ? 1 : 2 }}
onChange={(e) => setHi(Math.max(+e.target.value, lo + GAP))}
aria-label="Maximum price"
aria-valuetext={`$${hi}, ${count} results`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderHistogram(el) on the mounted element instead.
<div class="slider-histogram">
<span class="slider-histogram__bar" style="left:10px;height:3.366px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:19.25px;height:5.61px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:28.5px;height:10.098px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:37.75px;height:15.707px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:47px;height:24.683px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:56.25px;height:33.659px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:65.5px;height:42.634px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:74.75px;height:46px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:84px;height:41.512px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:93.25px;height:37.024px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:102.5px;height:31.415px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:111.75px;height:26.927px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:121px;height:22.439px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:130.25px;height:19.073px" aria-hidden="true"></span>
<span class="slider-histogram__bar slider-histogram__bar--in" style="left:139.5px;height:16.829px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:148.75px;height:13.463px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:158px;height:11.22px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:167.25px;height:10.098px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:176.5px;height:7.854px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:185.75px;height:6.732px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:195px;height:5.61px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:204.25px;height:4.488px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:213.5px;height:3.366px" aria-hidden="true"></span>
<span class="slider-histogram__bar" style="left:222.75px;height:2.244px" aria-hidden="true"></span>
<span class="slider-histogram__rail" aria-hidden="true"></span>
<span class="slider-histogram__band" style="left:64.5px;width:83.25px" aria-hidden="true"></span>
<span class="slider-histogram__thumb slider-histogram__thumb--lo" style="left:64.5px" aria-hidden="true"></span>
<span class="slider-histogram__thumb slider-histogram__thumb--hi" style="left:147.75px" aria-hidden="true"></span>
<p class="slider-histogram__value" aria-hidden="true">
<b>$300 – $750</b>
<span>253 results</span>
</p>
<input class="slider-histogram__input slider-histogram__input--lo" type="range" min="0" max="1200" step="50" style="z-index:1" aria-label="Minimum price" aria-valuetext="$300" value="300"/>
<input class="slider-histogram__input slider-histogram__input--hi" type="range" min="0" max="1200" step="50" style="z-index:2" aria-label="Maximum price" aria-valuetext="$750, 253 results" value="750"/>
</div>.slider-histogram { position: relative; width: 240px; height: 104px; -webkit-user-select: none; user-select: none; }
.slider-histogram__bar {
position: absolute;
bottom: calc(100% - 46px);
width: 7.25px;
border-radius: 2px 2px 0 0;
background: light-dark(#dbdde1, #2e3035);
transform-origin: bottom;
transform: scaleY(0.45);
transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1), background-color 0.25s;
}
.slider-histogram__bar--in { background: light-dark(#50535b, #c6c8cd); transform: scaleY(1); }
.slider-histogram__rail { position: absolute; left: 9px; right: 9px; top: 58px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-histogram__band { position: absolute; top: 58px; height: 4px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); }
.slider-histogram__thumb {
position: absolute;
top: 51px;
width: 18px;
height: 18px;
margin-left: -9px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
}
.slider-histogram:has(.slider-histogram__input--lo:focus-visible) .slider-histogram__thumb--lo,
.slider-histogram:has(.slider-histogram__input--hi:focus-visible) .slider-histogram__thumb--hi { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-histogram__value { position: absolute; left: 0; right: 0; top: 82px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.slider-histogram__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.slider-histogram__input { position: absolute; left: 0; top: 46px; width: 100%; height: 28px; margin: 0; opacity: 0; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-histogram__input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 18px; height: 28px; cursor: grab; }
.slider-histogram__input::-moz-range-thumb { pointer-events: auto; width: 18px; height: 28px; border: 0; cursor: grab; }
@media (prefers-reduced-motion: reduce) {
.slider-histogram__bar { transition: background-color 0.25s; }
}function initSliderHistogram(root) {
var W = 240;
var T = 18;
var MAX = 1200;
var GAP = 100;
var BINS = [3, 5, 9, 14, 22, 30, 38, 41, 37, 33, 28, 24, 20, 17, 15, 12, 10, 9, 7, 6, 5, 4, 3, 2];
var lo = root.querySelector('.slider-histogram__input--lo');
var hi = root.querySelector('.slider-histogram__input--hi');
var bars = root.querySelectorAll('.slider-histogram__bar');
var band = root.querySelector('.slider-histogram__band');
var thumbLo = root.querySelector('.slider-histogram__thumb--lo');
var thumbHi = root.querySelector('.slider-histogram__thumb--hi');
var range = root.querySelector('.slider-histogram__value b');
var count = root.querySelector('.slider-histogram__value span');
function at(n) { return T / 2 + (n / MAX) * (W - T); }
function update(e) {
var a = +lo.value;
var b = +hi.value;
if (e && e.target === lo && a > b - GAP) lo.value = a = b - GAP;
if (e && e.target === hi && b < a + GAP) hi.value = b = a + GAP;
// A bin counts as in range when its middle price is; in-range bars rise, the rest sink.
var n = 0;
bars.forEach(function (bar, i) {
var mid = (i + 0.5) * (MAX / BINS.length);
var inside = mid >= a && mid <= b;
bar.classList.toggle('slider-histogram__bar--in', inside);
if (inside) n += BINS[i];
});
band.style.left = at(a) + 'px';
band.style.width = at(b) - at(a) + 'px';
thumbLo.style.left = at(a) + 'px';
thumbHi.style.left = at(b) + 'px';
range.textContent = '$' + a + ' – $' + b;
count.textContent = n + ' results';
lo.setAttribute('aria-valuetext', '$' + a);
hi.setAttribute('aria-valuetext', '$' + b + ', ' + n + ' results');
// Near the top end the low thumb sits on top, so the pair can always be pulled apart.
lo.style.zIndex = a > MAX / 2 ? 2 : 1;
hi.style.zIndex = a > MAX / 2 ? 1 : 2;
}
lo.addEventListener('input', update);
hi.addEventListener('input', update);
return function () {
lo.removeEventListener('input', update);
hi.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-histogram').forEach(initSliderHistogram);Goal: Create a "Histogram" slider.
Visual behavior: A price range drawn over its own histogram: bars inside the two handles rise to full ink, bars outside sink and fade, and the count of results updates as you drag.
Technique:
- Two real <input type="range"> elements share the track, transparent, with pointer-events off on the inputs and back on for their thumbs, so either handle can be dragged and each works from the keyboard. The low one stacks on top past the midpoint so the pair can always be pulled apart. The drawing is aria-hidden.
- The bars are spans sized from the bin counts, scaled down from the bottom with scaleY. A bin is in range when its middle price is; in-range bars get a class that scales them to full height and darkens them, through a transition with a small overshoot.
- The results count is the sum of the in-range bins, read out beside the range.
Accessibility: Two native range inputs labelled Minimum price and Maximum price, with dollar aria-valuetext and the results count on the maximum; keyboard focus rings the handle. Under prefers-reduced-motion the bars change without the scale transition.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Histogram" slider.
Visual behavior: A price range drawn over its own histogram: bars inside the two handles rise to full ink, bars outside sink and fade, and the count of results updates as you drag.
Technique:
- Two real <input type="range"> elements share the track, transparent, with pointer-events off on the inputs and back on for their thumbs, so either handle can be dragged and each works from the keyboard. The low one stacks on top past the midpoint so the pair can always be pulled apart. The drawing is aria-hidden.
- The bars are spans sized from the bin counts, scaled down from the bottom with scaleY. A bin is in range when its middle price is; in-range bars get a class that scales them to full height and darkens them, through a transition with a small overshoot.
- The results count is the sum of the in-range bins, read out beside the range.
Accessibility: Two native range inputs labelled Minimum price and Maximum price, with dollar aria-valuetext and the results count on the maximum; keyboard focus rings the handle. Under prefers-reduced-motion the bars change without the scale transition.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A price range drawn over its own histogram: bars inside the two handles rise to full ink, bars outside sink and fade, and the count of results updates as you drag.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring
- Techniques
- css transition, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Two native range inputs labelled Minimum price and Maximum price, with dollar aria-valuetext and the results count on the maximum; keyboard focus rings the handle. Under prefers-reduced-motion the bars change without the scale transition.