Design Lounge
Install

Nº 622 of 629

Price range over a histogram

A two-thumb nightly price filter sitting on a histogram of cabin prices: bars fill as the range covers them, the count updates live and the thumbs squash when they meet.

Preview

Open full size
1280 × 800 · 20.5 KB · 296 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Price range over a histogram" (piece slider-range-histogram) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

233 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The price filter in the side panel of “Larchway”, an invented cabin rental search for Kettle Valley. A 40-bar histogram of nightly prices sits right on top of a two-thumb range slider, so you can see where the cabins are before you choose. Bars inside the range fill spruce green, bars outside stay sand, and the bar under each thumb fills only partly, so the fill tracks the thumb pixel for pixel. The result count (“56 cabins”) updates in the headline and in the panel’s button as you slide, and the four best-rated cabins in range redraw beside it. Worth copying: the partial bar fill, two thumbs that cannot cross and visibly squash against each other, and min/max boxes that stay in sync both ways.

Reference behaviour

  1. First frame at 1280 × 800: a 64px white top bar (logo “Larchway”, search pill “Kettle Valley | 14 to 17 Nov | 2 guests”, avatar “IA”). Below, a 440px filter panel at left and the results at right, 32px gap, 24px 32px padding.
  2. Panel: header “Filters” with “Clear all”; section “Price per night” with the note “Before fees and taxes. Most cabins here sit around the dashed line.”; the histogram (112px tall), the track, end labels “$60” and “$460+”, then two boxes “Minimum $120” “to” “Maximum $200”; section “Type of stay” with four chips (A-frame, Log cabin, Treehouse, Yurt); footer “Reset price” and a green button “Show 56 cabins”.
  3. Data: 124 invented cabins with seeded prices (lognormal around $170, a 14% cluster near $300, capped at $520). Bins are $10 wide from $60 to $460; anything at or above $450 lands in the last bin. Bar height = 6% + 94% of count / tallest bin; an empty bin is a 2% stub.
  4. A dashed ember line marks the median price with the label “Median $182” on a panel-coloured chip at its top.
  5. Range defaults to $120 to $200. Step $5. Thumbs keep a $20 minimum gap. The maximum at $460 means “$460 and above” and shows “$460+”.
  6. Bars fully inside the range are #2e5e47; outside are #d9d2c3; a bar the thumb sits inside is painted with a hard-stop gradient so only the covered part is green.
  7. Results column: “Kettle Valley, 14 to 17 Nov, 2 guests”, the headline “56 cabins” (Bitter 30px/700), “From $120 to $200 a night, sorted by guest rating”, then a 2 × 2 grid of cards (the top four in range by rating, then price). Each card: a drawn 172px landscape (sky gradient, sun, two hill layers, a cabin silhouette per type with a lit window), name, rating with an ember star, type and distance, “$154 night”.
  8. Drag a thumb: pointer capture on the rail; the thumb scales to 1.18 with an overshoot curve and a 9px soft halo; a dark tooltip above it shows “$175”. Values snap to $5; everything (bars, count, button, cards, boxes) updates on every move.
  9. Press anywhere on the histogram or track: the nearer thumb springs to that price (k 700, c 38, about 250ms with a small overshoot) and then follows the pointer.
  10. Thumbs cannot cross. Push one into the other and it stops at the $20 gap; keep pushing and the moving thumb squashes horizontally, up to 24% narrower at $110 of overshoot (1 − overshoot/110). Release and it springs back round.
  11. The count in the button gives a 260ms bump (translateY −2px, scale 1.06) whenever it changes.
  12. Typing in a box moves its thumb live once the number is valid; Enter or blur clamps it ($60 to max − 20; min + 20 to $460) and rewrites the box. ArrowUp/Down in a box step $5, with Shift $50.
  13. Chips filter by type (aria-pressed, ink fill and a check). The histogram bars re-grow over 260ms to show only those types, the median moves, and the count recomputes.
  14. “Reset price” springs back to $120 to $200; “Clear all” clears the chips and opens the range to $60 to $460+.
  15. If nothing is in range, the grid shows a dashed box: “No cabins in this range. Try widening the price.”

Structure

1280 × 800
┌───────────────────────────────────────────────────────────────────────────────┐
│ ▲ Larchway  ( Kettle Valley | 14 to 17 Nov | 2 guests )                  (IA) │ 64
├───────────────────────────────────────────────────────────────────────────────┤
│ ┌ panel 440, r16 ─────────────┐  32  Kettle Valley, 14 to 17 Nov, 2 guests    │
│ │ Filters           Clear all │      56 cabins                    Bitter 30   │
│ ├─────────────────────────────┤      From $120 to $200 a night, sorted by…    │
│ │ Price per night             │      ┌ card ──────────┐ 20 ┌ card ──────────┐ │
│ │ Before fees and taxes…      │      │ landscape 172  │    │ landscape 172  │ │
│ │        ┆Median $182         │      │ Name     ★4.98 │    │ Name     ★4.98 │ │
│ │ ▁▁▃▇██▇▆▇█▅▃▅▆▄▃▂▂▃▃▁▁▁▁▁▁ │ 112  │ Type · km  $154│    │ Type · km  $177│ │
│ │ ───◯━━━━━━━◯────────────── │ 28   └────────────────┘    └────────────────┘ │
│ │ $60                   $460+ │      ┌ card ──────────┐    ┌ card ──────────┐ │
│ │ Minimum          Maximum    │      │                │    │                │ │
│ │ [$ 120]    to    [$ 200]    │ 44   └────────────────┘    └────────────────┘ │
│ ├─────────────────────────────┤                                               │
│ │ Type of stay                │                                               │
│ │ (A-frame)(Log cabin)(Tree…) │ 36                                            │
│ ├─────────────────────────────┤                                               │
│ │ Reset price  [Show 56 cabins]│ 48                                           │
│ └─────────────────────────────┘                                               │
└───────────────────────────────────────────────────────────────────────────────┘
  • header.top with the logo span, a non-interactive query pill, and the avatar.
  • aside.panel labelled by its h2; each block is a section labelled by its h3.
  • .rail (padding 0 14px, touch-action: none) holds .hist (aria-hidden, flex of 40 i bars, gap 2px, plus the median marker) and .track (28px, pulled up 11px so the line sits under the bars) with the green .sel span, two span role="slider" thumbs and two aria-hidden tooltips.
  • Min/max: real label + input inputmode="numeric" inside a bordered .field with a ”$” prefix.
  • Chips are button aria-pressed. Footer: a text button and the primary button with the count in a b.
  • main.res with the headline h1 and a .grid of a.card elements.
  • One visually hidden p aria-live="polite".

Tokens

:root {
  /* colour */
  --page: #efebe2;      /* page ground */
  --panel: #fbfaf6;     /* panel, cards, top bar, fields */
  --ink: #1b2620;       /* text, selected chip, tooltip */
  --ink-2: #535d57;     /* secondary text, 6.2:1 on panel */
  --line: #ddd7cb;      /* hairlines and card borders */
  --edge: #857c6e;      /* field, chip and pill borders, 3.8:1 on panel */
  --bar: #d9d2c3;       /* bars outside the range, track rail */
  --fill: #2e5e47;      /* bars inside, selected track, thumb ring, button */
  --fill-2: #24503b;    /* button hover */
  --ember: #b0491f;     /* median line and label, focus ring, stars */
  --window: #f5c46b;    /* lit cabin window in the drawings */

  /* type */
  --serif: "Bitter", Georgia, serif;
  --sans: "Kumbh Sans", system-ui, sans-serif;
  --fs-h1: 30px; --fs-logo: 21px; --fs-panel-h: 18px; --fs-card-h: 16px;
  --fs-body: 15px; --fs-small: 13px; --fs-micro: 12px;

  /* geometry */
  --panel-w: 440px; --gap: 32px; --top-h: 64px;
  --hist-h: 112px; --bars: 40; --bar-gap: 2px; --bar-r: 2px 2px 0 0;
  --rail-pad: 14px; --track-line: 4px; --thumb: 28px; --thumb-ring: 2px; --thumb-hit: 44px;
  --field-h: 44px; --chip-h: 36px; --cta-h: 48px; --card-art: 172px;
  --r-panel: 16px; --r-card: 14px; --r-cta: 12px; --r-field: 10px;
  --space: 4px 8px 12px 16px 20px 24px 32px;

  /* behaviour */
  --min: 60; --max: 460; --step: 5; --gap-min: 20; --bin: 10;
  --default: 120 200; --shift-step: 50; --squash-max: .24; --squash-per: 110;

  /* motion */
  --ease: cubic-bezier(.2,.7,.2,1);
  --pop: cubic-bezier(.34,1.56,.64,1);
  --thumb-press: 240ms; --bars-grow: 260ms; --tip: 140ms; --bump: 260ms;
  --spring: 700 38;   /* stiffness, damping, for jumps, keys, resets */
  --shadow-thumb: 0 1px 3px rgba(27,38,32,.25);
  --halo-press: 0 0 0 9px rgba(46,94,71,.16);
}

Typography

RoleFamilySizeWeightLine-heightNotes
Results headlineBitter30px7001.15−0.01em, tabular figures
LogoBitter21px7001−0.01em
Panel titleBitter18px6001.2
Card nameBitter16px6001.3
Section headingKumbh Sans15px6001.45
Field valueKumbh Sans16px6001tabular
ButtonKumbh Sans16px600, count 7001tabular
Chips, linksKumbh Sans14px500 / 6001links underlined, offset 3px
Notes, card metaKumbh Sans13px4001.45--ink-2
Field labels, end labels, medianKumbh Sans12px600 / 400 / 6001.2
TooltipKumbh Sans13px6001#fbfaf6 on ink, tabular

Motion

ThingTriggerPropertyFrom → toTimingReduced motion
Thumb presspointer downscale, box-shadow1 → 1.18, halo 0 → 9px240ms --popinstant
Thumb squashpushing past the gapscaleX1 → down to 0.76follows pointer, 240ms --pop backinstant
Thumb jumptrack press, keys, inputs, resetpositionold → new valuespring k 700, c 38instant
Dragpointer movepositionfollows pointer, snaps to $5nonesame
Partial bar fillthumb movesgradient stopper framenonesame
Bars re-growchip toggledheightold → new %260ms --easeinstant
Tooltipdrag or keyboard focusopacity, translateY0 / 4px → 1 / 0140ms, 200ms --easeinstant
Count bumpcount changestranslateY, scale0, 1 → −2px, 1.06 → back260ms --popnone
Card lifthovertranslateY0 → −2px200ms --easenone

States

  • Thumb rest: white, 2px #2e5e47 ring, small shadow. Hover: 7px halo at 14% green.
  • Thumb pressed: scale 1.18, 9px halo, cursor grabbing, raised above the other thumb, tooltip visible.
  • Thumb pushed against the other: squashed horizontally while the pointer keeps going.
  • Focus-visible on thumbs: 2.5px ember outline, offset 3px, plus the tooltip. Suppressed while the pointer is pressing.
  • Field focus: border goes #2e5e47 with a 3px 18% green ring.
  • Chip selected: ink fill, panel-coloured text, check icon.
  • Button hover: #24503b.
  • Empty: dashed --edge box with the “No cabins in this range” line; the button reads “Show 0 cabins”.
  • Max at $460: value text “460 dollars or more a night”, box shows “460+”.

Accessibility

  • Each thumb is role="slider", tabindex="0", labelled “Minimum price” / “Maximum price”, described by the note. aria-valuemin/aria-valuemax are dynamic: the minimum thumb’s max is max − 20, the maximum thumb’s min is min + 20. aria-valuetext is “120 dollars a night” or “460 dollars or more a night”.
  • Keys on a thumb: Left/Down −$5, Right/Up +$5, Shift+arrow ±$50, PageDown/PageUp ±$50, Home to its lowest allowed value, End to its highest.
  • The histogram is decorative (aria-hidden); the count and range are spoken through a polite live region, debounced 600ms: “56 cabins between $120 and $200”.
  • The boxes are real text inputs with visible labels, so a screen reader user can type a price instead of sliding.
  • touch-action: none and user-select: none on the rail so a horizontal drag never scrolls or selects. Each thumb’s hit area is 44px (an 8px ::after ring around the 28px disc).
  • Contrast: ink on panel 15:1, --ink-2 6.2:1, ember label on panel 5.9:1, button text on green 7.7:1, field and chip borders 3.8:1.

Responsive rules

  • ≥ 1280: as drawn, max width 1280 centred.
  • 1024: the panel stays 440px; the results column narrows and cards stay two up.
  • < 900: one column, 16px padding; the panel first, the results under it, one card per row; the search pill keeps only the place.
  • < 420: section padding 18px, rail padding 12px, headline 26px. At 390 the histogram is about 290px wide, bars about 5px; the chips wrap to two lines.
  • Positions are percentages of the rail, so nothing needs re-measuring on resize.

Acceptance checklist

Always

  • Histogram bars and thumb positions share the same inner width, so a bar’s edge lines up with the price it represents.
  • Bars inside the range are filled, outside faded, and the bar under a thumb is filled only up to the thumb.
  • Thumbs cannot cross; the moving one stops at the minimum gap and squashes while pushed further.
  • Count, headline, button and cards update on every pointer move, not only on release.
  • Pressing the track moves the nearer thumb there with a spring, then it follows the pointer.
  • Inputs and thumbs stay in sync both ways; out-of-range typing is clamped on Enter or blur.
  • Two role="slider" thumbs with dynamic min/max and currency aria-valuetext.
  • Arrows, Shift+arrows, PageUp/PageDown, Home and End all work on both thumbs.
  • A polite live region announces the count after the user pauses.
  • Reduced motion removes the springs, squash, bump and bar re-grow but keeps every state.

This demo

  • “Larchway”, “Kettle Valley”, default range $120 to $200, step $5, gap $20, ends “$60” and “$460+”.
  • 40 bars of $10, median label “Median $182”, headline “56 cabins”, button “Show 56 cabins”.
  • Fill #2e5e47, faded bars #d9d2c3, ember #b0491f; Bitter for the headline and names, Kumbh Sans for the rest.

Implementation notes

Partial fill of the bar under each thumb. Paint each bar with a hard-stop gradient from the continuous (spring-animated) positions, not the snapped values:

const lo = MIN + a * (MAX - MIN), hi = MIN + b * (MAX - MIN);   // a, b: displayed fractions
bars.forEach((el, i) => {
  const x0 = MIN + i * BIN;
  const p = clamp((lo - x0) / BIN, 0, 1) * 100, q = clamp((hi - x0) / BIN, 0, 1) * 100;
  el.style.background = q <= 0 || p >= 100 ? 'var(--bar)'
    : `linear-gradient(90deg,var(--bar) ${p}%,var(--fill) ${p}%,var(--fill) ${q}%,var(--bar) ${q}%)`;
});

No crossing, with a squash. Clamp against the other thumb, and turn the overshoot into a horizontal scale the CSS springs back from:

rail.addEventListener('pointermove', e => {
  if (drag < 0) return;
  const raw = toVal(e.clientX);
  const over = drag ? val[0] + GAP - raw : raw - (val[1] - GAP);   // dollars past the stop
  set(drag, raw);                                                 // snaps to $5 and clamps
  th[drag].style.setProperty('--sq', over > 0 ? 1 - Math.min(.24, over / 110) : 1);
});
.th { --s: 1; --sq: 1; transform: scale(calc(var(--s) * var(--sq)), var(--s));
      transition: transform 240ms cubic-bezier(.34,1.56,.64,1); }
.th.on { --s: 1.18; z-index: 2; }

One rail, one capture. Put pointerdown on the rail (histogram plus track), pick the thumb that was hit or the nearer one, and capture on the rail so the whole histogram is a target. Use a small spring (k 700, c 38, four substeps per frame) only for jumps; during a drag the dragged thumb sits exactly under the pointer.

Common mistakes:

  • Laying out the histogram edge to edge while the thumbs are inset by their radius; the bars then lie about the prices.
  • Updating the count only on release, which loses the whole point of seeing the distribution.
  • Rewriting the input the user is typing in on every keystroke; skip the source input until Enter or blur.
  • Giving both thumbs static aria-valuemin/aria-valuemax of the whole range, so a screen reader offers values the thumb cannot take.

Details

Palette
Type
Bitter · Kumbh Sans
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Icon Museum

A control. It studies this site for quiet, not for a show.

All sources