開始日・終了日連動(Date Range Picker)— minDate/maxDateを動的に制御するDatePicker

日付・時刻 初級

このコンポーネントについて

人気ライブラリ Flatpickr を使って、開始日と終了日の2つの入力欄を連動させる日付選択(DatePicker)コンポーネントです。 宿泊予約や期間指定検索でよくある「開始日を選んだら終了日はそれ以降しか選べない」という制御を、minDate の動的更新で実装しています。

単方向の連動(Pattern 1)と、終了日側からも開始日を制限できる双方向連動(Pattern 2)の2パターンを収録しています。 2つの独立した入力欄を相互に制限し合う構成のため、予約フォームなど開始日・終了日を別々に扱う業務画面にそのまま流用できます。

📎 1つの入力欄で期間をまとめて選択する mode: "range" の実装は flatpickrで日付の範囲選択を実装する方法【動くデモ付き】 で解説しています。本ページは開始日・終了日を別入力欄で扱い、minDate / maxDate を動的に制御するパターンに絞って紹介します。
  • minDateの動的更新(Pattern 1) — 開始日を選択すると、終了日Flatpickrの minDate がその日付に更新され、開始日より前を選べなくなる
  • 選択済み終了日の自動クリア — 開始日を後ろの日付に変更し、選択済みの終了日が新しいminDateより前になった場合は終了日を自動クリアする
  • 双方向連動(Pattern 2) — 終了日を選択すると開始日Flatpickrの maxDate も更新され、開始日側にも「終了日以前」の制限がかかる
  • 双方向の自動クリア — Pattern 2では開始日変更時の終了日クリアに加え、終了日を前の日付に変更した際に選択済みの開始日が自動クリアされる
  • 日本語ロケール — 曜日・月名が日本語表示になり、週の開始が日曜日になる

実装のポイント・注意点

minDateの動的更新は、開始日Flatpickrの onChange コールバック内で fpEnd.set('minDate', selectedDates[0]) を呼ぶだけで実現できます。 開始日をクリアした場合は fpEnd.set('minDate', null) で制限を解除します。

選択済み値の矛盾解消が本事例の山場です。minDate を更新しても、Flatpickrはすでに選択済みの値を自動では消しません。 そのため onChange 内で「終了日の選択済み値が新しいminDateより前かどうか」を明示的に比較し、該当する場合は fpEnd.clear() を呼ぶ処理が必須です。 これを怠ると、画面上は矛盾した日付ペア(終了日が開始日より前)が選択されたまま残ってしまいます。

双方向版(Pattern 2) では、開始日→終了日のminDate制御に加えて、終了日→開始日のmaxDate制御を同じロジックで追加します。 fpStartfpEnd それぞれの onChange で相手側の制限を更新し、矛盾時はクリアする、という同一パターンを両方向に適用するだけで実装できます。

注意点:Flatpickr の clear() メソッドは set() による制限を自動では解除しません。 クリアボタンのクリックハンドラ内で、相手側の minDate / maxDatenull に戻す処理を明示的に書く必要があります(サンプルソースに実装済み)。

HTML・CSS・JavaScriptで実装しており、Flatpickrライブラリ(CDN)を1本追加するだけで動きます。フレームワーク不要でコピペすぐに使えます。

デモ

Pattern 1 — 開始日→終了日(単方向連動)

開始日
終了日

Pattern 2 — 双方向連動

開始日
終了日

Powered by Flatpickr

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。
Flatpickr の CSS・JS はネット接続時に CDN から自動取得されます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>DatePicker(開始日・終了日連動)サンプル</title>
  <!-- Flatpickr CSS(CDN) -->
  <!-- SRI: バージョンを変更する場合は integrity のハッシュも更新してください -->
  <link rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css"
    integrity="sha512-MQXduO8IQnJVq1qmySpN87QQkiR1bZHtorbJBD0tzy7/0U9+YIC93QWHeGTEoojMVHWWNkoCp8V6OzVSYrX0oQ=="
    crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<!-- Pattern 1: 開始日→終了日(単方向連動) -->
<div class="dpr-pattern">
  <p class="dpr-pattern-label">Pattern 1 — 開始日→終了日(単方向連動)</p>
  <div class="dpr-time-row">
    <span class="dpr-row-label">開始日</span>
    <div class="dpr-input-row">
      <input type="text" id="dpr1-start-input" class="dpr-input"
             placeholder="YYYY/MM/DD" readonly autocomplete="off">
      <button type="button" class="dpr-icon-btn" id="dpr1-start-toggle" aria-label="開始日を選択">
        <svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
          <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
        </svg>
      </button>
      <button type="button" class="dpr-clear-btn" id="dpr1-start-clear" aria-label="クリア">×</button>
    </div>
  </div>
  <div class="dpr-time-row">
    <span class="dpr-row-label">終了日</span>
    <div class="dpr-input-row">
      <input type="text" id="dpr1-end-input" class="dpr-input"
             placeholder="YYYY/MM/DD" readonly autocomplete="off">
      <button type="button" class="dpr-icon-btn" id="dpr1-end-toggle" aria-label="終了日を選択">
        <svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
          <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
        </svg>
      </button>
      <button type="button" class="dpr-clear-btn" id="dpr1-end-clear" aria-label="クリア">×</button>
    </div>
  </div>
  <button type="button" class="dpr-confirm-btn" id="dpr1-confirm">決定</button>
  <p class="dpr-result" id="dpr1-result"></p>
</div>

<!-- Pattern 2: 双方向連動 -->
<div class="dpr-pattern">
  <p class="dpr-pattern-label">Pattern 2 — 双方向連動</p>
  <div class="dpr-time-row">
    <span class="dpr-row-label">開始日</span>
    <div class="dpr-input-row">
      <input type="text" id="dpr2-start-input" class="dpr-input"
             placeholder="YYYY/MM/DD" readonly autocomplete="off">
      <button type="button" class="dpr-icon-btn" id="dpr2-start-toggle" aria-label="開始日を選択">
        <svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
          <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
        </svg>
      </button>
      <button type="button" class="dpr-clear-btn" id="dpr2-start-clear" aria-label="クリア">×</button>
    </div>
  </div>
  <div class="dpr-time-row">
    <span class="dpr-row-label">終了日</span>
    <div class="dpr-input-row">
      <input type="text" id="dpr2-end-input" class="dpr-input"
             placeholder="YYYY/MM/DD" readonly autocomplete="off">
      <button type="button" class="dpr-icon-btn" id="dpr2-end-toggle" aria-label="終了日を選択">
        <svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
          <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
        </svg>
      </button>
      <button type="button" class="dpr-clear-btn" id="dpr2-end-clear" aria-label="クリア">×</button>
    </div>
  </div>
  <button type="button" class="dpr-confirm-btn" id="dpr2-confirm">決定</button>
  <p class="dpr-result" id="dpr2-result"></p>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"
  integrity="sha512-K/oyQtMXpxI4+K0W7H25UopjM8pzq0yrVdFdG21Fh5dBe91I40pDd9A4lzNlHPHBIP2cwZuoxaUSX0GJSObvGA=="
  crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/l10n/ja.min.js"
  integrity="sha512-XamS5TTeqwEqePo/YxW3AFGwvEcQzN520Om7b1eB/LYHt6tLgXZeutZPPF8D0sIo7YndJEpCEhKwvf62U780Dw=="
  crossorigin="anonymous"></script>
<script src="./script.js"></script>
</body>
</html>
:root {
  --dpr-accent: #2B7FE8;
  --dpr-danger: #E53E3E;
  --dpr-text:   #1A2332;
  --dpr-muted:  #9AA5B4;
  --dpr-border: #D0D7E0;
  --dpr-bg:     #F4F6F9;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 24px;
  background: #fff;
  color: var(--dpr-text);
}

/* ===== パターンカード ===== */
.dpr-pattern {
  margin-bottom: 20px;
  padding: 20px;
  background: var(--dpr-bg);
  border-radius: 8px;
  max-width: 520px;
}
.dpr-pattern:last-child { margin-bottom: 0; }

.dpr-pattern-label {
  margin: 0 0 14px;
  font-size: 11px;
  font-weight: 700;
  color: var(--dpr-accent);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* ===== 日付行(ラベル+入力欄グループ) ===== */
.dpr-time-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 10px;
}
.dpr-time-row:last-of-type { margin-bottom: 0; }

.dpr-row-label {
  font-size: 13px;
  color: #666;
  width: 56px;
  flex-shrink: 0;
}

/* ===== 入力欄グループ(入力+アイコン+クリア) ===== */
.dpr-input-row {
  display: flex;
  align-items: center;
}

/* ===== 入力欄 ===== */
.dpr-input {
  width: 140px;
  padding: 7px 10px;
  font-size: 14px;
  border: 1px solid var(--dpr-border);
  border-radius: 4px 0 0 4px;
  outline: none;
  font-family: sans-serif;
  color: var(--dpr-text);
  background: #fff;
  cursor: pointer;
  transition: border-color 0.15s;
}
.dpr-input:focus { border-color: var(--dpr-accent); }

/* カレンダーアイコンボタン */
.dpr-icon-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--dpr-border);
  border-left: none;
  border-radius: 0;
  background: #fff;
  color: #5A6A7A;
  cursor: pointer;
  transition: background 0.15s;
  flex-shrink: 0;
}
.dpr-icon-btn:hover { background: var(--dpr-bg); }

/* ×クリアボタン */
.dpr-clear-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--dpr-border);
  border-left: none;
  border-radius: 0 4px 4px 0;
  background: #fff;
  color: var(--dpr-muted);
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  transition: color 0.15s;
  flex-shrink: 0;
}
.dpr-clear-btn:hover { color: #5A6A7A; }

/* ===== 決定ボタン ===== */
.dpr-confirm-btn {
  display: inline-block;
  margin-top: 14px;
  padding: 8px 24px;
  background: var(--dpr-accent);
  color: #fff;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  font-family: sans-serif;
  transition: opacity 0.15s;
}
.dpr-confirm-btn:hover { opacity: 0.85; }

/* ===== 結果表示 ===== */
.dpr-result {
  margin: 8px 0 0;
  font-size: 14px;
  min-height: 20px;
}
.dpr-result.has-value {
  padding: 10px 14px;
  background: #F0F7FF;
  border: 1px solid var(--dpr-accent);
  border-radius: 6px;
  color: #1A5EA8;
  font-weight: 600;
}
.dpr-result.is-error { color: var(--dpr-danger); }
// ===== Flatpickr 共通設定 =====
var baseConfig = {
  locale: 'ja',        // 日本語ロケール(l10n/ja.min.js 読み込み後に有効)
  dateFormat: 'Y/m/d', // 表示フォーマット: YYYY/MM/DD
  allowInput: false    // テキスト直接入力を無効化(カレンダー選択のみ)
};


// ===== Pattern 1: 開始日→終了日(単方向連動) =====
var fp1Start = flatpickr('#dpr1-start-input', Object.assign({}, baseConfig, {
  onChange: function(selectedDates) {
    if (selectedDates.length > 0) {
      // 終了日カレンダーの最小選択可能日を開始日に設定
      fp1End.set('minDate', selectedDates[0]);
      // 終了日が新しいminDateより前ならば自動クリア(矛盾した状態を防ぐ)
      if (fp1End.selectedDates.length > 0 && fp1End.selectedDates[0] < selectedDates[0]) {
        fp1End.clear();
      }
    } else {
      fp1End.set('minDate', null); // 開始日が未選択なら制限を解除
    }
  }
}));

var fp1End = flatpickr('#dpr1-end-input', baseConfig);

document.getElementById('dpr1-start-toggle').addEventListener('click', function() { fp1Start.open(); });
document.getElementById('dpr1-end-toggle').addEventListener('click', function()   { fp1End.open(); });

// 開始日クリア(clear() は set() の制限を解除しないため手動でリセット)
document.getElementById('dpr1-start-clear').addEventListener('click', function() {
  fp1Start.clear();
  fp1End.set('minDate', null);
  clearResult('dpr1-result');
});

document.getElementById('dpr1-end-clear').addEventListener('click', function() {
  fp1End.clear();
  clearResult('dpr1-result');
});

// 決定ボタンで結果を表示
document.getElementById('dpr1-confirm').addEventListener('click', function() {
  var result = document.getElementById('dpr1-result');
  if (fp1Start.selectedDates.length === 0 || fp1End.selectedDates.length === 0) {
    showError(result, '※ 開始日と終了日の両方を選択してください');
    return;
  }
  showValue(result, '期間: ' + formatJa(fp1Start.selectedDates[0]) + ' 〜 ' + formatJa(fp1End.selectedDates[0]));
});


// ===== Pattern 2: 双方向連動 =====
var fp2Start = flatpickr('#dpr2-start-input', Object.assign({}, baseConfig, {
  onChange: function(selectedDates) {
    if (selectedDates.length > 0) {
      fp2End.set('minDate', selectedDates[0]);
      if (fp2End.selectedDates.length > 0 && fp2End.selectedDates[0] < selectedDates[0]) {
        fp2End.clear();
      }
    } else {
      fp2End.set('minDate', null);
    }
  }
}));

var fp2End = flatpickr('#dpr2-end-input', Object.assign({}, baseConfig, {
  onChange: function(selectedDates) {
    if (selectedDates.length > 0) {
      // 開始日カレンダーの最大選択可能日を終了日に設定
      fp2Start.set('maxDate', selectedDates[0]);
      // 開始日が新しいmaxDateより後ならば自動クリア
      if (fp2Start.selectedDates.length > 0 && fp2Start.selectedDates[0] > selectedDates[0]) {
        fp2Start.clear();
      }
    } else {
      fp2Start.set('maxDate', null); // 終了日が未選択なら制限を解除
    }
  }
}));

document.getElementById('dpr2-start-toggle').addEventListener('click', function() { fp2Start.open(); });
document.getElementById('dpr2-end-toggle').addEventListener('click', function()   { fp2End.open(); });

// 開始日クリア → 終了日側のminDate制限も解除
document.getElementById('dpr2-start-clear').addEventListener('click', function() {
  fp2Start.clear();
  fp2End.set('minDate', null);
  clearResult('dpr2-result');
});

// 終了日クリア → 開始日側のmaxDate制限も解除
document.getElementById('dpr2-end-clear').addEventListener('click', function() {
  fp2End.clear();
  fp2Start.set('maxDate', null);
  clearResult('dpr2-result');
});

document.getElementById('dpr2-confirm').addEventListener('click', function() {
  var result = document.getElementById('dpr2-result');
  if (fp2Start.selectedDates.length === 0 || fp2End.selectedDates.length === 0) {
    showError(result, '※ 開始日と終了日の両方を選択してください');
    return;
  }
  showValue(result, '期間: ' + formatJa(fp2Start.selectedDates[0]) + ' 〜 ' + formatJa(fp2End.selectedDates[0]));
});


// ===== ユーティリティ =====
// Date を「YYYY年M月D日」形式の文字列に変換
function formatJa(date) {
  return date.getFullYear() + '年' + (date.getMonth() + 1) + '月' + date.getDate() + '日';
}
function showValue(el, text) {
  el.textContent = text;
  el.className = 'dpr-result has-value';
}
function showError(el, text) {
  el.textContent = text;
  el.className = 'dpr-result is-error';
}
function clearResult(id) {
  var el = document.getElementById(id);
  el.textContent = '';
  el.className = 'dpr-result';
}

// デモリセットボタン用
function resetDemo() {
  fp1Start.clear();
  fp1End.clear();
  fp1End.set('minDate', null);
  fp2Start.clear();
  fp2End.clear();
  fp2Start.set('maxDate', null);
  fp2End.set('minDate', null);
  clearResult('dpr1-result');
  clearResult('dpr2-result');
}

AI用プロンプト

各パターンのプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や日付フォーマットの変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

Pattern 1 — 開始日→終了日(単方向連動)

# DatePicker(Flatpickr・開始日→終了日連動)作成依頼

## 概要
Flatpickr ライブラリを使って、開始日を選ぶと終了日の選択可能範囲が動的に制限される日付選択コンポーネントを実装してください。

## 要件
- 開始日・終了日それぞれに「テキスト入力欄+カレンダーアイコンボタン+クリアボタン」を用意する
- 入力欄クリック / アイコンボタンクリックで対応するFlatpickrカレンダーを表示する
- 日本語ロケール(曜日・月名が日本語、週始まりが日曜)を適用する
- 日付フォーマットは YYYY/MM/DD にする
- 開始日を選択したら終了日カレンダーの minDate を自動更新し、開始日より前の日付を選べないよう制限する
- 開始日をクリアしたら終了日の minDate 制限を解除する
- 開始日を変更したとき、終了日がすでに新しい開始日より前になっていたら終了日を自動クリアする
- 各クリアボタンで対応する入力欄をリセットする
- 「決定」ボタン押下で選択期間を「期間: YYYY年M月D日 〜 YYYY年M月D日」形式で表示する。どちらか未選択の場合はエラーメッセージを表示する

## 技術仕様
- HTML / CSS / JavaScript で実装
- 外部ライブラリ:Flatpickr 4.6.13(CDN)+ 日本語ロケール(l10n/ja.min.js)
- レスポンシブ対応:必要

## 動作詳細
- 開始・終了それぞれ独立した Flatpickr インスタンスを作成する(fpStart, fpEnd)
- 共通オプション: locale: "ja" / dateFormat: "Y/m/d" / allowInput: false
- fpStart の onChange 内で fpEnd.set('minDate', fpStart.selectedDates[0]) を呼んで終了日の最小選択可能日を更新する
- fpStart をクリアした場合は fpEnd.set('minDate', null) で制限を解除する
- fpEnd の選択済み値が新しい minDate より前になっていた場合は fpEnd.clear() を呼ぶ
- Flatpickr CSS は cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css を読み込む
- Flatpickr JS は cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js を読み込む
- 日本語ロケールは cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/l10n/ja.min.js を Flatpickr 本体より後に読み込む

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。

Pattern 2 — 双方向連動

# DatePicker(Flatpickr・開始日⇄終了日 双方向連動)作成依頼

## 概要
Flatpickr ライブラリを使って、開始日・終了日が互いの選択可能範囲を制限し合う双方向連動の日付選択コンポーネントを実装してください。

## 要件
- 開始日・終了日それぞれに「テキスト入力欄+カレンダーアイコンボタン+クリアボタン」を用意する
- 日本語ロケール(曜日・月名が日本語、週始まりが日曜)、日付フォーマット YYYY/MM/DD を適用する
- 開始日を選択したら終了日カレンダーの minDate を自動更新する(開始日より前を選べなくする)
- 終了日を選択したら開始日カレンダーの maxDate を自動更新する(終了日より後を選べなくする)
- 開始日を変更したとき終了日が範囲外になっていたら終了日を自動クリアする。終了日を変更したとき開始日が範囲外になっていたら開始日を自動クリアする
- 各クリアボタンでクリアした側の入力欄をリセットし、相手側の制限(minDate / maxDate)も解除する
- 「決定」ボタン押下で「期間: YYYY年M月D日 〜 YYYY年M月D日」形式で表示する。どちらか未選択の場合はエラーメッセージを表示する

## 技術仕様
- HTML / CSS / JavaScript で実装
- 外部ライブラリ:Flatpickr 4.6.13(CDN)+ 日本語ロケール(l10n/ja.min.js)
- レスポンシブ対応:必要

## 動作詳細
- 開始・終了それぞれ独立した Flatpickr インスタンスを作成する(fpStart, fpEnd)
- 共通オプション: locale: "ja" / dateFormat: "Y/m/d" / allowInput: false
- fpStart の onChange で fpEnd.set('minDate', ...) を、fpEnd の onChange で fpStart.set('maxDate', ...) を、それぞれ相手側に適用する
- 各クリア時は対応する set('minDate', null) / set('maxDate', null) で制限を解除する
- Flatpickr CSS・JS・日本語ロケールの読み込み先は単方向版と同じ CDN URL を使用する

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。