GODRICH

タイムピッカー ui 9選 — 一点から区間まで

タイムピッカー ui とは、何時何分という一点だけでなく、いつからいつまでという長さまで画面上で決めるための部品のことです。ダイヤル・ドラム・枠の格子から、区間バー・繰り返しルール・時差表まで、実際に動く9つを置きました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気順ではなく、答えたい問いが「いつ」から「どれだけの長さ」へ移る順番です。最初の三つは一点の時刻を決めます(01〜03)。次の二つは始まりと終わりを同時に持つ区間です(04・05)。六番目と七番目は一度きりで終わらない時刻を扱います。つまり繰り返しと、今から何分先かという相対時間です(06・07)。最後の二つでは時刻そのものより長さと時差が主役になります(08・09)。日付そのものを月のカレンダーから選ぶ側は カレンダーの月表示グリッド に、選択部品全体の型は ピッカー UI のパターン にまとめてあります。九つに共通する原則は一つで、確定した値は必ず一か所にだけ書き、見た目も読み上げもその一か所を読みます。

01針を回して選ぶ丸いダイヤル

丸い盤の上で針をドラッグすると、指の角度が Math.atan2 で時刻に変わります。指を離した瞬間に時が決まり、同じ盤がそのまま分の目盛りへ一度だけ切り替わるので、二段階が同じ場所で終わります。見た目は時計でも中身は role="slider" なので、値は aria-valuenow に、読ませたい言い方は aria-valuetext に分けて書きます。

Math.atan2rotatearia-valuetext
function fromPointer(e) {
  var r = dial.getBoundingClientRect();
  var a = Math.atan2(e.clientX - r.left - r.width / 2, r.top + r.height / 2 - e.clientY) * 180 / Math.PI;
  a = (a + 360) % 360;
  if (s.hour) { s.h = Math.round(a / 30) || 12; } else { s.m = (Math.round(a / 30) * 5) % 60; }
  paint();
}
dial.addEventListener('pointerdown', function (e) {
  dl.classList.remove('is-demo'); dial.setPointerCapture(e.pointerId);
  dial.dataset.drag = '1'; fromPointer(e); e.preventDefault();
});
dial.addEventListener('pointermove', function (e) { if (dial.dataset.drag) fromPointer(e); });
dial.addEventListener('pointerup', function () {
  if (!dial.dataset.drag) return;
  delete dial.dataset.drag;
  if (s.hour) { s.hour = false; paint(); }
});

02時と分が別々に回るドラムローラー

時と分の列を縦に転がすと、数字が惰性で滑ってから最も近いマスへ吸い付きます。確定値はどこかの変数ではなくスクロール位置そのものから読み、一マスの高さで割って何番目かを求めます。スクロールが止まってから 90 ミリ秒後に読むので、滑っている途中の値が確定として扱われることはありません。

scroll-snap-typescroll-snap-aligntranslateY
var H = cols[0].querySelector('.dr__cell').getBoundingClientRect().height;
function at(c) { return Math.round(c.scrollTop / H); }
function read() {
  var v = [];
  for (var i = 0; i < cols.length; i++) {
    var c = cols[i], el = c.querySelector('.dr__track').children[at(c)];
    var t = el ? el.textContent.trim() : '00';
    c.setAttribute('aria-valuenow', String(Number(t)));
    c.setAttribute('aria-valuetext', c.dataset.vt.replace('{v}', String(Number(t))));
    v.push(t);
  }
  live.textContent = out.dataset.fmt.replace('{h}', String(Number(v[0]))).replace('{m}', String(Number(v[1])));
}
c.addEventListener('scroll', function () {
  if (!human) return;
  clearTimeout(tid); tid = setTimeout(read, 90);
});

03空いている枠だけ押せるスロットグリッド

30分刻みの枠が格子に並び、埋まった枠は見た目を薄くするのではなく本物の disabled なので、押しても何も起きません。選んだ枠は aria-pressed だけが覚え、マスの色はその属性が決め、確定バッジは外側の箱に付く .is-picked を読んで一コマで現れます。矢印キーの移動は満席の枠を同じ向きへ飛び越すので、押せない枠でフォーカスが止まりません。

aria-pressedgrid-template-columnssteps(1, end)
function pick(b) {
  sg.classList.remove('is-demo');
  slots.forEach(function (s) { if (!s.disabled) s.setAttribute('aria-pressed', String(s === b)); });
  sg.classList.add('is-picked');
  live.textContent = out.dataset.fmt.replace('{t}', b.dataset.t);
}
grid.addEventListener('keydown', function (e) {
  var cols = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
  var step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols }[e.key];
  var jump = e.key === 'Home' || e.key === 'End';
  if (!step && !jump) return;
  var k = slots.indexOf(document.activeElement);
  if (k < 0) return;
  e.preventDefault();
  var d = jump ? (e.key === 'Home' ? -1 : 1) : (step > 0 ? 1 : -1);
  var j = jump ? (e.key === 'Home' ? 0 : slots.length - 1) : k + step;
  while (j >= 0 && j < slots.length && slots[j].disabled) j += d;
  if (j >= 0 && j < slots.length) slots[j].focus({ preventScroll: true });
});

04二つのつまみが押し合う区間バー

一日を 96 マス、つまり 15 分刻みに割った一本の軸を、開始と終了のつまみが分け合います。最小の間隔(MIN = 4、1時間)より近づくと相手を押しのけるので、値が入れ替わることがありません。間の帯をつかむと長さ L を保ったまま区間全体が動きます。

aria-valuetextscaleXpointermove
var v = [38, 72], MIN = 4, drag = null, st = 0, sv = [0, 0];
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(); }
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);
});

05曜日ごとに一本ずつ伸ばす勤務時間バー

曜日ごとの5行にそれぞれ一本の横棒が置かれ、両端をドラッグすると 30 分刻み(一日 48 マス)で吸着します。clamp が軸の外へも相手の端の向こう側へも出させないので、終了が開始より前になる行が作れません。棒の上の読みも二つの aria-valuetext も、同じ draw の中で一緒に書き直されます。

setPointerCaptureclamptranslateX
function clamp(x, lo, hi) { return Math.min(Math.max(x, lo), hi); }
function fmt(t) { var h = t / 2 | 0; return (h < 10 ? '0' : '') + h + ':' + (t % 2 ? '30' : '00'); }
function draw(r) {
  var s = +r.dataset.s, e = +r.dataset.e, g = r.querySelectorAll('.wh__grip');
  r.style.setProperty('--s', s / 48); r.style.setProperty('--e', e / 48);
  g[0].setAttribute('aria-valuenow', s * 30); g[0].setAttribute('aria-valuetext', fmt(s));
  g[1].setAttribute('aria-valuenow', e * 30); g[1].setAttribute('aria-valuetext', fmt(e));
  r.querySelector('.wh__read [data-k="1"]').textContent = fmt(s) + '–' + fmt(e);
}
function set(r, i, t) {
  if (i) r.dataset.e = clamp(t, +r.dataset.s + 1, 48);
  else r.dataset.s = clamp(t, 0, +r.dataset.e - 1);
  draw(r);
}

06チップを押すと文になる繰り返しルール

曜日チップをオン・オフするたび、下の一文が「毎週 月・水・金」のように書き直されます。毎月へ切り替えると同じチップが第何週の曜日という意味に変わり、その週番号は基準日からその曜日が最初に来る日付を数えて求めます。チップは role="checkbox"、上のセグメントは role="radiogroup" なので、状態はクラスではなく aria-checked にだけ書かれます。

aria-checkedaria-livesteps(1, end)
function on(c) { return c.getAttribute('aria-checked') === 'true'; }
function ord(w) {
  var d = new Date(2026, 8, 12);
  d.setDate(d.getDate() + ((w - d.getDay() + 7) % 7));
  return OR[Math.ceil(d.getDate() / 7) - 1];
}
chips.forEach(function (c) {
  c.addEventListener('click', function () { wake(); c.setAttribute('aria-checked', String(!on(c))); render(); });
});
opts.forEach(function (o) {
  o.addEventListener('click', function () {
    wake();
    opts.forEach(function (x) { x.setAttribute('aria-checked', String(x === o)); });
    seg.dataset.m = o.dataset.m; render();
  });
});

07押すと結果の時刻まで見せるプリセット

チップには基準時刻に足す分(data-add)か、0 時から数えた分(data-at)のどちらかが書いてあり、結果の時刻はその場で計算されます。1440 分に達したら翌日用の文型に切り替わるので、言い回しは HTML に、計算は JS に分かれたままです。同じ値を選び直したときは転がさず、違う値のときだけ下から一度上がって入れ替わります。

aria-livetranslateYsteps(1, end)
var BASE = 14 * 60 + 40;
function clock(m) {
  var day = m >= 1440 ? 1 : 0, t = m % 1440;
  var h = Math.floor(t / 60), i = t % 60;
  var tpl = root.querySelector('.rp__tpl[data-day="' + day + '"]').textContent;
  return tpl.replace('{t}', (h < 10 ? '0' : '') + h + ':' + (i < 10 ? '0' : '') + i);
}
function pick(btn) {
  root.classList.remove('is-demo');
  for (var k = 0; k < chips.length; k++) chips[k].setAttribute('aria-pressed', String(chips[k] === btn));
  var m = btn.hasAttribute('data-add') ? BASE + Number(btn.getAttribute('data-add')) : Number(btn.getAttribute('data-at'));
  var t = clock(m);
  live.textContent = t;
  if (t === cur.textContent) return;
  nxt.textContent = t;
  slot.classList.remove('is-roll');
  void slot.offsetWidth;
  slot.classList.add('is-roll');
}

08分と秒を上げると輪が回るカウントダウン

分と秒の欄は本物の role="spinbutton" で、上下キーが分を1ずつ、秒を5ずつ動かします。開始を押すと残り時間の比率が --ring-angle に出て、周りの conic-gradient の輪がその分だけ減ります。角度が途中の値を通れるのは、そのカスタムプロパティが @property で登録されているからです(MDN の @property)。

@propertyconic-gradientaria-valuenow
@property --ring-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 360deg;
}
.cd__ring {
  position: relative;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 124px;
  height: 124px;
  border-radius: 50%;
  background: conic-gradient($color 0deg var(--ring-angle), rgba($subject-ink, .14) var(--ring-angle) 360deg);
}
@keyframes cd-drain {
  0%   { --ring-angle: 360deg; }
  62%  { --ring-angle: 72deg; }
  86%  { --ring-angle: 360deg; }
  100% { --ring-angle: 360deg; }
}

09一つの時刻を三つの都市が同時に読む表

時差は実行時に問い合わせるのではなく、基準都市から何分離れているかを書いた配列(OFF = [0, 300, 780])で持ちます。基準の分が変わると三行が一度のループで書き直されるので、一つの値から三つの表示が出て、ずれようがありません。1440 分に達した行にだけ翌日の印が付き、その文言も読み上げ領域の一文に混ざります。

steps(1, end)aria-livetranslateY
var OFF = [0, 300, 780];
var MAX = 1410, STEP = 30;
var base = 7 * 60;
function apply() {
  root.classList.remove('is-demo');
  var say = [];
  for (var i = 0; i < rows.length; i++) {
    var m = base + OFF[i], t = m % 1440;
    var next = m >= 1440 ? '1' : '0';
    rows[i].querySelector('.tz__t').textContent = pad(Math.floor(t / 60)) + ':' + pad(t % 60);
    rows[i].setAttribute('data-next', next);
    var b = rows[i].querySelector('.tz__badge');
    say.push(rows[i].querySelector('.tz__name').textContent + ' ' + rows[i].querySelector('.tz__t').textContent
      + (next === '1' && b ? ' ' + b.textContent : ''));
  }
  rail.style.setProperty('--p', (base / MAX).toFixed(4));
  rail.setAttribute('aria-valuenow', String(base));
  rail.setAttribute('aria-valuetext', say[0]);
  live.textContent = say.join(' · ');
}

どこで壊れるか — 落とし穴

今回、実際に手が止まったのは三か所でした。

一つ目は 02 のドラムです。.dr__colscroll-snap-type: y mandatory が効いた本物のスクロールコンテナで、自動再生は中の .dr__tracktranslateY で動かします。ところが mandatory のスナップは、トラックが transform で動いた分だけスクロール位置を合わせ直そうとするため、動かした見た目がその場で打ち消されました。答えは動きを弱めることではなく、プレビューの間だけスナップを外すことです。.dr.is-demo .dr__col { scroll-snap-type: none; } の一行を足し、人が触った瞬間に .is-demo が外れて mandatory が戻るようにしました。同じ列でもう一つ効いているのが .dr__trackpadding-block: var(--h) で、上下に一マス分の余白がないと最初の 01 と最後の 12 が中央の窓まで届かず、両端の値が選べない列になります。窓の外は mask-image のグラデーションで薄くしてあるので、見えている数字と確定した数字を取り違えにくくなっています。

二つ目は 08 の輪です。--ring-angle を keyframes で 360deg から 72deg へ動かしても、登録していないカスタムプロパティは中身がただの文字列なので補間されず、角度が途中の値を取らずに飛びます。@propertysyntax: "<angle>" を宣言して初めて滑らかにつながります。そして inherits: false なので値は親から降りてきません。だから JS 側も ringEl.style.setProperty('--ring-angle', (360 * f).toFixed(1) + 'deg') と、輪の要素そのものに角度を書いています。親に書いても輪には届きません。減り方にも一手あって、輪は 0deg まで落とさず 62% の地点の 72deg で止め、そこから 86% まで使ってゆっくり満たし直します。一度でも空の輪のコマができると、その一枚が poster に選ばれて「何も設定されていないタイマー」の絵になってしまうからです。

三つ目は切り替えの作り方です。値の差し替えを steps(1, end) の一コマのカットだけで済ませると、隣り合うコマの差がほとんど出ず、動いたコマ数が足りません。24 コマの実測で動いたコマは 07 が 11、09 が 12 で、九つの中でこの二つだけが半分前後にとどまりました。累積変化面積でも 09 は 1.799% と最も小さく、最大の 03(17.651%)の十分の一ほどです。そこで 09 は、カットと同じ瞬間に基準行の棒を scaleX で 0.2979 から 0.383 へ伸ばし、翌日バッジを translateY で 7px 押し上げています。07 も同じ理由で、押されたチップが scale(.93) から scale(1.05) へ跳ね、値は見えていない間に 5px 下がってカットと一緒に戻ります。カットの隣に連続した動きを一つ置くだけで、静止画に見える瞬間が消えました。01 は逆に動いたコマが 23 と九つで最も多く、針が毎コマ回るので補助はいりませんでした。同じ理屈は 03 にもあり、確定バッジと下の文はどちらも 40% の一点でカットします。片方だけ先に出ると、バッジは付いているのに文は「時間を選んでください」のままというコマが一枚残るからです。九つのフォルダをこの並びのまま開くパスワードは 6meczddf で、ここで挙げた keyframes はどれもフォルダの scss にそのまま入っています。

アクセシビリティ

prefers-reduced-motion: reduce では、九つとも .is-demo の自動再生を animation: none !important で止め、そのうえで静止した一枚だけを残します。02 と 03 は [data-live] の行を opacity: 1 に戻し、07・08・09 は差し替え用の複製を display: none で消して先頭の値だけを見せます。05 は水曜の二枚目の読みを opacity: 0 にし、01 は時の目盛りの輪を出したままにします。止めても情報が減らないのは、動いていたのが値そのものではなく値の切り替え方だからです。操作は JS 側にあるので、reduce でもドラッグもキーも同じように効きます。役割の使い分けは MDN の slider ロール が基準です。予約や通知の入り口としての置き場所は スケジュールで動く UI にまとめています。

マウス キーボード 状態が書かれる場所
01 盤の針をドラッグ ←→↑↓ で1段、Home・End、Enter・Space で時と分を切り替え role="slider"aria-valuenowaria-valuetext
02 列を縦にスクロール ↑↓ で1マス、Home・End で端へ 列ごとの role="slider"aria-valuetext
03 空いている枠をクリック ←→↑↓ で移動(満席は飛ばす)、Home・End 選んだ <button>aria-pressed、文は aria-live="polite"
04 つまみと帯をドラッグ ←→↑↓ で15分、Home・End 二つの role="slider"aria-valuenow(分)と aria-valuetext
05 行の端をドラッグ ←→ で30分、Home・End 行ごとの role="slider" 二つ、行は role="group"aria-labelledby
06 チップとセグメントをクリック ←→ でチップ間・モード間を移動 チップの aria-checked、文は aria-live="polite"
07 チップをクリック ←→ で隣のチップへ移り、その場で適用 チップの aria-pressed と読み上げ領域の結果時刻
08 ▲▼ ボタンをクリック ↑↓ で分1・秒5、Home・End role="spinbutton"aria-valuenowaria-valuetext
09 ◀▶ とレールをクリック ←→↑↓ で30分、Home・End 基準行の role="slider"aria-valuetext、三行分は aria-live="polite"

FAQ

<input type="time"> で足りないのはどこですか

足りないのは一点より広い形です。標準の入力は「何時何分」を一つ返すだけなので、04 のような始まりと終わりの組も、06 のような繰り返しルールも、09 のような三都市同時の読みも表現できません。03 のように満席の枠を押せなくして残りだけを一目で見せるといった空き状況の扱いも、入力欄一つには持たせる場所がありません。

サーバーとの時差はどう合わせますか

持ち回るのは表示用の文字列ではなく、基準からの分だけにします。09 は基準都市からの差を OFF = [0, 300, 780] という分の配列で持ち、基準の分が変わったら三行をまとめて書き直します。値が一つで、書き出しが一度にまとまっていれば、行同士がずれることはありません。実務でも同じ形で、UTC の分を唯一の値として保存し、都市名や翌日の印は描く直前に組み立てます。

スナップ幅はどう決めますか

受け取る側が実際に扱える最小単位に合わせます。この9つでも 04 は 15 分(一日 96 マス)、05 は 30 分(一日 48 マス)、03 は 30 分の枠を十二個とそれぞれ別で、02 は一マスの高さ --hscrollTop を割って何番目かを求めます。予約枠が 30 分単位なら 5 分刻みで選ばせても指が疲れるだけですし、逆に細かい設定を 30 分刻みにすると入れたい値が入りません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。