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.

Appearing message versus reserved slot Two columns comparing an error message inserted into the layout when it appears with a message that fills a slot reserved in advance. Message inserted on error • display: none → block ✗ everything below jumps down ✗ clicks land on the wrong field ✗ counts toward layout shift Reserved message slot • slot always present, one line tall ✓ nothing moves when the message appears ✓ clicks land where the user aimed • one line of whitespace per field
A reserved slot trades a little whitespace for a form that never moves under the user's pointer or thumb.

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.

Anatomy of a shift-free field A field built from four stable rows: label, optional hint, input with a constant border width, and a message row reserved at one line tall. Label fixed row Hint (optional) fixed row, hidden text stays sized Input 2px border in every state Message slot min 1.5em, visibility hidden when empty
Every row exists from the start; errors only fill in text and change colours, so nothing below the field ever moves for single-line messages.

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.

Should this field reserve space for its message? A decision tree for reserving space for a field's error message depending on whether the message can appear without the user just submitting, and whether it fits on one line. Can the error appear before the user submits? no Let it appear on submit yes Does the message fit on one line at the narrowest width? yes Reserve one line no Reserve one line, shorten the message
Reserve space where messages appear mid-interaction; submit-only messages can simply appear.

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.

← Back to Inline Error Messaging Strategies