04二つのつまみが押し合う区間バー
開始と終了のつまみが一本の24時間軸を分け合い、最小の間隔より近づくと相手を押しのけ、間の帯をつかむと長さを保ったまま区間ごと移動します。
使いどころ会議時間やレンタル時間のように開始と終了をまとめて決めるフォーム
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 – 午後6:00</span><span class="rg__len">8時間30分</span></span>
<span class="rg__cut" data-k="2" aria-hidden="true"><span class="rg__val">午後5:30 – 午後6:30</span><span class="rg__len">1時間</span></span>
<span class="rg__cut" data-k="3" aria-hidden="true"><span class="rg__val">午後7:30 – 午後8:30</span><span class="rg__len">1時間</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="区間ごと移動"></button>
<div class="rg__h rg__h--a">
<span class="rg__knob" id="rg-a" role="slider" tabindex="0"
aria-label="開始時刻" aria-valuemin="0" aria-valuemax="1440"
aria-valuenow="570" aria-valuetext="午前9時30分"></span>
</div>
<div class="rg__h rg__h--b">
<span class="rg__knob" id="rg-b" role="slider" tabindex="0"
aria-label="終了時刻" aria-valuemin="0" aria-valuemax="1440"
aria-valuenow="1080" aria-valuetext="午後6時"></span>
</div>
</div>
<div class="rg__ticks" aria-hidden="true">
<span>0時</span><span>6時</span><span>12時</span><span>18時</span><span>24時</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]; // 15分 한 칸, 최소 간격 1時間
function fmt(t, k) {
var h = (t / 4 | 0) % 24, m = (t % 4) * 15, ap = h < 12 ? '午前' : '午後', hh = h % 12 || 12;
return k ? ap + ' ' + hh + '時' + (m ? ' ' + m + '分' : '')
: 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 + '時間' : '') + (M ? (H ? ' ' : '') + M + '分' : '');
}
// 값이 뒤집히지 않는다 — 최소 간격 안으로 들어오면 상대 손잡이를 밀어낸다
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 二つのつまみが押し合う区間バー — 計測 累積面積 4.152% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · タイムピッカー ui 9選 — 一点から区間まで
このデモが属する記事
タイムピッカー ui 9選 — 一点から区間までタイムピッカー ui とは、何時何分という一点だけでなく、いつからいつまでという長さまで画面上で決めるための部品のことです。ダイヤル・ドラム・枠の格子から、区間バー・繰り返しルール・時差表まで、実際に動く9つを置きました。 この記事のデモをすべて見る

