04Span bar whose two handles push each other
Start and end handles share one 24-hour axis, shove each other away when they come inside the minimum gap, and grabbing the band between them moves the whole span without changing its length.
Best forForms that set a start and an end together, such as meetings and rentals
aria-valuetextscaleXpointermove

<main class="stage">
<div class="rg is-demo">
<div class="rg__head">
<span class="rg__cut" data-k="1"><span class="rg__val">9:30 AM – 6:00 PM</span><span class="rg__len">8 hr 30 min</span></span>
<span class="rg__cut" data-k="2" aria-hidden="true"><span class="rg__val">5:30 PM – 6:30 PM</span><span class="rg__len">1 hr</span></span>
<span class="rg__cut" data-k="3" aria-hidden="true"><span class="rg__val">7:30 PM – 8:30 PM</span><span class="rg__len">1 hr</span></span>
</div>
<div class="rg__body">
<div class="rg__axis" id="rg-axis">
<div class="rg__rail" aria-hidden="true"><div class="rg__fill"></div></div>
<button class="rg__band" id="rg-band" type="button" aria-label="Move the whole span"></button>
<div class="rg__h rg__h--a">
<span class="rg__knob" id="rg-a" role="slider" tabindex="0"
aria-label="Start time" aria-valuemin="0" aria-valuemax="1440"
aria-valuenow="570" aria-valuetext="9:30 AM"></span>
</div>
<div class="rg__h rg__h--b">
<span class="rg__knob" id="rg-b" role="slider" tabindex="0"
aria-label="End time" aria-valuemin="0" aria-valuemax="1440"
aria-valuenow="1080" aria-valuetext="6 PM"></span>
</div>
</div>
<div class="rg__ticks" aria-hidden="true">
<span>0:00</span><span>6:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.rg {
--a: .39583; // 시작 손잡이 위치 (0~1)
--b: .75; // 끝 손잡이 위치
--len: .35417; // 두 손잡이 사이 길이
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-3;
width: min(340px, 90vw);
color: $subject-ink;
}
// ── 머리: 값 텍스트 세 장을 겹쳐 두고 컷으로 갈린다 ──
.rg__head {
position: relative;
height: 22px;
}
.rg__cut {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
opacity: 0;
}
.rg__cut[data-k="1"] { opacity: 1; }
.rg__val {
font-size: clamp(13px, 4vw, 15px);
line-height: 22px;
font-weight: 700;
white-space: nowrap;
}
.rg__len {
font-size: clamp(11px, 3.2vw, 12px);
line-height: 22px;
font-weight: 700;
color: $color;
white-space: nowrap;
}
// 손잡이 레이어는 축 폭만큼 통째로 밀리므로 여기서 잘라 낸다 (안 자르면 body.scrollWidth 가 넘친다)
.rg__body {
padding-left: $sp-3;
padding-right: $sp-3;
overflow: hidden;
}
.rg__axis {
position: relative;
height: 40px;
}
.rg__rail {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16px;
translate: 0 -50%;
border-radius: $r-pill;
overflow: hidden;
background-color: rgba($subject-ink, .1);
background-image: repeating-linear-gradient(90deg, rgba($subject-ink, .3) 0 2px, rgba($subject-ink, 0) 2px 25%);
}
// 채움은 scaleX — 부모가 flex 가 아니라 단일 트랙이라 폭이 어긋나지 않는다
.rg__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transform-origin: left center; // 규칙8: 범위 선택 scaleX 좌 기준
transform: translateX(calc(var(--a) * 100%)) scaleX(var(--len));
background-color: $color;
}
// 가운데 띠 — 잡으면 길이를 유지한 채 통째로 움직인다
.rg__band {
position: absolute;
top: 50%;
left: calc(var(--a) * 100%);
width: calc(var(--len) * 100%);
height: 26px;
translate: 0 -50%;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-pill;
background-color: transparent;
cursor: grab;
touch-action: none;
}
.rg__band:focus-visible { outline: 3px solid $subject-ink; outline-offset: 0; }
.rg__h {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.rg__h--a { transform: translateX(calc(var(--a) * 100%)); }
.rg__h--b { transform: translateX(calc(var(--b) * 100%)); }
.rg__knob {
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 22px;
translate: -50% -50%;
border-radius: 50%;
background-color: $subject-ink;
box-shadow: $shadow-press;
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.rg__knob::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
translate: -50% -50%;
border-radius: 50%;
background-color: $stage-paper;
}
.rg__knob:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.rg__ticks {
display: flex;
justify-content: space-between;
margin-top: $sp-1;
}
.rg__ticks span {
font-size: clamp(9px, 2.8vw, 11px);
line-height: 14px;
font-weight: 500;
color: rgba($subject-ink, .6);
}
// ── 자동 루프 ──
// 8~30% 시작 손잡이가 끝 손잡이를 밀어낸다 → 42~60% 구간이 통째로 오른쪽으로 → 74~88% 복귀
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__fill { animation-name: rg-fill; }
.rg.is-demo .rg__h--a { animation-name: rg-a; }
.rg.is-demo .rg__h--b { animation-name: rg-b; }
// 값은 38·72 (15분 칸) → 밀려서 70·74 → 통째로 78·82 → 다시 70·74 → 38·72
// 되돌림(68~84%)은 여러 프레임으로 쪼개 최대 변화가 되지 않게 한다 — poster 는 "밀려난 순간"(25%)이 뽑힌다
// 중간 스톱을 촘촘히 둬 끄는 구간이 등속에 가깝게 흐르게 한다 — 한 프레임에 몰리면
// 되돌림이 poster 를 가져가고, 값 텍스트가 아직 안 갈린 프레임이 크게 어긋나 보인다
@keyframes rg-fill {
0%, 14% { transform: translateX(39.583%) scaleX(.35417); }
18% { transform: translateX(53.125%) scaleX(.21875); }
22% { transform: translateX(66.667%) scaleX(.08333); }
25%, 36% { transform: translateX(72.917%) scaleX(.04167); }
48%, 56% { transform: translateX(81.25%) scaleX(.04167); }
68% { transform: translateX(72.917%) scaleX(.04167); }
73% { transform: translateX(61.458%) scaleX(.14583); }
78% { transform: translateX(50%) scaleX(.25); }
84%, 100% { transform: translateX(39.583%) scaleX(.35417); }
}
@keyframes rg-a {
0%, 14% { transform: translateX(39.583%); }
18% { transform: translateX(53.125%); }
22% { transform: translateX(66.667%); }
25%, 36% { transform: translateX(72.917%); }
48%, 56% { transform: translateX(81.25%); }
68% { transform: translateX(72.917%); }
73% { transform: translateX(61.458%); }
78% { transform: translateX(50%); }
84%, 100% { transform: translateX(39.583%); }
}
@keyframes rg-b {
0%, 22% { transform: translateX(75%) scale(1); }
25% { transform: translateX(77.083%) scale(1.25); }
32%, 36% { transform: translateX(77.083%) scale(1); }
48%, 56% { transform: translateX(85.417%) scale(1); }
68% { transform: translateX(77.083%) scale(1); }
73% { transform: translateX(76.042%) scale(1); }
78%, 100% { transform: translateX(75%) scale(1); }
}
// 값 텍스트는 한 프레임에 컷 — 크로스페이드는 poster 에 유령을 남긴다
.rg.is-demo .rg__cut {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__cut[data-k="1"] { animation-name: rg-k1; }
.rg.is-demo .rg__cut[data-k="2"] { animation-name: rg-k2; }
.rg.is-demo .rg__cut[data-k="3"] { animation-name: rg-k3; }
@keyframes rg-k1 { 0% { opacity: 1; } 25% { opacity: 0; } 84% { opacity: 1; } 100% { opacity: 1; } }
@keyframes rg-k2 { 0% { opacity: 0; } 25% { opacity: 1; } 50% { opacity: 0; } 68% { opacity: 1; } 84% { opacity: 0; } 100% { opacity: 0; } }
@keyframes rg-k3 { 0% { opacity: 0; } 50% { opacity: 1; } 68% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.rg { gap: $sp-2; }
.rg__head { height: 20px; }
.rg__val { line-height: 20px; }
.rg__len { line-height: 20px; }
.rg__axis { height: 32px; }
.rg__rail { height: 14px; }
.rg__knob { width: 20px; height: 20px; }
.rg__band { height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b,
.rg.is-demo .rg__cut { animation: none !important; }
.rg.is-demo .rg__cut[data-k="1"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rg {
--a: .39583;
--b: .75;
--len: .35417;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
width: min(340px, 90vw);
color: #17141a;
}
.rg__head {
position: relative;
height: 22px;
}
.rg__cut {
position: absolute;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
opacity: 0;
}
.rg__cut[data-k="1"] {
opacity: 1;
}
.rg__val {
font-size: clamp(13px, 4vw, 15px);
line-height: 22px;
font-weight: 700;
white-space: nowrap;
}
.rg__len {
font-size: clamp(11px, 3.2vw, 12px);
line-height: 22px;
font-weight: 700;
color: #2f6df6;
white-space: nowrap;
}
.rg__body {
padding-left: 12px;
padding-right: 12px;
overflow: hidden;
}
.rg__axis {
position: relative;
height: 40px;
}
.rg__rail {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16px;
translate: 0 -50%;
border-radius: 999px;
overflow: hidden;
background-color: rgba(23, 20, 26, 0.1);
background-image: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.3) 0 2px, rgba(23, 20, 26, 0) 2px 25%);
}
.rg__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transform-origin: left center;
transform: translateX(calc(var(--a) * 100%)) scaleX(var(--len));
background-color: #2f6df6;
}
.rg__band {
position: absolute;
top: 50%;
left: calc(var(--a) * 100%);
width: calc(var(--len) * 100%);
height: 26px;
translate: 0 -50%;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background-color: transparent;
cursor: grab;
touch-action: none;
}
.rg__band:focus-visible {
outline: 3px solid #17141a;
outline-offset: 0;
}
.rg__h {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
pointer-events: none;
}
.rg__h--a {
transform: translateX(calc(var(--a) * 100%));
}
.rg__h--b {
transform: translateX(calc(var(--b) * 100%));
}
.rg__knob {
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 22px;
translate: -50% -50%;
border-radius: 50%;
background-color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.rg__knob::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
translate: -50% -50%;
border-radius: 50%;
background-color: #ffffff;
}
.rg__knob:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.rg__ticks {
display: flex;
justify-content: space-between;
margin-top: 4px;
}
.rg__ticks span {
font-size: clamp(9px, 2.8vw, 11px);
line-height: 14px;
font-weight: 500;
color: rgba(23, 20, 26, 0.6);
}
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__fill {
animation-name: rg-fill;
}
.rg.is-demo .rg__h--a {
animation-name: rg-a;
}
.rg.is-demo .rg__h--b {
animation-name: rg-b;
}
@keyframes rg-fill {
0%, 14% {
transform: translateX(39.583%) scaleX(0.35417);
}
18% {
transform: translateX(53.125%) scaleX(0.21875);
}
22% {
transform: translateX(66.667%) scaleX(0.08333);
}
25%, 36% {
transform: translateX(72.917%) scaleX(0.04167);
}
48%, 56% {
transform: translateX(81.25%) scaleX(0.04167);
}
68% {
transform: translateX(72.917%) scaleX(0.04167);
}
73% {
transform: translateX(61.458%) scaleX(0.14583);
}
78% {
transform: translateX(50%) scaleX(0.25);
}
84%, 100% {
transform: translateX(39.583%) scaleX(0.35417);
}
}
@keyframes rg-a {
0%, 14% {
transform: translateX(39.583%);
}
18% {
transform: translateX(53.125%);
}
22% {
transform: translateX(66.667%);
}
25%, 36% {
transform: translateX(72.917%);
}
48%, 56% {
transform: translateX(81.25%);
}
68% {
transform: translateX(72.917%);
}
73% {
transform: translateX(61.458%);
}
78% {
transform: translateX(50%);
}
84%, 100% {
transform: translateX(39.583%);
}
}
@keyframes rg-b {
0%, 22% {
transform: translateX(75%) scale(1);
}
25% {
transform: translateX(77.083%) scale(1.25);
}
32%, 36% {
transform: translateX(77.083%) scale(1);
}
48%, 56% {
transform: translateX(85.417%) scale(1);
}
68% {
transform: translateX(77.083%) scale(1);
}
73% {
transform: translateX(76.042%) scale(1);
}
78%, 100% {
transform: translateX(75%) scale(1);
}
}
.rg.is-demo .rg__cut {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rg.is-demo .rg__cut[data-k="1"] {
animation-name: rg-k1;
}
.rg.is-demo .rg__cut[data-k="2"] {
animation-name: rg-k2;
}
.rg.is-demo .rg__cut[data-k="3"] {
animation-name: rg-k3;
}
@keyframes rg-k1 {
0% {
opacity: 1;
}
25% {
opacity: 0;
}
84% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rg-k2 {
0% {
opacity: 0;
}
25% {
opacity: 1;
}
50% {
opacity: 0;
}
68% {
opacity: 1;
}
84% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rg-k3 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
68% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.rg {
gap: 8px;
}
.rg__head {
height: 20px;
}
.rg__val {
line-height: 20px;
}
.rg__len {
line-height: 20px;
}
.rg__axis {
height: 32px;
}
.rg__rail {
height: 14px;
}
.rg__knob {
width: 20px;
height: 20px;
}
.rg__band {
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.rg.is-demo .rg__fill,
.rg.is-demo .rg__h--a,
.rg.is-demo .rg__h--b,
.rg.is-demo .rg__cut {
animation: none !important;
}
.rg.is-demo .rg__cut[data-k="1"] {
opacity: 1;
}
}(function () {
var root = document.querySelector('.rg'), axis = document.getElementById('rg-axis');
var K = [document.getElementById('rg-a'), document.getElementById('rg-b')];
var band = document.getElementById('rg-band');
var cut = root.querySelector('.rg__cut[data-k="1"]');
var val = cut.querySelector('.rg__val'), len = cut.querySelector('.rg__len');
var v = [38, 72], MIN = 4, drag = null, st = 0, sv = [0, 0]; // 15min 한 칸, 최소 간격 1 hr
function fmt(t, k) {
var h = (t / 4 | 0) % 24, m = (t % 4) * 15, ap = h < 12 ? 'AM' : 'PM', hh = h % 12 || 12;
return k ? ap + ' ' + hh + ' o\'clock' + (m ? ' ' + m + 'min' : '')
: ap + ' ' + hh + ':' + (m < 10 ? '0' : '') + m;
}
function draw() {
root.style.setProperty('--a', v[0] / 96);
root.style.setProperty('--b', v[1] / 96);
root.style.setProperty('--len', (v[1] - v[0]) / 96);
for (var i = 0; i < 2; i++) {
K[i].setAttribute('aria-valuenow', v[i] * 15);
K[i].setAttribute('aria-valuetext', fmt(v[i], 1));
}
val.textContent = fmt(v[0]) + ' – ' + fmt(v[1]);
var d = (v[1] - v[0]) * 15, H = d / 60 | 0, M = d % 60;
len.textContent = (H ? H + ' hr' : '') + (M ? (H ? ' ' : '') + M + 'min' : '');
}
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
function set(i, t) {
t = Math.min(Math.max(t, 0), 96);
if (i) { v[1] = Math.max(t, MIN); if (v[1] - v[0] < MIN) v[0] = v[1] - MIN; }
else { v[0] = Math.min(t, 96 - MIN); if (v[1] - v[0] < MIN) v[1] = v[0] + MIN; }
draw();
}
function shift(a) { var L = sv[1] - sv[0]; a = Math.min(Math.max(a, 0), 96 - L); v[0] = a; v[1] = a + L; draw(); }
function at(e) { var r = axis.getBoundingClientRect(); return Math.round((e.clientX - r.left) / r.width * 96); }
function key(e, i) {
var s = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 0;
if (!s && e.key !== 'Home' && e.key !== 'End') return;
root.classList.remove('is-demo'); e.preventDefault(); sv = [v[0], v[1]];
if (i === 2) shift(v[0] + (e.key === 'Home' ? -96 : e.key === 'End' ? 96 : s));
else set(i, e.key === 'Home' ? 0 : e.key === 'End' ? 96 : v[i] + s);
}
[K[0], K[1], band].forEach(function (el, i) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); drag = i; st = at(e); sv = [v[0], v[1]];
el.setPointerCapture(e.pointerId); el.focus({ preventScroll: true }); e.preventDefault();
});
el.addEventListener('keydown', function (e) { key(e, i); });
});
document.addEventListener('pointermove', function (e) {
if (drag === null) return;
var d = at(e) - st;
if (drag === 2) shift(sv[0] + d); else set(drag, sv[drag] + d);
});
document.addEventListener('pointerup', function () { drag = null; });
draw();
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TimeRangeDualHandle.module.scss";
export type TimeRangeDualHandleProps = {
labels?: { start: string; end: string; band: string; ticks: string[] };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string; "--easing"?: string; "--color"?: string;
"--a"?: string; "--b"?: string; "--len"?: string;
};
type PtrEvt = {
clientX: number; pointerId: number; preventDefault(): void;
currentTarget: { setPointerCapture(id: number): void };
};
type KeyEvt = { key: string; preventDefault(): void };
type Span = [number, number];
const MIN = 4; // 최소 간격 1시간 = 15분 네 칸
const S2: Span = [70, 74]; // 루프 2단계: 밀려난 구간
const S3: Span = [78, 82]; // 루프 3단계: 통째로 옮긴 구간
function clock(t: number): string {
const h = Math.floor(t / 4) % 24, m = (t % 4) * 15, hh = h % 12 || 12;
return hh + ":" + (m < 10 ? "0" : "") + m + " " + (h < 12 ? "AM" : "PM");
}
function spoken(t: number): string {
const h = Math.floor(t / 4) % 24, m = (t % 4) * 15, hh = h % 12 || 12;
return (m ? hh + ":" + (m < 10 ? "0" : "") + m : String(hh)) + " " + (h < 12 ? "AM" : "PM");
}
function length(v: Span): string {
const d = (v[1] - v[0]) * 15, H = Math.floor(d / 60), M = d % 60;
return (H ? H + " hr" : "") + (M ? (H ? " " : "") + M + " min" : "");
}
export default function TimeRangeDualHandle({
labels = {
start: "Start time",
end: "End time",
band: "Move the whole span",
ticks: ["0:00", "6:00", "12:00", "18:00", "24:00"],
},
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TimeRangeDualHandleProps) {
const [demo, setDemo] = useState(true);
const [v, setV] = useState<Span>([38, 72]);
const axis = useRef<HTMLDivElement>(null);
const drag = useRef<{ i: number; st: number; sv: Span } | null>(null);
const vars: Vars = {
"--duration": duration + "ms", "--easing": easing, "--color": color,
"--a": String(v[0] / 96), "--b": String(v[1] / 96), "--len": String((v[1] - v[0]) / 96),
};
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
function set(i: number, t: number) {
const n = Math.min(Math.max(t, 0), 96);
setV((p): Span => {
if (i) { const b = Math.max(n, MIN); return [Math.min(p[0], b - MIN), b]; }
const a = Math.min(n, 96 - MIN); return [a, Math.max(p[1], a + MIN)];
});
}
function shift(a0: number, L: number) {
const a = Math.min(Math.max(a0, 0), 96 - L);
setV([a, a + L]);
}
function at(e: PtrEvt): number {
const el = axis.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.round(((e.clientX - r.left) / r.width) * 96);
}
function down(i: number) {
return (e: PtrEvt) => {
setDemo(false);
drag.current = { i, st: at(e), sv: v };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
};
}
function move(e: PtrEvt) {
const d = drag.current;
if (!d) return;
const delta = at(e) - d.st;
if (d.i === 2) shift(d.sv[0] + delta, d.sv[1] - d.sv[0]);
else set(d.i, d.sv[d.i] + delta);
}
function up() { drag.current = null; }
function key(i: number) {
return (e: KeyEvt) => {
const s = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1
: e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
if (!s && e.key !== "Home" && e.key !== "End") return;
setDemo(false);
e.preventDefault();
const jump = e.key === "Home" ? -96 : e.key === "End" ? 96 : s;
if (i === 2) shift(v[0] + jump, v[1] - v[0]);
else set(i, e.key === "Home" ? 0 : e.key === "End" ? 96 : v[i] + s);
};
}
const cuts: Span[] = [v, S2, S3];
return (
<div className={styles.rg + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.head}>
{cuts.map((c, i) => (
<span key={i} className={styles.cut} data-k={i + 1} aria-hidden={i > 0}>
<span className={styles.val}>{clock(c[0]) + " – " + clock(c[1])}</span>
<span className={styles.len}>{length(c)}</span>
</span>
))}
</div>
<div className={styles.body}>
<div className={styles.axis} ref={axis}>
<div className={styles.rail} aria-hidden="true"><div className={styles.fill} /></div>
<button
className={styles.band}
type="button"
aria-label={labels.band}
onPointerDown={down(2)}
onPointerMove={move}
onPointerUp={up}
onKeyDown={key(2)}
/>
{[0, 1].map((i) => (
<div key={i} className={styles.h + " " + (i ? styles.hB : styles.hA)}>
<span
className={styles.knob}
role="slider"
tabIndex={0}
aria-label={i ? labels.end : labels.start}
aria-valuemin={0}
aria-valuemax={1440}
aria-valuenow={v[i] * 15}
aria-valuetext={spoken(v[i])}
onPointerDown={down(i)}
onPointerMove={move}
onPointerUp={up}
onKeyDown={key(i)}
/>
</div>
))}
</div>
<div className={styles.ticks} aria-hidden="true">
{labels.ticks.map((t) => <span key={t}>{t}</span>)}
</div>
</div>
</div>
);
}04 Span bar whose two handles push each other — Measured area 4.152% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · 9 time picker ui patterns, from a point to a span
The post this demo lives in
9 time picker ui patterns, from a point to a spanA time picker ui is the part of a screen where someone sets a time without typing it. These nine go past a single time o See all demos in this post
If you like this effect - similar demos
Pickers05 A Range Picker Whose Trim Handles Get Pulled to the Nearest Zero CrossingCSS Waveform: 9 Peak Columns and Trim Pickers
Pickers01 Value bubble — converting track position to a percentage the label followsCSS Range Slider: 9 Physics-Grade Custom Sliders
Pickers02 Dual-thumb price range slider9 Date Range Picker UI Patterns — Copy-Paste