Reserving Space for Error Messages to Prevent Layout Shift
A user tabs out of the email field, an error appears below it, and everything underneath jumps down by a line — just as they click the next field, so the click lands on the wrong element. On mobile the jump can push the field they were about to tap under their thumb or behind the keyboard. Inline validation messages are one of the most common causes of unexpected layout shift in forms, and they are easy to prevent. This recipe reserves a slot for each field’s message so showing and hiding it never moves anything, keeps borders the same width in every state, handles messages that wrap to several lines, measures the result with the Layout Instability API, and explains when a small, user-initiated shift is acceptable. The messages themselves still come from the Constraint Validation API and are linked with aria-describedby as usual.
When Layout Shift From Errors Matters
It matters whenever errors appear while the user is still interacting — which, with blur validation, is most of the time:
- Errors shown on blur, which appear as the user moves to the next field and is about to click or tap it.
- Live errors that toggle while typing, which make the form bounce with every keystroke that crosses a rule.
- Async results arriving late, which shift the page while the user is elsewhere.
- Mobile forms, where a one-line shift can move a field under the keyboard.
Errors revealed only on submit also shift the layout, but the shift follows a user action and is expected, so Core Web Vitals excludes shifts within 500 ms of input. The timing strategies that decide when errors appear are covered in best practices for inline validation timing; this page is about making their appearance harmless.
Minimal Working Reserved Slots
<div class="field">
<label for="email">Email address</label>
<input id="email" name="email" type="email" required aria-describedby="email-err">
<p id="email-err" class="field-error" data-empty="true"></p>
</div>
.field {
display: grid;
grid-template-rows: auto auto minmax(1.5em, auto); /* label, input, reserved message row */
gap: 0.3rem;
}
/* Same border width in every state: only the colour and style change. */
.field :is(input, select, textarea) {
border: 2px solid var(--field-border, #64748b);
}
.field :is(input, select, textarea):user-invalid {
border-color: var(--error-border, #b91c1c); /* no width change, no shift */
}
.field-error {
min-block-size: 1.5em; /* the reserved line */
margin: 0;
color: var(--error-text, #b91c1c);
overflow-wrap: anywhere;
}
.field-error[data-empty="true"] { visibility: hidden; } /* keeps its space */
function showError(field: HTMLInputElement, message: string): void {
const out = document.getElementById(`${field.id}-err`)!;
out.textContent = message;
out.dataset.empty = String(!message);
field.setAttribute("aria-invalid", String(Boolean(message)));
}
const form = document.querySelector<HTMLFormElement>("#signup")!;
form.addEventListener("focusout", (e) => {
const el = e.target as HTMLInputElement;
if (el.matches("input")) showError(el, el.validity.valid ? "" : el.validationMessage);
});
form.addEventListener("submit", (e) => {
for (const el of form.querySelectorAll<HTMLInputElement>("input")) showError(el, el.validity.valid ? "" : el.validationMessage);
if (!form.reportValidity()) e.preventDefault();
});
Using visibility: hidden rather than hidden or display: none keeps the empty message’s box in the layout, so appearing costs nothing. An empty element with visibility: hidden is also ignored by screen readers, so aria-describedby pointing at it adds nothing to the field’s description until there is a message.
Space Reservation Option Reference
| Technique | Prevents | Cost | Notes |
|---|---|---|---|
min-block-size on the message |
Shift for one-line messages | One line of whitespace per field | Choose the line height of your error text |
visibility: hidden when empty |
Collapse of the reserved box | None | Also hidden from assistive technology |
| Constant border width | One- or two-pixel jumps | None | Change colour and style, never width |
outline instead of thicker border |
Shift on focus and error | None | Outlines do not affect layout |
Grid row minmax(1.5em, auto) |
Shift within a grid of fields | None | Rows still grow for longer messages |
| Absolutely positioned message | All shift | Overlap risk | Avoid: can cover the next field |
Verification Steps
// In-page measurement during development: log any shift not caused by recent input.
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput && entry.value > 0) {
console.warn("Layout shift", entry.value.toFixed(4), entry.sources?.map((s: any) => s.node));
}
}
}).observe({ type: "layout-shift", buffered: true });
import { test, expect } from "@playwright/test";
test("showing an error does not move the next field", async ({ page }) => {
await page.goto("/signup");
const next = page.getByLabel("Password");
const before = (await next.boundingBox())!.y;
await page.getByLabel("Email address").fill("not-an-email");
await page.getByLabel("Email address").blur();
await expect(page.locator("#email-err")).not.toBeEmpty();
expect((await next.boundingBox())!.y).toBe(before);
});
Edge Cases and Failure Modes
Messages longer than the reserved line. A two-line message on a narrow phone still shifts content by one line. Keep messages short enough to fit one line at your narrowest breakpoint where possible; when they cannot, accept the shift — it is user-initiated if shown on blur or submit — rather than truncating the message.
Hints that disappear when errors appear. Replacing a hint with an error of a different height shifts the layout. Put them in the same slot with the same minimum height, or keep both rows reserved.
Error summaries appearing at the top. A summary inserted above the form on submit pushes the whole form down. That shift follows the user’s action and focus moves to the summary, so it is acceptable; but do not insert a summary while the user is typing.
Fonts loading late. If the error font loads after the first message renders, the line height can change and shift the form. Size the reserved slot in em units of a fallback-compatible font, or use font-display: optional for form UI.
Async results. Messages that appear after an asynchronous check arrive without recent input, so their shifts count against Cumulative Layout Shift. Reserved slots matter most for exactly these fields.
Animating the Message Without Moving the Layout
Designers often want errors to fade or slide in rather than snap. Because the slot is already reserved, any animation can happen inside it without affecting the layout: animate opacity and a small transform, never height, margin or padding, which would reintroduce the shift you just removed. Transforms and opacity are also cheap for the browser to animate, so they do not add input latency while the user is typing. Respect reduced-motion preferences by dropping the movement and keeping, at most, a quick fade.
.field-error { transition: opacity 150ms ease, transform 150ms ease; }
.field-error[data-empty="true"] { opacity: 0; transform: translateY(-2px); visibility: hidden; }
.field-error[data-empty="false"] { opacity: 1; transform: none; visibility: visible; }
@media (prefers-reduced-motion: reduce) {
.field-error { transition: opacity 100ms linear; transform: none !important; }
}
Note that visibility switches immediately while opacity fades, so the message becomes readable to screen readers at the moment it becomes visible, not after the animation ends. The broader motion guidance is in accessible error shake animation with reduced motion.
When Reserving Space Is Not Worth It
Reserving a line per field makes long forms longer, and on dense layouts that whitespace has a cost. Two alternatives are worth considering. If errors are only shown on submit, the shift is expected and excluded from layout-shift scoring, and focus moves to the first error anyway, so unreserved messages are acceptable. And on very dense forms, reserve space only for fields that validate on blur or asynchronously — typically email, username, password, card number — and let the rest appear on submit. The principle is simple: nothing should move under the user’s pointer as a result of something they did not just do. The placement choices on small screens are covered in touch-friendly error message placement.
Frequently Asked Questions
How do I stop error messages from pushing the form down?
Reserve a slot for each field's message with a minimum height of one line, and hide the empty slot with visibility: hidden rather than display: none, so showing the message fills existing space instead of adding new space.
Do error messages count toward Cumulative Layout Shift?
Shifts within 500 milliseconds of user input are excluded, so errors shown immediately on submit usually do not count. Errors that appear later, such as after asynchronous checks, do count, which is why reserved space matters most for them.
Why does my field jump by a pixel when it becomes invalid?
The error style probably changes the border width. Keep the width constant across states and change only the colour or style, or use an outline, which does not affect layout.
Is an empty reserved message element announced by screen readers?
Not if it is hidden with visibility: hidden. The field's aria-describedby then contributes nothing until the message has text and becomes visible.
Related Guides
- Inline Error Messaging Strategies — where and how inline messages appear.
- Best Practices for Inline Validation Timing — when messages appear.
- Linking Errors with aria-describedby — association that works with reserved slots.
- Touch-Friendly Error Message Placement — placement on small screens.