9 Date Range Picker UI Patterns — Copy-Paste
A date range picker UI is a control that lets someone choose a span, not just one value.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Calendar drag range select
- 02 Dual-thumb price range slider
- 03 Card-style plan radio select
- 04 Avatar icon grid picker
- 05 Size chip + quantity stepper combo
- 06 Search combobox listbox
- 07 Scroll-snap number wheel picker
- 08 Aspect-ratio crop preset picker
- 09 Dual-list transfer picker
The nine are ordered by how many values you're choosing at once, not by popularity. 01 and 02 pick one range (start–end, min–max); 03, 04, and 05 pick one option out of several as a card, an avatar, or a chip; 06 and 07 find one item by searching or scrolling; 08 picks a ratio; 09 moves several items at once between two lists.
01Calendar drag range select
Tap a start day, then an end day, on the month grid — the days between fill with a soft tint while the two edges get solid dots. It fits a stay-length picker on a booking page or a vacation date range field.
.cal__day.is-range { background: rgba($color, .18); }
.cal__day.is-edge {
background: $color;
color: #fff;
font-weight: 700;
transform: scale(1.06);
}
02Dual-thumb price range slider
Drag either handle — or use arrow keys — and the min/max labels update live while only the span between them fills with color. It suits a price-range filter on a shop page or a size-range filter for listings.
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function setLo(n) { v.lo = Math.max(0, Math.min(n, v.hi - 5)); paint(); }
function setHi(n) { v.hi = Math.min(100, Math.max(n, v.lo + 5)); paint(); }
03Card-style plan radio select
With :has(), only the picked card lifts with a bright border while a check badge scales into view in its corner. It works for pricing-plan cards or shipping-method cards.
.plan:has(.plan__radio:checked) {
transform: translateY(-3px);
border-color: $color;
box-shadow: $shadow-raised;
}
.plan:has(.plan__radio:checked) .plan__badge { transform: scale(1); opacity: 1; }
04Avatar icon grid picker
Picking an avatar in the grid grows a ring in that avatar's own accent color and swaps the name in the preview above. It fits profile avatar selection or a character-select screen.
function select(idx) {
cur = idx;
items.forEach(function (b, i) {
b.setAttribute('aria-checked', i === idx ? 'true' : 'false');
b.tabIndex = i === idx ? 0 : -1;
b.classList.toggle('is-on', i === idx);
});
pvEmoji.textContent = items[idx].dataset.emoji;
pvName.textContent = items[idx].dataset.name;
}
05Size chip + quantity stepper combo
Picking a size chip changes the unit price, and the +/- stepper recalculates the subtotal on the spot. It suits a product page where size and quantity are chosen together before adding an item to the cart.
function price() {
return +(radios.filter(function (r) { return r.checked; })[0] || radios[0]).dataset.price;
}
function paint() {
num.textContent = qty;
dec.disabled = qty <= 1; inc.disabled = qty >= 9;
total.textContent = (price() * qty).toLocaleString('en-US') + ' KRW';
}
06Search combobox listbox
Typing filters the list to options starting with those letters, and arrow-key movement updates aria-selected on the active option while focus stays on the input. It fits a country or city search-select field, or a recipient picker after a search.
function render(q) {
shown = all.filter(function (c) { return c.indexOf(q) === 0 || !q; }).slice(0, 3);
list.innerHTML = '';
shown.forEach(function (c, i) {
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
li.textContent = c;
list.appendChild(li);
});
}
07Scroll-snap number wheel picker
Scrolling vertically enlarges and darkens whichever number lands in the center slot, and scroll-snap locks it exactly in place on release. It's built for an alarm-hour picker or a party-size wheel on a booking form.
function mark() {
var idx = Math.round(track.scrollTop / H);
idx = Math.max(0, Math.min(N - 1, idx));
if (idx === centered) return;
centered = idx;
items.forEach(function (el, i) { el.classList.toggle('is-center', i === idx); });
track.setAttribute('aria-activedescendant', items[idx].id);
}
08Aspect-ratio crop preset picker
Picking a 1:1, 4:5, 16:9, or 9:16 chip smoothly morphs the preview box's aspect-ratio to match. It fits cropping a profile photo or setting an upload ratio for Reels or Shorts.
function apply(v) { box.style.aspectRatio = v; }
radios.forEach(function (r) {
r.addEventListener('change', function () { apply(r.value); });
});
.ar__box {
aspect-ratio: 1 / 1;
height: 100%;
border: 2px solid $color;
transition: aspect-ratio $duration $easing;
}
.ar__radio:checked ~ span { background: $color; color: #fff; }
09Dual-list transfer picker
Select items in the left list and press the arrow button to move them to the right list; arrow keys roam within each list. It's built for assigning permissions or roles, or building an email recipient group.
function move(from, to, sel) {
var keep = [], moved = [];
from.forEach(function (n) { (sel[n] ? moved : keep).push(n); });
from.length = 0; Array.prototype.push.apply(from, keep);
Array.prototype.push.apply(to, moved);
Object.keys(sel).forEach(function (k) { delete sel[k]; });
render();
}
Where it breaks — a hidden decorative layer still counts toward scroll size
Building 01, I first added a purely decorative sweep bar behind the calendar — a ::before that only used translateX and was meant to loop on its own, separate from the real click logic. Once it moved translateX(366%), the bar sat visually off the stage, but document.documentElement.scrollWidth still grew by that distance, so both the 480×300 render and the 320px phone check reported overflow. opacity: 0 and pointer-events: none hide a layer from view, but its box and travel distance are still measured. The fix was to drop the decorative layer and drive the loop with the real .is-range/.is-edge state changes instead. 06's search list hit a related trap: a closed list panel that's only hidden with opacity keeps its full height in the box tree, so the initial render still overflows — only max-height: 0; overflow: hidden; actually collapses a closed panel. 03's :has() only runs on Chrome/Edge 105+, Safari 15.4+, Firefox 121+; below that, the card just doesn't lift, but the underlying radio selection still works. The zip password for all nine files is uv8qjazc.
Accessibility
03, 05, and 08 hide a real <input type="radio"> visually only, so a screen reader still reads it as a radio and arrow keys still move between options, while 01 uses role="gridcell" day buttons with aria-selected and its own arrow-key grid navigation instead of a native input. 02, 04, 07, and 09 are custom controls, so each carries role="slider", role="radiogroup", or role="listbox" along with aria-valuenow, aria-checked, or aria-selected, updated on every state change. 06 announces the highlighted option through aria-selected on the active role="option" item without moving focus from the input, and 05's quantity is aria-live="polite" so a new count is read quietly after every tap. Under prefers-reduced-motion: reduce, all nine drop their transitions, and the selected state — a check, a ring, a badge — stays in its final shape instead of disappearing.
@media (prefers-reduced-motion: reduce) {
.cal__day, .plan, .ava__item, .ar__box { transition: none; }
}
For more single components in the picker family, see 9 Segmented Control UI Patterns; the select-and-dropdown half of the same family is in 9 Select Dropdown Design Patterns.
FAQ
How is a calendar range select different from two separate date fields?
Two date fields take a start and an end value independently, but 01 lets you tap both on one grid so the days between fill in immediately — you see the length of the stay, not just two dates. That matters most on booking pages where the span itself is the thing being decided.
What happens if the two thumbs on the price slider get pushed together?
02's setLo and setHi always keep the minimum at least 5 units below the maximum. Push one handle toward the other and the other handle gets nudged along by that minimum gap, so the two thumbs never overlap or swap order.
Does the scroll-snap wheel picker work without a mouse wheel?
Yes — 07's track has scroll-snap-type: y mandatory, so a touch swipe settles naturally, and focusing the track and pressing an arrow key calls scrollBy({ top, behavior: 'smooth' }) to move one row at a time. The mouse wheel is just one more way to trigger the same scroll.