Histogram
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes field-histogram-grow { from { transform: scaleY(0); } }
.field-histogram { width: 250px; }
.field-histogram__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.field-histogram__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-histogram__count { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.field-histogram__bars { display: flex; align-items: flex-end; gap: 2px; height: 64px; padding: 0 9px; }
.field-histogram__bar {
flex: 1;
border-radius: 2px 2px 0 0;
background: light-dark(#dbdde1, #2e3035);
transform-origin: 50% 100%;
animation: field-histogram-grow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) both;
transition: background-color 0.2s;
}
.field-histogram__bar[data-in] { background: light-dark(#42454c, #e5e6e9); }
.field-histogram__slider { position: relative; height: 20px; margin-top: -1px; }
.field-histogram__line { position: absolute; left: 9px; right: 9px; top: 9px; height: 2px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); }
.field-histogram__sel { position: absolute; top: 9px; height: 2px; background: light-dark(#373a40, #f1f2f3); }
.field-histogram__range {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 20px;
margin: 0;
background: none;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
}
.field-histogram__range::-webkit-slider-runnable-track { height: 20px; background: none; }
.field-histogram__range::-moz-range-track { height: 20px; background: none; }
.field-histogram__range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
margin-top: 1px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 2px 6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: grab;
transition: transform 0.15s;
}
.field-histogram__range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 2px 6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: grab;
}
.field-histogram__range:focus { outline: none; }
.field-histogram__range:active::-webkit-slider-thumb { transform: scale(1.15); cursor: grabbing; }
.field-histogram__range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#5f626a, #a7abb2); }
.field-histogram__range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#5f626a, #a7abb2); }
.field-histogram__vals { display: flex; justify-content: space-between; margin-top: 8px; }
.field-histogram__vals span { padding: 3px 8px; border-radius: 6px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font-family: inherit; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .field-histogram__bar { animation: none; } }
`;
const BINS = 24;
const STEP = 20;
// How many products sit in each $20 price band: a skewed hump with a long tail.
const COUNTS = Array.from({ length: BINS }, (_, i) => {
const x = i / BINS;
const n = Math.sin(i * 9.17) * 43758.5;
return Math.round(4 + 70 * Math.exp(-(((x - 0.28) / 0.17) ** 2)) + 24 * Math.exp(-(((x - 0.7) / 0.1) ** 2)) + (n - Math.floor(n)) * 10);
});
const MAX = Math.max(...COUNTS);
function useTween(target: number) {
const [v, setV] = useState(target);
const cur = useRef(target);
useEffect(() => {
let raf = 0;
const step = () => {
cur.current += (target - cur.current) * 0.25;
if (Math.abs(target - cur.current) < 0.5) cur.current = target;
setV(cur.current);
if (cur.current !== target) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [target]);
return Math.round(v);
}
export function HistogramInput() {
const [lo, setLo] = useState(4);
const [hi, setHi] = useState(15);
const count = COUNTS.slice(lo, hi).reduce((a, b) => a + b, 0);
const shown = useTween(count);
const pct = (i: number) => `calc(9px + (100% - 18px) * ${i / BINS})`;
return (
<>
<style>
{css}
</style>
<div className="field-histogram" role="group" aria-labelledby="field-histogram-label">
<div className="field-histogram__head">
<span id="field-histogram-label" className="field-histogram__label">
Price
</span>
<span className="field-histogram__count" aria-live="polite">
{shown} products
</span>
</div>
<div className="field-histogram__bars" aria-hidden="true">
{COUNTS.map((c, i) => (
<span
key={i}
className="field-histogram__bar"
data-in={(i >= lo && i < hi) || undefined}
style={{ height: `${(c / MAX) * 100}%`, animationDelay: `${i * 18}ms` }}
/>
))}
</div>
<div className="field-histogram__slider">
<span className="field-histogram__line" />
<span className="field-histogram__sel" style={{ left: pct(lo), right: `calc(100% - ${pct(hi)})` }} />
<input
className="field-histogram__range"
type="range"
min={0}
max={BINS}
value={lo}
aria-label="Minimum price"
aria-valuetext={`$${lo * STEP}`}
onChange={(e) => setLo(Math.min(Number(e.target.value), hi - 1))}
/>
<input
className="field-histogram__range"
type="range"
min={0}
max={BINS}
value={hi}
aria-label="Maximum price"
aria-valuetext={`$${hi * STEP}`}
onChange={(e) => setHi(Math.max(Number(e.target.value), lo + 1))}
/>
</div>
<div className="field-histogram__vals" aria-hidden="true">
<span>${lo * STEP}</span>
<span>${hi * STEP}{hi === BINS ? '+' : ''}</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldHistogram(el) on the mounted element instead.
<div class="field-histogram" role="group" aria-labelledby="field-histogram-label">
<div class="field-histogram__head">
<span id="field-histogram-label" class="field-histogram__label">Price</span>
<span class="field-histogram__count" aria-live="polite">536 products</span>
</div>
<div class="field-histogram__bars" aria-hidden="true">
<span class="field-histogram__bar" style="height:10.975609756097562%;animation-delay:0ms"></span>
<span class="field-histogram__bar" style="height:23.170731707317074%;animation-delay:18ms"></span>
<span class="field-histogram__bar" style="height:28.04878048780488%;animation-delay:36ms"></span>
<span class="field-histogram__bar" style="height:46.34146341463415%;animation-delay:54ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:64.63414634146342%;animation-delay:72ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:87.8048780487805%;animation-delay:90ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:91.46341463414635%;animation-delay:108ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:100%;animation-delay:126ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:87.8048780487805%;animation-delay:144ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:69.51219512195121%;animation-delay:162ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:50%;animation-delay:180ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:34.146341463414636%;animation-delay:198ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:26.82926829268293%;animation-delay:216ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:19.51219512195122%;animation-delay:234ms"></span>
<span class="field-histogram__bar" data-in="true" style="height:21.951219512195124%;animation-delay:252ms"></span>
<span class="field-histogram__bar" style="height:26.82926829268293%;animation-delay:270ms"></span>
<span class="field-histogram__bar" style="height:35.36585365853659%;animation-delay:288ms"></span>
<span class="field-histogram__bar" style="height:45.1219512195122%;animation-delay:306ms"></span>
<span class="field-histogram__bar" style="height:34.146341463414636%;animation-delay:324ms"></span>
<span class="field-histogram__bar" style="height:25.609756097560975%;animation-delay:342ms"></span>
<span class="field-histogram__bar" style="height:13.414634146341465%;animation-delay:360ms"></span>
<span class="field-histogram__bar" style="height:18.29268292682927%;animation-delay:378ms"></span>
<span class="field-histogram__bar" style="height:8.536585365853659%;animation-delay:396ms"></span>
<span class="field-histogram__bar" style="height:12.195121951219512%;animation-delay:414ms"></span>
</div>
<div class="field-histogram__slider">
<span class="field-histogram__line"></span>
<span class="field-histogram__sel" style="left:calc(9px + (100% - 18px) * 0.16666666666666666);right:calc(100% - calc(9px + (100% - 18px) * 0.625))"></span>
<input class="field-histogram__range" type="range" min="0" max="24" aria-label="Minimum price" aria-valuetext="$80" value="4"/>
<input class="field-histogram__range" type="range" min="0" max="24" aria-label="Maximum price" aria-valuetext="$300" value="15"/>
</div>
<div class="field-histogram__vals" aria-hidden="true">
<span>$80</span>
<span>$300</span>
</div>
</div>@keyframes field-histogram-grow { from { transform: scaleY(0); } }
.field-histogram { width: 250px; }
.field-histogram__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.field-histogram__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-histogram__count { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.field-histogram__bars { display: flex; align-items: flex-end; gap: 2px; height: 64px; padding: 0 9px; }
.field-histogram__bar {
flex: 1;
border-radius: 2px 2px 0 0;
background: light-dark(#dbdde1, #2e3035);
transform-origin: 50% 100%;
animation: field-histogram-grow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) both;
transition: background-color 0.2s;
}
.field-histogram__bar[data-in] { background: light-dark(#42454c, #e5e6e9); }
.field-histogram__slider { position: relative; height: 20px; margin-top: -1px; }
.field-histogram__line { position: absolute; left: 9px; right: 9px; top: 9px; height: 2px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); }
.field-histogram__sel { position: absolute; top: 9px; height: 2px; background: light-dark(#373a40, #f1f2f3); }
.field-histogram__range {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 20px;
margin: 0;
background: none;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
}
.field-histogram__range::-webkit-slider-runnable-track { height: 20px; background: none; }
.field-histogram__range::-moz-range-track { height: 20px; background: none; }
.field-histogram__range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
margin-top: 1px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 2px 6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: grab;
transition: transform 0.15s;
}
.field-histogram__range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 2px 6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: grab;
}
.field-histogram__range:focus { outline: none; }
.field-histogram__range:active::-webkit-slider-thumb { transform: scale(1.15); cursor: grabbing; }
.field-histogram__range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#5f626a, #a7abb2); }
.field-histogram__range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#5f626a, #a7abb2); }
.field-histogram__vals { display: flex; justify-content: space-between; margin-top: 8px; }
.field-histogram__vals span { padding: 3px 8px; border-radius: 6px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font-family: inherit; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .field-histogram__bar { animation: none; } }function initFieldHistogram(root) {
var ranges = root.querySelectorAll('.field-histogram__range');
var bars = root.querySelectorAll('.field-histogram__bar');
var sel = root.querySelector('.field-histogram__sel');
var count = root.querySelector('.field-histogram__count');
var vals = root.querySelectorAll('.field-histogram__vals span');
if (ranges.length !== 2 || !bars.length || !sel || !count || vals.length !== 2) return;
var BINS = bars.length;
var STEP = 20;
// How many products sit in each $20 band: the same numbers the bars were drawn from.
var COUNTS = [];
for (var i = 0; i < BINS; i++) {
var x = i / BINS;
var n = Math.sin(i * 9.17) * 43758.5;
COUNTS.push(Math.round(4 + 70 * Math.exp(-Math.pow((x - 0.28) / 0.17, 2)) + 24 * Math.exp(-Math.pow((x - 0.7) / 0.1, 2)) + (n - Math.floor(n)) * 10));
}
var lo = Number(ranges[0].value);
var hi = Number(ranges[1].value);
var shown = null;
var raf = 0;
function pct(i) { return 'calc(9px + (100% - 18px) * ' + i / BINS + ')'; }
function tweenCount(target) {
cancelAnimationFrame(raf);
if (shown === null) shown = target;
function step() {
shown += (target - shown) * 0.25;
if (Math.abs(target - shown) < 0.5) shown = target;
count.textContent = Math.round(shown) + ' products';
if (shown !== target) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function render() {
bars.forEach(function (b, i) { b.toggleAttribute('data-in', i >= lo && i < hi); });
sel.style.left = pct(lo);
sel.style.right = 'calc(100% - ' + pct(hi) + ')';
ranges[0].setAttribute('aria-valuetext', '$' + lo * STEP);
ranges[1].setAttribute('aria-valuetext', '$' + hi * STEP);
vals[0].textContent = '$' + lo * STEP;
vals[1].textContent = '$' + hi * STEP + (hi === BINS ? '+' : '');
tweenCount(COUNTS.slice(lo, hi).reduce(function (a, b) { return a + b; }, 0));
}
// Each thumb stays at least one band away from the other.
function onLo() {
lo = Math.min(Number(ranges[0].value), hi - 1);
ranges[0].value = String(lo);
render();
}
function onHi() {
hi = Math.max(Number(ranges[1].value), lo + 1);
ranges[1].value = String(hi);
render();
}
ranges[0].addEventListener('input', onLo);
ranges[1].addEventListener('input', onHi);
render();
return function () {
cancelAnimationFrame(raf);
ranges[0].removeEventListener('input', onLo);
ranges[1].removeEventListener('input', onHi);
};
}
document.querySelectorAll('.field-histogram').forEach(initFieldHistogram);Goal: Create a "Histogram" input.
Visual behavior: A price-range slider over the distribution of prices: bars grow in on load, the bars inside the chosen range light up, the selection line follows the two thumbs and the product count rolls to the new total.
Technique:
- Two native range inputs are stacked over one track with pointer-events: none, and their thumbs re-enable pointer events, so both handles are draggable and fully accessible.
- Bars are a flex row of 24 bands with heights from the data; they grow in from the bottom with staggered delays, and bands inside the range switch tone.
- Each thumb is clamped one band away from the other; the count eases toward the sum of the selected bands on requestAnimationFrame.
Accessibility: Two native range inputs labelled Minimum price and Maximum price, with dollar aria-valuetext; the count is aria-live. Under prefers-reduced-motion the bars do not grow in.
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" input.
Visual behavior: A price-range slider over the distribution of prices: bars grow in on load, the bars inside the chosen range light up, the selection line follows the two thumbs and the product count rolls to the new total.
Technique:
- Two native range inputs are stacked over one track with pointer-events: none, and their thumbs re-enable pointer events, so both handles are draggable and fully accessible.
- Bars are a flex row of 24 bands with heights from the data; they grow in from the bottom with staggered delays, and bands inside the range switch tone.
- Each thumb is clamped one band away from the other; the count eases toward the sum of the selected bands on requestAnimationFrame.
Accessibility: Two native range inputs labelled Minimum price and Maximum price, with dollar aria-valuetext; the count is aria-live. Under prefers-reduced-motion the bars do not grow in.
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 slider over the distribution of prices: bars grow in on load, the bars inside the chosen range light up, the selection line follows the two thumbs and the product count rolls to the new total.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, fill, delay stagger
- Techniques
- keyframes, raf, css transition
- 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; the count is aria-live. Under prefers-reduced-motion the bars do not grow in.