UI Components Medium
Time Range Picker
A time range picker with draggable start/end handles on a 24-hour timeline. Ideal for scheduling availability, meeting room booking, or shift planning.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
/* โโ Card โโ */
.picker-wrapper {
width: min(620px, 100%);
}
.picker-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.25rem;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.picker-header {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.picker-title {
font-size: 1.375rem;
font-weight: 700;
color: #f1f5f9;
}
.picker-subtitle {
font-size: 0.875rem;
color: #64748b;
}
/* โโ Timeline โโ */
.timeline-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 0.5rem 0;
}
.timeline-track {
position: relative;
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
margin: 1.5rem 0;
cursor: pointer;
user-select: none;
}
.timeline-fill {
position: absolute;
top: 0;
height: 100%;
background: linear-gradient(90deg, #38bdf8, #818cf8);
border-radius: 999px;
pointer-events: none;
}
/* โโ Handles โโ */
.timeline-handle {
position: absolute;
top: 50%;
width: 22px;
height: 22px;
background: #f1f5f9;
border: 3px solid #38bdf8;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: grab;
transition: transform 0.1s ease, box-shadow 0.15s ease, border-color 0.15s ease;
z-index: 2;
}
.timeline-handle:hover,
.timeline-handle:focus {
box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.2);
outline: none;
}
.timeline-handle:active,
.timeline-handle.dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.15);
box-shadow: 0 0 0 8px rgba(56, 189, 248, 0.25);
border-color: #7dd3fc;
}
.handle-end {
border-color: #818cf8;
}
.handle-end:hover,
.handle-end:focus {
box-shadow: 0 0 0 6px rgba(129, 140, 248, 0.2);
}
.handle-end.dragging {
box-shadow: 0 0 0 8px rgba(129, 140, 248, 0.25);
border-color: #a5b4fc;
}
/* โโ Tooltips โโ */
.handle-tooltip {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.12);
color: #f1f5f9;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
padding: 0.3rem 0.6rem;
border-radius: 0.4rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.handle-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(255, 255, 255, 0.12);
}
.timeline-handle:hover .handle-tooltip,
.timeline-handle.dragging .handle-tooltip {
opacity: 1;
}
/* โโ Hour Labels โโ */
.timeline-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #475569;
padding: 0 2px;
}
/* โโ Range Display โโ */
.range-display {
background: rgba(56, 189, 248, 0.06);
border: 1px solid rgba(56, 189, 248, 0.15);
border-radius: 0.875rem;
padding: 1rem 1.25rem;
}
.range-display-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.range-time {
display: flex;
align-items: center;
gap: 0.5rem;
color: #38bdf8;
font-size: 1rem;
font-weight: 600;
}
.range-duration {
background: rgba(56, 189, 248, 0.15);
color: #7dd3fc;
font-size: 0.8125rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 999px;
}
/* โโ Presets โโ */
.presets-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.presets-label {
font-size: 0.8125rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.625rem;
}
.preset-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.875rem 0.5rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
color: #94a3b8;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
text-align: center;
}
.preset-btn:hover {
background: rgba(56, 189, 248, 0.08);
border-color: rgba(56, 189, 248, 0.25);
color: #e2e8f0;
}
.preset-btn.active {
background: rgba(56, 189, 248, 0.12);
border-color: rgba(56, 189, 248, 0.35);
color: #f1f5f9;
}
.preset-icon {
font-size: 1.25rem;
line-height: 1;
}
.preset-name {
font-size: 0.8125rem;
font-weight: 600;
color: inherit;
}
.preset-sub {
font-size: 0.6875rem;
color: #475569;
}
@media (max-width: 480px) {
.presets-grid {
grid-template-columns: repeat(2, 1fr);
}
.picker-card {
padding: 1.25rem;
}
}
@media (prefers-reduced-motion: reduce) {
.timeline-handle,
.preset-btn {
transition: none;
}
}(function () {
"use strict";
// โโ State โโ
const MIN_HOURS = 0;
const MAX_HOURS = 24;
const MIN_GAP = 0.5; // 30 minutes minimum range
let startHour = 9;
let endHour = 17;
// โโ DOM refs โโ
const track = document.getElementById("timelineTrack");
const fill = document.getElementById("timelineFill");
const handleStart = document.getElementById("handleStart");
const handleEnd = document.getElementById("handleEnd");
const tooltipStart = document.getElementById("tooltipStart");
const tooltipEnd = document.getElementById("tooltipEnd");
const rangeText = document.getElementById("rangeText");
const rangeDuration = document.getElementById("rangeDuration");
const presetBtns = document.querySelectorAll(".preset-btn");
if (!track) return;
// โโ Utilities โโ
function hoursToPercent(h) {
return ((h - MIN_HOURS) / (MAX_HOURS - MIN_HOURS)) * 100;
}
function percentToHours(pct) {
return (pct / 100) * (MAX_HOURS - MIN_HOURS) + MIN_HOURS;
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}
function snapToQuarter(h) {
return Math.round(h * 4) / 4; // snap to 15-minute increments
}
function formatHour(h) {
const totalMinutes = Math.round(h * 60);
const hours = Math.floor(totalMinutes / 60) % 24;
const minutes = totalMinutes % 60;
const period = hours < 12 ? "AM" : "PM";
const display = hours % 12 === 0 ? 12 : hours % 12;
return `${display}:${String(minutes).padStart(2, "0")} ${period}`;
}
function formatDuration(start, end) {
const totalMinutes = Math.round((end - start) * 60);
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
if (m === 0) return `${h} hour${h !== 1 ? "s" : ""}`;
if (h === 0) return `${m} min`;
return `${h}h ${m}m`;
}
// โโ Render โโ
function render() {
const startPct = hoursToPercent(startHour);
const endPct = hoursToPercent(endHour);
handleStart.style.left = `${startPct}%`;
handleEnd.style.left = `${endPct}%`;
fill.style.left = `${startPct}%`;
fill.style.width = `${endPct - startPct}%`;
tooltipStart.textContent = formatHour(startHour);
tooltipEnd.textContent = formatHour(endHour);
rangeText.textContent = `${formatHour(startHour)} โ ${formatHour(endHour)}`;
rangeDuration.textContent = formatDuration(startHour, endHour);
// Highlight active preset
presetBtns.forEach((btn) => {
const s = parseFloat(btn.dataset.start);
const e = parseFloat(btn.dataset.end);
const isActive = Math.abs(startHour - s) < 0.05 && Math.abs(endHour - e) < 0.05;
btn.classList.toggle("active", isActive);
});
}
// โโ Drag logic โโ
function getTrackPercent(clientX) {
const rect = track.getBoundingClientRect();
return clamp(((clientX - rect.left) / rect.width) * 100, 0, 100);
}
function makeDraggable(handle, isStart) {
let dragging = false;
function onMove(e) {
if (!dragging) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const pct = getTrackPercent(clientX);
let hours = snapToQuarter(percentToHours(pct));
if (isStart) {
hours = clamp(hours, MIN_HOURS, endHour - MIN_GAP);
startHour = hours;
} else {
hours = clamp(hours, startHour + MIN_GAP, MAX_HOURS);
endHour = hours;
}
render();
}
function onUp() {
if (!dragging) return;
dragging = false;
handle.classList.remove("dragging");
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.removeEventListener("touchmove", onMove);
document.removeEventListener("touchend", onUp);
}
handle.addEventListener("mousedown", (e) => {
e.preventDefault();
dragging = true;
handle.classList.add("dragging");
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
handle.addEventListener(
"touchstart",
(e) => {
e.preventDefault();
dragging = true;
handle.classList.add("dragging");
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onUp);
},
{ passive: false }
);
// Keyboard navigation
handle.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 1 : 0.25; // shift = 1hr, normal = 15min
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
if (isStart) startHour = clamp(startHour - step, MIN_HOURS, endHour - MIN_GAP);
else endHour = clamp(endHour - step, startHour + MIN_GAP, MAX_HOURS);
render();
}
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
if (isStart) startHour = clamp(startHour + step, MIN_HOURS, endHour - MIN_GAP);
else endHour = clamp(endHour + step, startHour + MIN_GAP, MAX_HOURS);
render();
}
});
}
makeDraggable(handleStart, true);
makeDraggable(handleEnd, false);
// Click on track to move nearest handle
track.addEventListener("click", (e) => {
if (e.target === handleStart || e.target === handleEnd) return;
const pct = getTrackPercent(e.clientX);
const hours = snapToQuarter(percentToHours(pct));
const distStart = Math.abs(hours - startHour);
const distEnd = Math.abs(hours - endHour);
if (distStart < distEnd) {
startHour = clamp(hours, MIN_HOURS, endHour - MIN_GAP);
} else {
endHour = clamp(hours, startHour + MIN_GAP, MAX_HOURS);
}
render();
});
// โโ Preset buttons โโ
presetBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const s = parseFloat(btn.dataset.start);
const e = parseFloat(btn.dataset.end);
// "Night" preset remapped to 10โ22 for sensible single-day display
if (btn.dataset.overnight) {
startHour = 10;
endHour = 22;
} else {
startHour = s;
endHour = e;
}
render();
});
});
// โโ Initial render โโ
render();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Time Range Picker</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="picker-wrapper">
<div class="picker-card">
<div class="picker-header">
<h2 class="picker-title">Select Time Range</h2>
<p class="picker-subtitle">Drag the handles to define your availability window</p>
</div>
<!-- Timeline -->
<div class="timeline-container">
<div class="timeline-track" id="timelineTrack">
<div class="timeline-fill" id="timelineFill"></div>
<div class="timeline-handle" id="handleStart" role="slider" aria-label="Start time" tabindex="0">
<div class="handle-tooltip" id="tooltipStart">9:00 AM</div>
</div>
<div class="timeline-handle handle-end" id="handleEnd" role="slider" aria-label="End time" tabindex="0">
<div class="handle-tooltip" id="tooltipEnd">5:00 PM</div>
</div>
</div>
<!-- Hour labels -->
<div class="timeline-labels">
<span>0</span>
<span>6</span>
<span>12</span>
<span>18</span>
<span>24</span>
</div>
</div>
<!-- Range display -->
<div class="range-display" id="rangeDisplay">
<div class="range-display-inner">
<div class="range-time">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span id="rangeText">9:00 AM โ 5:00 PM</span>
</div>
<div class="range-duration" id="rangeDuration">8 hours</div>
</div>
</div>
<!-- Presets -->
<div class="presets-section">
<span class="presets-label">Presets</span>
<div class="presets-grid">
<button class="preset-btn" data-start="9" data-end="12">
<span class="preset-icon">๐
</span>
<span class="preset-name">Morning</span>
<span class="preset-sub">9:00 โ 12:00</span>
</button>
<button class="preset-btn" data-start="12" data-end="17">
<span class="preset-icon">โ๏ธ</span>
<span class="preset-name">Afternoon</span>
<span class="preset-sub">12:00 โ 17:00</span>
</button>
<button class="preset-btn" data-start="9" data-end="18">
<span class="preset-icon">๐๏ธ</span>
<span class="preset-name">Full Day</span>
<span class="preset-sub">9:00 โ 18:00</span>
</button>
<button class="preset-btn" data-start="22" data-end="6.5" data-overnight="true">
<span class="preset-icon">๐</span>
<span class="preset-name">Night</span>
<span class="preset-sub">10:00 โ 22:00</span>
</button>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Time Range Picker
A fully interactive time range picker built with vanilla JS and CSS. Drag the start and end handles along a 24-hour timeline to define a time window, with real-time display updates and preset shortcuts for common scheduling scenarios.
Features
- Draggable start and end handles constrained to a 24-hour timeline
- Live display showing selected range in 12-hour AM/PM format with duration
- Preset buttons for common shifts: Morning, Afternoon, Full Day
- Minimum 30-minute range enforcement to prevent zero-width selections
- Touch-friendly via pointer events