Histogram

Price536 products
No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-histogram
Source on GitHub

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.

More inputs

See all
Ink
Mask
Measure
Mood