Validating a Date Range So the Start Date Falls Before the End Date

A date range is invalid the moment the chosen start date is later than the end date, yet a naive per-field validator never catches it because each input is individually well-formed — this recipe enforces the cross-field rule, updates the validity of both inputs, and announces the failure accessibly so the user knows exactly which date to fix.

When to Use This Recipe

Use this whenever two date inputs are logically coupled and one must not exceed the other: booking check-in/check-out, report date ranges, subscription start/end, project timelines, or any “from / to” pair. The defining characteristic is that neither field is wrong in isolation — the error lives in the relationship between them, which is exactly the situation covered by Cross-Field Validation Strategies.

Choose this pattern when:

  • Both endpoints are user-editable (if the end date is fixed, a simple min attribute is enough).
  • Editing either field can resolve or introduce the error, so both must be re-validated on each change.
  • You need the failure to be programmatically announced, not just colored red — the same accessibility bar applied to cross-field password confirmation.

If you only have a single date with a static lower or upper bound, prefer the native min/max attributes documented in HTML5 Input Types & Attributes — no scripting required.

Cross-field date-range validation data flow The start date input and end date input both feed a single comparison function. When start is after end, the comparator sets a custom validity error on both inputs and writes a message to a shared aria-live region. Either change re-validates the pair start date change event end date change event compareRange() start > end ? validity on both inputs aria-live message
A change to either endpoint runs one comparator that writes validity onto both inputs and a single shared message region.

Minimal Working Implementation

Parse both values into Date objects (or compare the ISO strings directly, since <input type="date"> yields lexicographically sortable YYYY-MM-DD), then write the cross-field verdict onto both fields with setCustomValidity. Following the site’s <form novalidate> house style, the form’s own submit handler calls reportValidity() to surface anything left unresolved.

const form = document.querySelector<HTMLFormElement>('#range-form')!;
const startInput = form.querySelector<HTMLInputElement>('#start-date')!;
const endInput = form.querySelector<HTMLInputElement>('#end-date')!;
const liveRegion = form.querySelector<HTMLElement>('#range-error')!;

const MESSAGE = 'The start date must be on or before the end date.';

function validateRange(): boolean {
  const start = startInput.value;
  const end = endInput.value;

  // Don't flag an incomplete pair — only validate once both are present.
  if (!start || !end) {
    clearRangeError();
    return true;
  }

  // ISO 'YYYY-MM-DD' strings sort correctly, so a string compare is exact.
  const invalid = start > end;

  if (invalid) {
    // Mark BOTH inputs so either can carry focus and an inline message.
    startInput.setCustomValidity(MESSAGE);
    endInput.setCustomValidity(MESSAGE);
    startInput.setAttribute('aria-invalid', 'true');
    endInput.setAttribute('aria-invalid', 'true');
    liveRegion.textContent = MESSAGE; // announced via aria-live="assertive"
  } else {
    clearRangeError();
  }
  return !invalid;
}

function clearRangeError(): void {
  startInput.setCustomValidity('');
  endInput.setCustomValidity('');
  startInput.setAttribute('aria-invalid', 'false');
  endInput.setAttribute('aria-invalid', 'false');
  liveRegion.textContent = '';
}

// Re-validate the pair whenever EITHER endpoint changes.
startInput.addEventListener('change', validateRange);
endInput.addEventListener('change', validateRange);

form.addEventListener('submit', (e) => {
  if (!validateRange() || !form.reportValidity()) {
    e.preventDefault();
    // Focus the start field so the user lands on the editable cause.
    (startInput.validationMessage ? startInput : endInput).focus();
  }
});

The accessible markup pre-renders the message container so aria-describedby always points at a stable id and there is no layout shift when the error appears:

<form id="range-form" novalidate>
  <label for="start-date">Start date</label>
  <input type="date" id="start-date" aria-describedby="range-error" aria-invalid="false" />

  <label for="end-date">End date</label>
  <input type="date" id="end-date" aria-describedby="range-error" aria-invalid="false" />

  <p id="range-error" class="error-message" role="alert" aria-live="assertive"></p>
  <button type="submit">Save range</button>
</form>

Parameter & Option Reference

Parameter / Option Type Default Purpose
comparison operator > vs >= > > allows a same-day range (start equals end); switch to >= to require a strictly later end.
value source ISO string vs Date ISO string <input type="date"> returns sortable YYYY-MM-DD; parse to Date only if you need time-of-day or arithmetic.
setCustomValidity target both inputs both Marking both lets focus and inline messaging attach to either field.
aria-live politeness polite / assertive assertive A relationship error blocks progress, so assertive is appropriate; use polite if you validate live on every keystroke.
trigger event change / input change change fires when the picker commits a date; use input only if users type the date manually and you want live feedback.
empty-pair handling boolean treat as valid Avoids flagging the range before the user has filled both endpoints.

Verification Steps

A Playwright check that both inputs report the same custom validity:

test('inverted range blocks both fields', async ({ page }) => {
  await page.getByLabel('Start date').fill('2026-08-10');
  await page.getByLabel('End date').fill('2026-08-01');
  await page.getByRole('button', { name: 'Save range' }).click();

  for (const label of ['Start date', 'End date']) {
    await expect(page.getByLabel(label)).toHaveJSProperty(
      'validationMessage',
      'The start date must be on or before the end date.',
    );
  }
});

Edge Cases & Failure Modes

Stale validity after the user fixes one field. Because both inputs carry setCustomValidity(MESSAGE), fixing one field but forgetting to re-run the comparator leaves the other field still flagged, blocking submit with no visible cause. The fix is to bind the validator to the change event of both inputs (as above) and always clear validity on both in the valid branch, never just on the field that changed.

Time zones and Date parsing. If you convert the strings with new Date('2026-08-10'), the value is parsed as UTC midnight, which can shift a day in negative-offset zones and make an equal range look inverted. Comparing the raw ISO strings sidesteps this entirely; reach for Date only when you genuinely need duration math, and then construct local dates explicitly.

Equal dates rejected by accident. A single-day booking has identical start and end values. Using start >= end silently rejects that legitimate case. Default to > so same-day ranges pass, and only tighten to >= when the domain truly forbids a zero-length range.

Frequently Asked Questions

Why mark both inputs invalid instead of just the end date?

The error is a property of the pair, not one field, and the user might choose to fix it by moving the start date earlier rather than the end date later. Marking both keeps either field eligible to receive focus and an inline message, and ensures reportValidity() surfaces the problem regardless of which input the user reaches first.

Can I do this with native attributes alone?

Partly. You can set the end input's min attribute to the current start value (and the start input's max to the end value) on every change, which lets the browser enforce the bound. But you still need a script to keep those attributes in sync, so a single comparator that writes setCustomValidity on both fields is clearer and gives you full control over the message.

Should I validate on every keystroke or only on change?

For a native date picker, change is ideal because the value only commits once a full date is selected. If users type the date by hand you may prefer input for live feedback, but then switch the live region to aria-live="polite" so a half-typed year does not trigger a barrage of assertive announcements.

← Back to Cross-Field Validation Strategies