Forms and Form Styling
|
This section documents general HTML5 and CSS concepts — it is not tied to any specific framework or library. This content was generated with the assistance of AI. Verify it against current MDN documentation and browser-support tables (caniuse.com) before relying on it in production, since HTML/CSS features and browser support continue to evolve. |
Forms are the primary way a web page collects input from a user — sign-up details, search criteria,
checkout information, file uploads, and so on. This page covers the core HTML form elements
(form, input, label, textarea, fieldset/legend, select, button), the attributes that make
inputs usable and accessible (maxlength, placeholder, required), common CSS techniques for making
default form controls presentable (custom text/textarea underlines, custom buttons, a custom select-box
arrow, and :valid/:invalid validation styling), and finally file uploads: the native input type="file"
element, styling a custom file-picker control, and building a drag-and-drop upload zone. See
HTML5 Structure and Semantics for the broader semantic-markup picture that forms fit into.
The form element
Every group of form controls is wrapped in a form element, which needs an action attribute (the URL the
form data is submitted to) and a method attribute (get for small amounts of non-sensitive data, since it
ends up in the URL’s query string, or post for sensitive data or larger payloads):
<form action="url_to_send_form_data" method="post">
<!-- form elements go here -->
</form>
Text-based inputs
The input element is the workhorse of HTML forms. It always needs a type attribute (what kind of
control to render) and a name attribute (the key the value is submitted under). The plain text type looks
like this:
<form action="url_to_send_form_data" method="post">
<div>
First name: <br />
<input type="text" name="firstname" />
</div>
<div>
Last name: <br />
<input type="text" name="lastname" />
</div>
</form>
The maxlength attribute caps how many characters can be typed — handy for things like usernames:
<input type="text" name="username" maxlength="20" />
type="email" renders a text field with built-in validation that checks the value looks like an email
address:
<input type="email" name="email" />
type="password" masks whatever the user types:
<input type="password" name="password" />
Checkboxes and radio buttons
Checkboxes let a user pick any number of options. Each checkbox in a group typically gets its own name
(so each can be submitted independently) and a value identifying that option:
<form action="url_to_send_form_data" method="post">
<div>
<input type="checkbox" name="color1" value="red" /> Red
</div>
<div>
<input type="checkbox" name="color2" value="green" /> Green
</div>
<div>
<input type="checkbox" name="color3" value="blue" /> Blue
</div>
</form>
Radio buttons let a user pick exactly one option out of a set. Unlike checkboxes, every radio button in the
same group shares the same name (that’s what makes them mutually exclusive), while each still needs its
own value:
<form action="url_to_send_form_data" method="post">
<div>
<input type="radio" name="color" value="red" /> Red
</div>
<div>
<input type="radio" name="color" value="green" /> Green
</div>
<div>
<input type="radio" name="color" value="blue" /> Blue
</div>
</form>
Use checkboxes when multiple values can be selected at once (e.g. filters on a search results page), and radio buttons when only one value out of a set makes sense (e.g. a delivery option).
The label element
Text placed next to an input visually associates the two, but a sighted mouse user is the only one who
benefits: a screen reader has no way to connect loose text to the control it describes, and clicking the
text does nothing. The label element fixes both problems. Its for attribute must match the id of the
control it describes, which lets a click (or tap) on the label focus that control:
<form action="url_to_send_form_data" method="post">
<div>
<label for="first_name">First name:</label><br />
<input type="text" name="firstname" id="first_name" />
</div>
<div>
<label for="last_name">Last name:</label><br />
<input type="text" name="lastname" id="last_name" />
</div>
</form>
A label can also simply wrap its control instead of using for/id — both forms are valid, and which
one reads better often depends on whether the label needs to sit before or after the control (see the
checkbox example under Grouping fields with fieldset and legend below).
The textarea element
input type="text" is a single line. When a user needs to enter multiple lines — a comment, a message — use textarea instead, sizing it with the rows and cols attributes:
<div>
<label for="message">Message:</label><br />
<textarea id="message" rows="5" cols="20"></textarea>
</div>
Grouping fields with fieldset and legend
fieldset groups related controls together — typically sections of a larger form, such as personal
details versus delivery details. Pairing it with a legend gives that group a visible, accessible caption,
which helps users (and assistive technology) understand what a chunk of the form is asking for before they
start filling it in:
<form action="url_to_send_form_data" method="post">
<fieldset>
<legend>Favorite web language?</legend>
<div>
<input type="radio" id="html" name="html" />
<label for="html">HTML</label>
</div>
<div>
<input type="radio" id="css" name="css" />
<label for="css">CSS</label>
</div>
</fieldset>
</form>
Note that in this example the label comes after the input — putting the checkbox/radio control first
and its description afterward is a common convention for these two input types.
The select element
select renders a dropdown list, useful when there’s a long set of options and the user should pick just
one (countries, years, and similar enumerations are classic examples). Each choice is an option inside the
select:
<form action="url_to_send_form_data" method="post">
<fieldset>
<label for="countries">Country:</label><br />
<select id="countries">
<option value="england">England</option>
<option value="scotland">Scotland</option>
<option value="ireland">Ireland</option>
<option value="wales">Wales</option>
</select>
</fieldset>
</form>
Buttons
button needs a type attribute with one of three values: "button" (no default behavior — useful when
you wire up your own JavaScript handler), "reset" (clears all form values back to their defaults), or
"submit" (submits the form):
<button type="submit">Submit</button>
input type="button" is an older, equivalent way to render a plain button, with its label set via value
instead of inner text:
<input type="button" value="Submit" />
Styling labels, text inputs, and textareas
Out of the box, form controls look inconsistent across browsers and rarely match a site’s visual design.
A very small amount of CSS goes a long way. A common pattern: add a placeholder attribute so the field
hints at what’s expected, strip the default border off text inputs and textareas and replace it with just a
bottom border, and give the label its own font size:
<form action="url_to_send_form_data" method="post">
<div>
<label for="first_name">First name:</label><br />
<input type="text" name="firstname" id="first_name" placeholder="Your first name" />
</div>
<div>
<label for="message">Message:</label><br />
<textarea id="message" rows="5" cols="20" placeholder="Your message"></textarea>
</div>
</form>
:root {
--border-color: #666;
}
* {
font-family: arial, sans-serif;
}
label {
font-size: 20px;
}
div {
margin-bottom: 30px;
}
input,
textarea {
border: 0;
border-bottom: 1px solid var(--border-color);
padding: 10px 0;
width: 200px;
}
Removing the box-style border and keeping only the bottom rule turns a plain text input into the "underline" style seen across a lot of modern forms, for very little code.
Styling buttons
The default button rendering is plain and inconsistent across browsers, so it is almost always restyled:
a background color, no border, an explicit size, uppercase text, and :hover/:active states for
feedback:
<button type="submit">Submit</button>
:root {
--bg-color: #999;
--bg-active-color: #888;
--text-color: #fff;
}
button {
background: var(--bg-color);
border: 0;
color: var(--text-color);
cursor: pointer;
font-size: 12px;
height: 50px;
width: 200px;
text-transform: uppercase;
}
button:hover {
background: var(--bg-active-color);
}
button:active {
background: var(--bg-color);
}
Styling select boxes
A select box is usually restyled to look like the site’s other text inputs, complete with a custom
dropdown arrow. Browsers apply their own chrome (background, border, box-shadow, and a native arrow) to
select, so the trick is to switch that chrome off with -webkit-appearance: none (plus stripping the
border/box-shadow) and then draw a replacement arrow with a ::after pseudo-element on a wrapping
container:
<div class="select-wrapper">
<select id="countries">
<option value="england">England</option>
<option value="scotland">Scotland</option>
<option value="ireland">Ireland</option>
<option value="wales">Wales</option>
</select>
</div>
:root {
--border-color: #666;
}
select {
background: transparent;
border: 0;
border-radius: 0;
border-bottom: 1px solid var(--border-color);
box-shadow: none;
color: var(--border-color);
padding: 10px 0;
width: 200px;
-webkit-appearance: none;
}
.select-wrapper {
position: relative;
width: 200px;
}
.select-wrapper::after {
content: '<>';
color: var(--border-color);
font-size: 14px;
top: 8px;
right: 0;
transform: rotate(90deg);
position: absolute;
z-index: -1;
}
The ::after content is rotated 90 degrees so the <> characters read as a downward-pointing chevron, and
it is positioned absolutely inside the (position: relative) wrapper so it sits over the right edge of the
select box.
Validation styling with :valid and :invalid
Styling alone isn’t enough for real-world forms — users need feedback when required fields are missing or
malformed. HTML’s required attribute marks a field as mandatory, and the built-in type-specific checks
(type="email", and so on) mark a field as malformed when its value doesn’t match the expected format. CSS
can then react to that native validation state directly, with no JavaScript, via the :valid and :invalid
pseudo-classes:
<label for="first_name">First name:</label><br />
<input type="text" id="first_name" name="firstname" placeholder="Your first name" required />
<label for="email">Email:</label><br />
<input type="email" id="email" name="email" required />
:root {
--valid-color: green;
--invalid-color: red;
}
input:valid,
textarea:valid {
border-bottom-color: var(--valid-color);
}
input:invalid,
textarea:invalid {
border-bottom-color: var(--invalid-color);
}
A field only counts as :invalid once it has constraints that can fail (required, a type with built-in
format checking, pattern, and similar) — an ordinary optional text input is always :valid. Browsers
also block form submission and surface their own validation messages when a required or malformed field is
submitted, on top of whatever CSS feedback you add.
File inputs
input type="file" lets a user pick one or more files from their device to include in the form submission.
Add the multiple attribute to allow selecting more than one file at once, and accept to hint at which
file types the picker should filter for (a hint only — it does not enforce the restriction server-side):
<form action="url_to_send_form_data" method="post" enctype="multipart/form-data">
<div>
<label for="avatar">Profile picture:</label><br />
<input type="file" id="avatar" name="avatar" accept="image/*" />
</div>
<div>
<label for="attachments">Attachments:</label><br />
<input type="file" id="attachments" name="attachments" multiple accept=".pdf,.doc,.docx" />
</div>
</form>
Two details matter here that don’t apply to other input types:
-
The enclosing
formneedsenctype="multipart/form-data"— without it, file contents are not included in the submission, only the file names. -
In JavaScript, the selected files are available as a
FileListon the input’sfilesproperty (e.g.document.getElementById('attachments').files), which behaves like an array ofFileobjects (name,size,type, and so on) even though it isn’t a real array.
Styling a custom file input control
The native file input (the small "Choose File" / "Browse…" button plus a filename readout) is one of the
hardest form controls to restyle directly — browsers give it very little that CSS can hook into. The
standard workaround is to hide the real input visually while keeping it functional, and let a label
associated with it (via for/id) act as the clickable, fully-stylable button — clicking any label
whose for points at a file input opens that input’s file picker, exactly like clicking the label opens the
for-associated checkbox in the earlier fieldset example:
<div class="file-upload">
<input type="file" id="document" name="document" class="file-input" />
<label for="document" class="file-label">Choose file</label>
<span class="file-name">No file selected</span>
</div>
.file-input {
/* Visually hidden, but still focusable and clickable via its label */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.file-label {
display: inline-block;
background: #999;
color: #fff;
padding: 10px 20px;
cursor: pointer;
text-transform: uppercase;
font-size: 12px;
}
.file-label:hover {
background: #888;
}
.file-name {
margin-left: 10px;
color: #666;
font-size: 14px;
}
const fileInput = document.querySelector('.file-input');
const fileNameLabel = document.querySelector('.file-name');
fileInput.addEventListener('change', () => {
const { files } = fileInput;
fileNameLabel.textContent = files.length === 0
? 'No file selected'
: files.length === 1
? files[0].name
: `${files.length} files selected`;
});
Note that display: none on the input would make it unfocusable and unreachable via keyboard, which breaks
accessibility — the visually-hidden approach above (an "off-screen" clip pattern) keeps the input in the
accessibility tree and reachable by tabbing, while showing only the styled label.
Drag-and-drop file uploads
Beyond clicking a button to open a file picker, browsers also let users drag files from their desktop or
file manager straight onto a page. That behavior is built on the HTML Drag and Drop API: an element that
should accept a drop needs to listen for the dragenter, dragover, dragleave, and drop events, and
critically must call event.preventDefault() on dragover (and usually drop) — otherwise the browser’s
default behavior kicks in, which for a dropped file is to navigate away from the page and open the file
directly instead of handing it to your code.
A drop zone is typically paired with a hidden input type="file", so the same code path handles both a
traditional click-to-browse and a drag-and-drop drop, and so the selected files still travel with the form
as multipart/form-data:
<div class="drop-zone" id="drop-zone">
<p>Drag and drop files here, or click to browse</p>
<input type="file" id="file-input" class="file-input" multiple hidden />
</div>
.drop-zone {
border: 2px dashed #999;
border-radius: 4px;
padding: 40px;
text-align: center;
color: #666;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.drop-zone.drag-over {
border-color: #4a90d9;
background: #f0f7ff;
}
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
// Clicking the drop zone falls back to the normal file picker.
dropZone.addEventListener('click', () => fileInput.click());
['dragenter', 'dragover'].forEach((eventName) => {
dropZone.addEventListener(eventName, (e) => {
// Prevent the browser's default action (opening/navigating to the file).
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach((eventName) => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('drag-over');
});
});
dropZone.addEventListener('drop', (e) => {
// DataTransfer carries the dropped payload; .files gives the FileList,
// just like a change event on a plain <input type="file">.
const droppedFiles = e.dataTransfer.files;
fileInput.files = droppedFiles;
// Reuse whatever handling a normal file-picker selection would trigger.
fileInput.dispatchEvent(new Event('change'));
});
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) {
console.log(`Ready to upload: ${file.name} (${file.size} bytes)`);
}
});
A few points worth calling out:
-
dragoverfires continuously while a dragged item is over the element, sopreventDefault()needs to run on everydragoverevent, not justdragenter— omitting it ondragoveris the most common reason a drop zone "doesn’t work" and the browser opens the file instead. -
The
draggableattribute (e.g.<div draggable="true">) is what makes an element on your own page something the user can pick up and drag — it’s unrelated to accepting a drop and is not needed for the file-upload case above, since the files being dragged originate from the operating system, not from a draggable element in the page. -
fileInput.files = droppedFileslets the same hidden input back both the drag-and-drop path and a manual browse, so the rest of the form (validation, a submit handler, an upload-progress UI) only needs to watch onechangeevent.