Created
September 1, 2025 21:15
-
-
Save Michaelgathara/111a668cfa854f41e12c2b80368ada1a to your computer and use it in GitHub Desktop.
Circular IOS-Like Time Picker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<title>iOS Time Picker</title> | |
<style> | |
html, | |
body { | |
height: 100%; | |
margin: 0; | |
background: #000; | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
} | |
body { | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.timepicker { | |
position: relative; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
gap: 16px; | |
height: 220px; | |
padding: 0 8px; | |
} | |
.col { | |
position: relative; | |
height: 220px; | |
overflow-y: auto; | |
scrollbar-width: none; | |
-ms-overflow-style: none; | |
text-align: center; | |
font-size: 28px; | |
line-height: 44px; | |
scroll-snap-type: y mandatory; | |
-webkit-overflow-scrolling: touch; | |
min-width: 72px; | |
perspective: 600px; | |
transform-style: preserve-3d; | |
} | |
.col::-webkit-scrollbar { | |
display: none; | |
} | |
.col::after { | |
content: ""; | |
position: absolute; | |
inset: 0; | |
pointer-events: none; | |
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.0) 28%, rgba(0, 0, 0, 0.0) 72%, rgba(0, 0, 0, 0.85) 100%); | |
} | |
#hour { | |
width: 72px; | |
} | |
#minute { | |
width: 88px; | |
} | |
.item { | |
height: 44px; | |
scroll-snap-align: center; | |
scroll-snap-stop: always; | |
color: rgba(235, 235, 245, 0.55); | |
transition: color 0.12s ease, transform 0.12s ease; | |
transform-origin: center center; | |
will-change: transform, color; | |
} | |
.highlight { | |
pointer-events: none; | |
position: absolute; | |
top: 50%; | |
left: 0; | |
right: 0; | |
height: 44px; | |
margin-top: -22px; | |
border-top: 1px solid rgba(255, 255, 255, 0.18); | |
border-bottom: 1px solid rgba(255, 255, 255, 0.18); | |
background: rgba(255, 255, 255, 0.06); | |
border-radius: 22px; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="timepicker" id="timepicker"> | |
<div class="col" id="hour"></div> | |
<div class="col" id="minute"></div> | |
<div class="highlight"></div> | |
</div> | |
<script> | |
(function () { | |
const ITEM_HEIGHT = 44; | |
const REPEATS = 9; | |
const columns = [ | |
{ id: 'hour', values: Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')), loop: true }, | |
{ id: 'minute', values: Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')), loop: true } | |
]; | |
function buildColumn(el, values, loop) { | |
const frag = document.createDocumentFragment(); | |
if (loop) { | |
for (let r = 0; r < REPEATS; r++) { | |
for (let i = 0; i < values.length; i++) { | |
const div = document.createElement('div'); | |
div.className = 'item'; | |
div.textContent = values[i]; | |
frag.appendChild(div); | |
} | |
} | |
} else { | |
for (let i = 0; i < values.length; i++) { | |
const div = document.createElement('div'); | |
div.className = 'item'; | |
div.textContent = values[i]; | |
frag.appendChild(div); | |
} | |
} | |
el.appendChild(frag); | |
const oneSet = values.length * ITEM_HEIGHT; | |
const lowerBound = oneSet * 1; | |
const upperBound = oneSet * (REPEATS - 2); | |
el.scrollTop = oneSet * Math.floor(REPEATS / 2); | |
let isSnapping = false; | |
let snapTimer = null; | |
const finalizeSnap = () => { | |
isSnapping = true; | |
const targetIndex = Math.round((el.scrollTop + el.clientHeight / 2 - ITEM_HEIGHT / 2) / ITEM_HEIGHT); | |
const targetTop = targetIndex * ITEM_HEIGHT - (el.clientHeight / 2 - ITEM_HEIGHT / 2); | |
el.scrollTo({ top: targetTop, behavior: 'smooth' }); | |
setTimeout(() => { isSnapping = false; }, 240); | |
}; | |
const loopAndHighlight = () => { | |
if (loop) { | |
if (el.scrollTop < lowerBound) { | |
el.scrollTop += oneSet * (REPEATS - 2); | |
} else if (el.scrollTop > upperBound) { | |
el.scrollTop -= oneSet * (REPEATS - 2); | |
} | |
} | |
const center = el.scrollTop + el.clientHeight / 2; | |
const items = el.querySelectorAll('.item'); | |
for (let i = 0; i < items.length; i++) { | |
const item = items[i]; | |
const box = item.offsetTop + ITEM_HEIGHT / 2; | |
const dist = Math.abs(center - box); | |
if (dist < ITEM_HEIGHT / 2) { | |
item.style.color = '#ffffff'; | |
item.style.transform = 'scale(1.12) rotateX(0deg) translateZ(0)'; | |
item.style.fontWeight = '600'; | |
} else { | |
const offset = (box - center) / ITEM_HEIGHT; | |
const clamped = Math.max(-2, Math.min(2, offset)); | |
const abs = Math.abs(clamped); | |
const rotate = clamped * 12; | |
const translateZ = -10 * abs; | |
const alpha = 0.55 - Math.min(0.35, abs * 0.18); | |
item.style.color = `rgba(235,235,245,${alpha.toFixed(3)})`; | |
item.style.transform = `scale(${1 - Math.min(0.06, abs * 0.03)}) rotateX(${rotate}deg) translateZ(${translateZ}px)`; | |
item.style.fontWeight = '400'; | |
} | |
} | |
}; | |
const onScroll = () => { | |
loopAndHighlight(); | |
if (isSnapping) return; | |
clearTimeout(snapTimer); | |
snapTimer = setTimeout(finalizeSnap, 120); | |
}; | |
el.addEventListener('scroll', onScroll, { passive: true }); | |
loopAndHighlight(); | |
} | |
columns.forEach(cfg => buildColumn(document.getElementById(cfg.id), cfg.values, cfg.loop)); | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment