Multi-column form

A responsive two-column contact form with CSS grid that collapses to one column on phones. Works with any static site.

On a wide screen a single column of full-width inputs wastes space and makes a short form look long. Putting short fields side by side fixes that, as long as the layout falls back to one column on a phone. This page does it with CSS grid in about twenty lines, without a framework or media-query juggling for each field.

The complete page#

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Contact</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      line-height: 1.5;
      max-width: 48rem;
      margin: 3rem auto;
      padding: 0 1rem;
    }
    .form-grid {
      display: grid;
      grid-template-columns: 1fr;
      gap: 1rem 1.5rem;
    }
    @media (min-width: 40rem) {
      .form-grid { grid-template-columns: 1fr 1fr; }
      .full { grid-column: 1 / -1; }
    }
    label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
    input, select, textarea {
      width: 100%;
      box-sizing: border-box;
      padding: 0.6rem 0.75rem;
      border: 1px solid #8a8a8a;
      border-radius: 6px;
      font: inherit;
      background: #fff;
    }
    textarea { min-height: 10rem; resize: vertical; }
    .actions { display: flex; justify-content: flex-end; }
    button {
      padding: 0.65rem 1.5rem;
      border: 0;
      border-radius: 6px;
      background: #2563eb;
      color: #fff;
      font: inherit;
      font-weight: 600;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h1>Start a project</h1>

  <form class="form-grid" action="https://silentforms.com/api/submit" method="POST">
    <input type="hidden" name="accessKey" value="YOUR_ACCESS_KEY_HERE">
    <input type="hidden" name="subject" value="New project enquiry">

    <div>
      <label for="first-name">First name</label>
      <input id="first-name" name="first_name" type="text" autocomplete="given-name" required>
    </div>

    <div>
      <label for="last-name">Last name</label>
      <input id="last-name" name="last_name" type="text" autocomplete="family-name" required>
    </div>

    <div>
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required>
    </div>

    <div>
      <label for="phone">Phone</label>
      <input id="phone" name="phone" type="tel" autocomplete="tel">
    </div>

    <div>
      <label for="company">Company</label>
      <input id="company" name="company" type="text" autocomplete="organization">
    </div>

    <div>
      <label for="budget">Budget</label>
      <select id="budget" name="budget">
        <option value="">Choose one</option>
        <option>Under 5k</option>
        <option>5k to 20k</option>
        <option>Over 20k</option>
      </select>
    </div>

    <div class="full">
      <label for="message">Tell us about the project</label>
      <textarea id="message" name="message" required></textarea>
    </div>

    <input type="checkbox" name="botcheck" style="display: none;" tabindex="-1" autocomplete="off">

    <div class="full actions">
      <button type="submit">Send enquiry</button>
    </div>
  </form>
</body>
</html>

How the layout works#

One column by default. grid-template-columns: 1fr puts every field on its own row. That is the phone layout, and it is what the browser uses unless the screen is wide enough. Writing the small-screen layout first means nothing breaks on a device you did not test.

Two columns from 40rem up. Inside the min-width media query the grid gets two equal columns, and fields flow into them left to right, two per row. Using rem rather than pixels means the breakpoint moves with the visitor's font size: someone who has enlarged their text gets the one-column layout sooner, which is what they need.

.full spans the whole row. grid-column: 1 / -1 runs from the first grid line to the last, so the message box and the button row always take the full width. It only applies inside the media query, because on one column everything is already full width.

Each field is one grid item. The label and input are wrapped together in a div, so they move as a unit and a label can never end up in a different column from its input.

Hidden inputs take no space. Grid ignores input type="hidden", and the honeypot is display: none, so neither creates an empty cell. If you hide the honeypot another way, such as positioning it off screen, it becomes a grid item and leaves a gap.

gap sets 1rem between rows and 1.5rem between columns, so there is no margin arithmetic on individual fields.

Tab order and reading order#

Keyboard users tab through fields in source order, and screen readers read them in that order too. Grid can visually reorder items with properties like order or grid-row, but avoid it here: if the visual order and the source order differ, people tabbing through the form see focus jump around. Keep the source order the order you want fields filled in, and let the grid place them.

Pair fields that belong together, such as first and last name, so they sit on the same row. Fields that need more room, such as a message or an address, should span the full width.

What you receive#

Each name becomes a row in the email, in source order: first_name, last_name, email, phone, company, budget, message. The subject field sets the email's subject line and is not shown as a row. Because the email field is named email, pressing Reply in your mail app answers the visitor.

Testing#

Open the page and drag the browser window narrower, or use the device toolbar in developer tools, and check that the layout switches to one column below the breakpoint. Tab through the whole form once to confirm the focus order, then send a test submission.

Next steps#