Validating Date Input Min, Max and Format

Why does <input type="date" min="18/09/2026"> never complain about past dates, why does the error text differ in every browser, and why does a half-typed date submit as empty? This recipe sets min and max in the only format browsers accept, computes them from the user’s local “today” without UTC drift, maps rangeUnderflow, rangeOverflow, badInput and valueMissing to your own messages, keeps dynamic limits current when a page stays open past midnight, and runs the same rules through a typed fallback — all on the site’s standard Constraint Validation API baseline with novalidate and a manual reportValidity().

When to Use the Native Date Input

The native input is the right default for dates the user chooses relative to now: delivery dates, appointment days, travel dates, report ranges. It brings a localised picker, keyboard entry segment by segment, mobile date wheels and built-in range constraints at no cost. Use it when:

  • The date is near today, so the picker opens in the right month.
  • You need range limits that the picker can grey out, such as “not in the past” or “within 90 days”.
  • You want native mobile pickers, which are faster than any custom widget on touch devices.

For dates people know by heart, especially dates of birth, prefer the three-field pattern from age verification and date of birth validation. For time-of-day and time-zone rules, see validating time slots and business hours.

Date input validity flags and messages A table mapping each ValidityState flag a native date input can raise to its cause and the message the recipe shows. Cause Message valueMissing required, nothing entered Enter a delivery date badInput partly typed or impossible date Enter a real date rangeUnderflow before min Choose 19 September or later rangeOverflow after max Choose 17 December or earlier
Four flags cover every native date failure; each gets a message that names the fix rather than the browser's generic text.

Minimal Working Date Range Validator

<form id="delivery" novalidate>
  <label for="deliver-on">Delivery date</label>
  <input id="deliver-on" name="deliverOn" type="date" required aria-describedby="deliver-hint deliver-err">
  <p id="deliver-hint" class="hint">From tomorrow, up to 90 days ahead.</p>
  <p id="deliver-err" class="field-error" hidden></p>
  <button type="submit">Book delivery</button>
</form>
/** Local calendar date as YYYY-MM-DD. Never use toISOString() on "now" for this. */
function localISO(d = new Date()): string {
  return [d.getFullYear(), d.getMonth() + 1, d.getDate()].map((n, i) => String(n).padStart(i ? 2 : 4, "0")).join("-");
}

function shiftISO(iso: string, days: number): string {
  const [y, m, d] = iso.split("-").map(Number);
  return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10);
}

const display = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
const pretty = (iso: string) => display.format(new Date(`${iso}T00:00:00Z`));

const form = document.querySelector<HTMLFormElement>("#delivery")!;
const field = form.querySelector<HTMLInputElement>("#deliver-on")!;
const error = form.querySelector<HTMLElement>("#deliver-err")!;

function applyLimits(): void {
  const today = localISO();
  field.min = shiftISO(today, 1);
  field.max = shiftISO(today, 90);
}

function message(): string {
  const v = field.validity;
  if (v.badInput) return "Enter a real date, for example 3 October 2026.";
  if (v.valueMissing) return "Enter a delivery date.";
  if (v.rangeUnderflow) return `Choose ${pretty(field.min)} or later.`;
  if (v.rangeOverflow) return `Choose ${pretty(field.max)} or earlier.`;
  return "";
}

function validate(show: boolean): void {
  field.setCustomValidity("");               // clear ours so the native flags are readable
  const msg = message();
  field.setCustomValidity(msg);              // replace the browser's text with ours
  if (show) {
    error.textContent = msg;
    error.hidden = !msg;
    field.toggleAttribute("aria-invalid", Boolean(msg));
  }
}

applyLimits();
// Pages left open overnight: refresh the limits when the user comes back.
document.addEventListener("visibilitychange", () => document.visibilityState === "visible" && applyLimits());
field.addEventListener("blur", () => validate(true));
field.addEventListener("input", () => validate(!error.hidden));   // clear live once shown

form.addEventListener("submit", (event) => {
  applyLimits();
  validate(true);
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});

Clearing the custom validity before reading the flags matters. A field with a custom error has customError set, but the native flags (rangeUnderflow and friends) are computed independently and remain readable; clearing first simply guarantees that a stale custom message from the previous check can never mask the current state.

Mapping a native date input to one message A decision tree that checks badInput first, then valueMissing, then rangeUnderflow and rangeOverflow, to pick exactly one message for a native date input. validity.badInput? yes Enter a real date no validity.valueMissing? yes Enter a delivery date no Outside min or max? yes Choose on or after min / before max no Valid
badInput is checked before valueMissing because a half-typed date reports an empty value too.

Date Input Option Reference

Option Type Default Purpose
min "YYYY-MM-DD" tomorrow Earliest allowed date; greys out earlier days in pickers
max "YYYY-MM-DD" today + 90 Latest allowed date
step days 1 step="7" with a min restricts to one weekday
required boolean on Raises valueMissing when empty
Limit refresh visibilitychange on Keeps “tomorrow” correct on long-open pages
Display formatter Intl.DateTimeFormat locale, timeZone: "UTC" Readable dates in messages without drift

The step attribute is underused: min="2026-09-21" step="7" allows only Mondays (because 21 September 2026 is a Monday) and raises stepMismatch for any other day. Add a matching message (“Deliveries are on Mondays only”) if you use it.

Verification Steps

import { test, expect } from "@playwright/test";

test.use({ locale: "en-GB" });   // pins the date format used in the message

test("past dates are rejected with a readable message", async ({ page }) => {
  await page.clock.setFixedTime(new Date("2026-09-18T10:00:00"));
  await page.goto("/delivery");
  await page.getByLabel("Delivery date").fill("2026-09-17");
  await page.getByRole("button", { name: "Book delivery" }).click();
  await expect(page.locator("#deliver-err")).toHaveText("Choose 19 September 2026 or later.");
  await expect(page.getByLabel("Delivery date")).toBeFocused();
});

Edge Cases and Failure Modes

UTC drift in min. new Date().toISOString().slice(0, 10) returns the UTC date. In the evening in the Americas it is already tomorrow, so min becomes the day after tomorrow and a valid “tomorrow” delivery is refused. Build the date from local getters, as localISO does.

Reading valueAsDate in local time. It is UTC midnight; field.valueAsDate.getDate() returns the previous day west of UTC. Read field.value (a string) or use getUTCDate().

Browser-specific typing. Chromium lets users type impossible dates like 31 February and reports badInput; Firefox and Safari clamp or reject segments as you type. Your message logic must work for both, which is why it checks flags rather than parsing the value.

Styling invalid date inputs early. :invalid matches a required date input on page load, painting it red before any interaction. Use :user-invalid, as covered in styling invalid inputs with :user-invalid.

Providing a Typed Fallback With the Same Rules

Some users find the segmented native control awkward — especially with screen magnifiers, where the picker opens off-screen — and some organisations need consistent visuals across browsers. A plain text field that accepts YYYY-MM-DD, DD/MM/YYYY and a few other shapes can sit behind a “Type the date instead” toggle and feed exactly the same min/max logic, by converting the typed value to ISO and comparing strings. The key is reuse: the parser produces an ISO string, and from that point the same comparison and the same message functions apply, so both inputs always agree about what is valid.

export function parseTypedDate(raw: string, order: "DMY" | "MDY" = "DMY"): string | null {
  const s = raw.trim();
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
  const m = s.match(/^(\d{1,2})[./\s-](\d{1,2})[./\s-](\d{4})$/);
  if (!m) return null;
  const [a, b, y] = [Number(m[1]), Number(m[2]), Number(m[3])];
  const [d, mo] = order === "DMY" ? [a, b] : [b, a];
  const dt = new Date(Date.UTC(y, mo - 1, d));
  if (dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;   // rejects 31/02
  return dt.toISOString().slice(0, 10);
}

The order parameter must come from the page’s locale, not a guess: 03/04/2026 is 3 April in most of the world and 4 March in the United States. When in doubt, state the format in the hint and reject values that are ambiguous rather than silently choosing.

Cross-Field Date Ranges With Native Inputs

Two native inputs for a range — check-in and check-out — can constrain each other through min and max: when check-in changes, set check-out’s min to the day after. The browser then raises rangeUnderflow on check-out automatically and greys out impossible days in its picker, which is better than any custom rule because it prevents the error rather than reporting it. Re-run validation on check-out whenever check-in changes, because a previously valid check-out can become invalid; the dependency pattern and its announcements are covered in validating date range start before end.

Check-in constrains check-out through min Changing the check-in date updates the check-out input's min attribute, which re-evaluates its rangeUnderflow flag and greys out earlier days in the picker. User Check-in input Check-out input choose 12 October min = "2026-10-13" dispatch input to re-validate existing 10 October now sets rangeUnderflow "Choose 13 October 2026 or later."
The dependant field's constraint is updated rather than re-implemented, so the browser does the range check and the picker prevents most mistakes.
const checkIn = form.querySelector<HTMLInputElement>("#check-in")!;
const checkOut = form.querySelector<HTMLInputElement>("#check-out")!;
checkIn.addEventListener("change", () => {
  checkOut.min = checkIn.value ? shiftISO(checkIn.value, 1) : field.min;
  if (checkOut.value) checkOut.dispatchEvent(new Event("input"));     // re-validate the dependant
});

Frequently Asked Questions

Why is my date input's min attribute ignored?

It is probably not in YYYY-MM-DD format. Browsers only accept ISO dates in min and max, regardless of how the field is displayed, and silently ignore anything else.

How do I show my own message instead of the browser's date error?

Read the ValidityState flags — badInput, valueMissing, rangeUnderflow, rangeOverflow — and call setCustomValidity() with your own text for whichever is set, then call reportValidity() on submit.

Why is the value of my date input empty when the user has typed something?

The value stays empty until the date is complete and valid. A partially typed or impossible date sets validity.badInput to true; check it first and ask for a real date.

How do I set the minimum date to today?

Build today's date from local getters (getFullYear, getMonth, getDate) and format it as YYYY-MM-DD. Using toISOString() gives the UTC date, which is wrong for part of every day in most time zones.

← Back to Date and Time Validation