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.

Enforcing a Minimum and Maximum Span

The ordering rule is often only half the requirement. A hotel may demand at least a one-night stay, a trial subscription may cap the range at 30 days, and a reporting tool may refuse ranges wider than a fiscal year to keep queries cheap. These are still cross-field rules — the constraint lives in the distance between the two endpoints — so they belong in the same comparator rather than in two disconnected per-field checks.

Compute the span in whole days from the ISO strings and layer the bounds on top of the ordering test. Because both values are YYYY-MM-DD, constructing dates with the Date.UTC epoch avoids the daylight-saving hour drift that would otherwise make a “7-day” span occasionally measure 6.96 days and round wrong.

const MS_PER_DAY = 86_400_000;
const MIN_NIGHTS = 1;
const MAX_NIGHTS = 30;

/** Whole-day span between two ISO 'YYYY-MM-DD' strings, DST-safe. */
function spanInDays(startIso: string, endIso: string): number {
  const [sy, sm, sd] = startIso.split('-').map(Number);
  const [ey, em, ed] = endIso.split('-').map(Number);
  // Date.UTC sidesteps local-zone offsets, so the divisor is exact.
  const start = Date.UTC(sy, sm - 1, sd);
  const end = Date.UTC(ey, em - 1, ed);
  return Math.round((end - start) / MS_PER_DAY);
}

function rangeMessage(startIso: string, endIso: string): string | null {
  if (startIso > endIso) return 'The start date must be on or before the end date.';
  const nights = spanInDays(startIso, endIso);
  if (nights < MIN_NIGHTS) return `The stay must be at least ${MIN_NIGHTS} night.`;
  if (nights > MAX_NIGHTS) return `The stay cannot exceed ${MAX_NIGHTS} nights.`;
  return null; // null means the pair is valid
}

Returning a nullable message rather than a boolean lets the caller feed a specific string into setCustomValidity for each distinct failure, so a screen-reader user hears “at least 1 night” instead of a vague “invalid range.” Wire it into the existing validateRange by replacing the start > end test with const message = rangeMessage(start, end) and branching on whether message is null.

Mirroring the Bound to Native min/max

Scripting the comparator does not mean abandoning the browser’s built-in enforcement. On every change, copy the committed value of one field into the other’s constraint attribute — set endInput.min = startInput.value and startInput.max = endInput.value. The date picker then greys out impossible days before the user can even select them, turning an error you must report into an interaction you prevent.

Keep the JavaScript comparator as the source of truth regardless. Native min/max on type="date" is unenforced when the field is empty, is trivially bypassed by pasting or by programmatic value changes, and produces a generic rangeUnderflow/rangeOverflow message you cannot customize. Treat the mirrored attributes as a UX affordance that narrows the target, and keep setCustomValidity as the authoritative gate that runs on submit.

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.

How do I compare dates that include a time, not just a day?

Switch the inputs to type="datetime-local", which also yields a lexically sortable string (YYYY-MM-DDTHH:mm), so the same raw string comparison stays exact within a single time zone. Only parse to Date if you must compare values that were captured in different offsets, and then normalise both to UTC before comparing so a 09:00 start in one zone is not judged against a 09:00 end in another.

← Back to Cross-Field Validation Strategies