Reactで、時刻を選ぶ入力欄を作る方法
時刻を選ぶ入力欄は、<input type="time"> に value と onChange を渡し、"10:30" のような文字列を state に持てば作れます。何分単位で選ばせるかは step、選べる時間帯は min と max で決まり、MDN の「時刻を必須にする」の節にあるとおり、範囲の外の時刻はブラウザが送信を止めます。
ただし、検証環境の Chromium では、step で15分単位にしても、ピッカーの分の一覧には1分ずつの時刻が並びました。選べる時刻をはっきり決めたいときのために、select で選択肢を並べる部品も作って比べています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。ピッカーの中の時刻をクリックして選ぶ操作は試しておらず、ピッカーの見え方はスクリーンショットで確かめています。Firefox、Safari、スマートフォンのブラウザ、スクリーンリーダーでは試していません。

input type="time" の値を、文字列の state で持つ
TimeInput は、見出し(label)、フォームで送るときの名前(name)、選べる範囲(min、max)、何分単位か(stepMinutes)を受け取ります。入力欄の下には、選んでいる時刻か、直してほしい点を出します。
import { useId, useState } from 'react';
type Props = {
label: string;
name: string;
min?: string; // "09:00" の形
max?: string;
stepMinutes?: number; // 何分単位で選ばせるか
required?: boolean;
};
export function TimeInput({ label, name, min, max, stepMinutes = 1, required }: Props) {
const id = useId();
const [value, setValue] = useState('');
const [error, setError] = useState('');
function check(input: HTMLInputElement) {
const v = input.validity;
if (v.stepMismatch) return `${stepMinutes}分単位で選んでください`;
if (v.rangeUnderflow || v.rangeOverflow) return `${min}〜${max}の間で選んでください`;
return '';
}
return (
<div className="time-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
type="time"
name={name}
value={value}
min={min}
max={max}
step={stepMinutes * 60}
required={required}
aria-describedby={`${id}-msg`}
aria-invalid={error ? true : undefined}
onChange={(e) => {
setValue(e.target.value);
setError(check(e.target));
}}
/>
<p id={`${id}-msg`} className={error ? 'error' : undefined}>
{error || (value ? `${value} を選んでいます` : '未選択')}
</p>
</div>
);
}
state の初期値は空文字列にします。React の input のリファレンスにあるとおり、値を管理する入力欄に渡す value は、undefined や null にしてはいけません。
value には、画面の表示の形にかかわらず、24時間制の "HH:mm" が入ります。MDN の input type="time" のページにも、そう書かれています。検証でも、日本語の設定で起動した Chromium で「1430」と打った場合と、英語の設定で起動した Chromium で「0230P」と打った場合(画面は「02:30 PM」)のどちらも、value は "14:30" でした。

MDN の「時刻値の書式」の節によると、画面の表示の形は、利用者のロケールかブラウザによって選ばれます。Chromium の起動オプションに --lang=ja-JP を渡し、Playwright の locale も ja-JP にしても(navigator.language は ja-JP)、入力欄に午前・午後を選ぶ欄が残り、12時間制のままでした。Chromium を起動するときに環境変数 LANG も ja_JP.UTF-8 にすると、時と分だけの24時間制になりました。ブラウザの言語の設定だけでは表示の形が決まらなかったので、どちらの形で表示されても困らないように、処理には value の "HH:mm" だけを使います。
step に秒の数を渡して、15分単位にする
step は秒で数えます。stepMinutes * 60 にしているのはそのためで、15分単位なら step={900} になります。MDN の step の説明によると、何も付けないときは60(1分)です。
検証に使った画面では、TimeInput を次のように置きました。
<TimeInput label="来店の時刻" name="visit" min="09:00" max="18:00" stepMinutes={15} required />
この入力欄で、時刻を打ったときの validity は次のとおりです。
| 打った時刻 | 入力欄の状態 | 下に出る文 |
|---|---|---|
| 10:30 | 問題なし | 10:30 を選んでいます |
| 10:07 | stepMismatch が true |
15分単位で選んでください |
| 08:30 | rangeUnderflow が true |
09:00〜18:00の間で選んでください |
| 18:30 | rangeOverflow が true |
09:00〜18:00の間で選んでください |
キーボードでは15分単位でない時刻も打てて、value には "10:07" が入りました。検証の画面では、フォームの onSubmit で送られる値を表示するようにしています。"10:07" が入った状態で送信ボタンを押すと、onSubmit は呼ばれず、フォーカスが入力欄に移りました。10:30 なら、{"visit":"10:30",...} のように送られます。
分の欄で ↑ と ↓ を押したときは、step どおりに15分ずつ動きました。
10:00 から: ArrowUp→10:15 ArrowUp→10:30 ArrowUp→10:45 ArrowUp→10:00 ArrowDown→10:45
10:45 で ↑ を押すと、11:00 ではなく 10:00 に戻ります。分の欄の中だけで回り、時の欄は変わりません。
一方、時計のアイコンをクリックして開くピッカーは、step を付けていても、分の列に1分ずつの一覧を出しました(上の GIF の3回目の操作)。10:15 を選んでいる状態で開くと、分の列は 15、16、17…と続きます。ピッカーの一覧に 10:16 のような時刻も並ぶので、15分単位で選ばせたい画面では、入力欄の下の文で知らせることが欠かせません。
日付をまたぐ時間帯は、min を max より後にする
min に max より後の時刻を渡すと、日付をまたぐ時間帯になります。MDN の「最小値と最大値が真夜中をまたぐようにする」の節にあるとおり、これは time の入力欄だけにある決まりです。
<TimeInput label="夜間の配達" name="night" min="22:00" max="06:00" stepMinutes={30} />
検証環境の Chromium は、22:00 から翌朝 6:00 までを正しい時刻として扱いました。日本語の設定で起動していても、validationMessage は英語でした。
| 打った時刻 | 入力欄の状態 | validationMessage |
|---|---|---|
| 23:00 | 問題なし | Value must be 6:00 or earlier. |
| 05:00 | 問題なし | Value must be 22:00 or later. |
| 12:00 | rangeUnderflow と rangeOverflow が両方 true |
Value must be between 22:00 and 6:00. |
| 23:15 | stepMismatch が true |
Value must be 6:00 or earlier. |
validity は期待どおりですが、validationMessage は合っていません。問題のない 23:00 にも「Value must be 6:00 or earlier.」(6:00 以前にしてください)という文が入り、30分単位でない 23:15 にも、範囲についての文が入っています。TimeInput で validationMessage を使わず、validity の項目を見て自分で文を作っているのはこのためです。
23:15 が stepMismatch になるのは、MDN の step の説明にあるとおり、min が段階の基準になるからです。22:00 から30分ずつの 22:30、23:00…が正しい時刻になります。
validationMessage は、日付をまたぐ範囲では、問題のない時刻にも文が入っていました。「validationMessage が空でなければエラー」という判定にすると、23:00 のような正しい時刻までエラーとして表示されます。正しいかどうかは validity.valid か、validity の個々の項目で判定してください。
MDN の「step 属性の使用」の節によると、step を60で割り切れない数にすると、秒の欄が出ます。step="1" の入力欄で「103000」と打つと、value は "10:30" ではなく "10:30:00" になりました。時刻どうしを比べるときは、文字列のままではなく、分に直してから比べると、秒の有無に左右されません。
// "HH:mm" の文字列を、0時からの分に直す
export function toMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
toMinutes('10:30') と toMinutes('10:30:00') は、どちらも 630 になりました。
選べる時刻を決めたいときは、select で並べる
15分単位の時刻から選ばせたいときは、時刻の入力欄をやめて、select に選べる時刻だけを並べる方法があります。10:07 のような時刻は、選択肢に出てきません。
まず、min から max まで、決まった分ごとの時刻の一覧を作る関数です。
const pad = (n: number) => String(n).padStart(2, '0');
// min から max まで、stepMinutes ごとの時刻の一覧を作る。
// max が min より前なら、日付をまたぐ範囲(22:00〜06:00 など)として扱う
export function timeSlots(min: string, max: string, stepMinutes: number): string[] {
const start = toMinutes(min);
let end = toMinutes(max);
if (end < start) end += 24 * 60;
const slots: string[] = [];
for (let t = start; t <= end; t += stepMinutes) {
const m = t % (24 * 60);
slots.push(`${pad(Math.floor(m / 60))}:${pad(m % 60)}`);
}
return slots;
}
max が min より前のときは24時間を足して、time の入力欄と同じく日付をまたぐ範囲として扱います。Node.js で実行した結果です。
timeSlots('09:00', '18:00', 15): 37 個 09:00 09:15 09:30 … 17:45 18:00
timeSlots('22:00', '06:00', 30): 22:00 22:30 23:00 23:30 00:00 00:30 01:00 01:30 02:00 02:30 03:00 03:30 04:00 04:30 05:00 05:30 06:00
この一覧を option にして並べます。先頭には、まだ選んでいない状態を表す、value が空の「選んでください」を置きます。
import { useId, useMemo, useState } from 'react';
import { timeSlots } from './timeSlots';
type Props = {
label: string;
name: string;
min: string;
max: string;
stepMinutes: number;
required?: boolean;
};
export function TimeSelect({ label, name, min, max, stepMinutes, required }: Props) {
const id = useId();
const [value, setValue] = useState('');
const slots = useMemo(() => timeSlots(min, max, stepMinutes), [min, max, stepMinutes]);
return (
<div className="time-field">
<label htmlFor={id}>{label}</label>
<select id={id} name={name} value={value} required={required} onChange={(e) => setValue(e.target.value)}>
<option value="">選んでください</option>
{slots.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
);
}
<TimeSelect label="受け取りの時刻" name="pickup" min="09:00" max="18:00" stepMinutes={15} /> と置くと、選択肢は「選んでください」と 09:00 から 18:00 までの37個、合わせて38個になりました。10:45 を選んで送信すると、"pickup":"10:45" が送られます。value の形は time の入力欄と同じ "HH:mm" なので、受け取る側の処理はどちらでも変わりません。
time の入力欄は、キーボードで数字を打てるので、時刻をすばやく入れたい画面に向いています。予約の枠のように、選べる時刻が決まっている画面では、select で並べるほうが、選べない時刻をそもそも見せずに済みます。





