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.
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.
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.
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.
Related Guides
- Date and Time Validation — calendar dates versus instants and time zones.
- Reading ValidityState Flags for Granular Errors — the flag-to-message pattern in general.
- Validating Card Expiry Dates — month-year dates with end-of-month rules.
- Localizing Custom Validation Messages — translating the messages built here.