Web Forms 2 – Styling Web Forms
We have analyzed all the HTML elements we need to create and structure web forms. Now, although you haven't studied CSS yet, we will move on to analyzing how to use CSS code to style form controls. I recommend revisiting these articles later, after you have studied CSS, to better understand the information presented. Styling form controls was not a very simple task, but it is becoming increasingly easier as old browsers are phased out and modern browsers offer us more features to use.
Not all widgets are equal when it comes to styling them with CSS.
Currently, there are still some issues and, depending on these issues, three categories can be identified:
Relatively easy to style.
These elements can be styled without major difficulties. They include the following elements:
- <form>,
- <fieldset> and <legend>
- everything that means a single line of text created with <input> (type text, url, email, etc.), including <input type="search">,
- <textarea>
- Buttons created with <input> and <button>,
- <label>
- <output>
Relatively hard to style.
Some elements are harder to style, requiring more complex CSS code or a few specific tricks:
- checkboxes and radio buttons,
- default elements like <select>, which require custom solutions for full style control,
Very hard to style.
These elements cannot be fully styled using CSS:
- <input type="color">,
- date-related controls like <input type="datetime-local">,
- <input type="range">,
- <input type="file">,
- Elements involved in creating dropdown widgets, including <select>, <option>, <optgroup>, and <datalist>
- <progress> and <meter>
We will discuss what can be done regarding the styling of these elements in "Advanced Methods for Styling Web Forms".
The real issue with all these controls is that they have a very complex structure, and beyond some basic styles (such as changing the width or margin of the control), in general, we cannot style the internal components of the controls that make up these widgets.
If you still want to further customize these widgets, you need to create your own widgets from scratch using HTML, CSS, and JavaScript.
☞ There are some available CSS pseudo-elements that allow you to style the internal components of these form controls, such as ::-moz-range-track or ::-webkit-slider-thumb, but these are not supported by all browsers, so they cannot be reliably used.
Relatively Easy to Style Elements
A few details about specific aspects of form styling that are worth mentioning.
Font and Text
CSS font and text properties can be easily used on any widget (you can even use @font-face). However, browser behavior is often inconsistent. By default, some widgets do not inherit the font family and font size from their parents. Many browsers instead use the system's default appearance. To make the forms' appearance consistent with the rest of the content, we can add the following rules to the stylesheet:
button, input, select, textarea {
font-family: inherit;
font-size: 100%;
}
There are many debates about whether forms look better using the system's default styles or using custom styles designed to match the content. This decision is yours to make, as the site's designer.
Box Sizing
All text fields have full support for every property related to the CSS box model, such as width, height, padding, margin, and border (width, height, padding, margin, and border). As before, browsers rely on the system's default styles when displaying these widgets. It's up to you to define how you want to combine them in your content. If you want to keep the native appearance of the widgets, you'll encounter a small challenge if you want to give them a consistent size.
This is because each widget has its own rules for border, padding, and margin. To give the same size to multiple different widgets, you can use the box-sizing property along with some consistent values for other properties:
button, input, select, textarea {
width: 150px;
padding: 0;
margin: 0;
box-sizing: border-box;
}
Legend Placement
The legend element is easy to style, but it can be a bit tricky to control its placement. By default, it is always positioned above the top border of its parent fieldset, near the top-left corner. To position it elsewhere—for example, somewhere inside the field or in the bottom-left corner—you need to rely on positioning.
Let's look at the following examples:
To position the legend this way, we used the following CSS lines:
fieldset {
position: relative;
}
legend {
position: absolute;
bottom: 0;
right: 0;
}
Additionally, the fieldset must be positioned so that the legend is positioned relative to it (otherwise, the legend would be positioned relative to the body element).
The legend element is very important for accessibility—it is announced by assistive technologies as part of the label for each form element within the fieldset. However, using a technique like the one above is perfectly fine. The content of the legend will be announced in the same way; the change is purely visual.
Advanced Methods for Styling Web Forms
In this article, we'll explore what can be done with CSS to style form controls that are typically harder to customize.
Let's start by discussing the CSS appearance property, which is quite useful for making hard-to-style elements more stylable.
Styling Control at the Operating System Level
The appearance property was created as a way to control how operating systems affect the styling of web form controls. Unfortunately, the behavior of early implementations of this property varied greatly across browsers, which wasn't very helpful. Newer implementations are more consistent—Chromium-based browsers (Chrome, Opera, Edge), Safari, and Firefox all support the prefixed version -webkit-. Firefox addressed this because web developers mostly seemed to use the -webkit- prefix, so it was better for compatibility.
If you check the reference page on MDN Web Docs, you'll find a long list of possible values for -webkit-appearance. However, by far the most useful value—and probably the only one you'll actually use—is none. This disables the operating system's styling for the control as much as possible, allowing you to create your own custom styles using CSS.
For example, let's take the following controls:
<form>
<p>
<label for="search">search: </label>
<input id="search" name="search" type="search">
</p>
<p>
<label for="text">text: </label>
<input id="text" name="text" type="text">
</p>
<p>
<label for="date">date: </label>
<input id="date" name="date" type="datetime-local">
</p>
<p>
<label for="radio">radio: </label>
<input id="radio" name="radio" type="radio">
</p>
<p>
<label for="checkbox">checkbox: </label>
<input id="checkbox" name="checkbox" type="checkbox">
</p>
<p><input type="submit" value="submit"></p>
<p><input type="button" value="button"></p>
</form>
Applying the following CSS lines removes the operating system-level styling:
input {
-webkit-appearance: none;
appearance: none;
}
☞ It's a good idea to always include both prefixed and unprefixed declarations when using a prefixed property. Prefixed usually means “work in progress,” so in the future, browser vendors may agree to drop the prefix altogether.
The following example shows how the controls are rendered by your system—default on the left and with the CSS rule applied on the right:
In most cases, the effect is to remove the styled border, which makes CSS styling a bit easier to apply, but it's not essential. In a few cases—such as search, radio button, and checkbox—it becomes much more useful.
Modifying Search Boxes
<input type="search"> is essentially just a text input, so why is appearance: none; useful in this case? The answer is that in Chromium-based browsers, search boxes have certain styling restrictions—for example, you can't adjust their height.
This issue can be resolved using appearance: none;:
input[type="search"] {
-webkit-appearance: none;
appearance: none;
}
In the example below, you can see two search boxes with identical styling. The one on the right has appearance: none; applied, while the one on the left does not. If you view the page in Chrome, you'll notice that the left one is not properly sized.
Interestingly, setting the border/background on the search field seems to also resolve this issue. The next search input does not have appearance: none; applied, but it does not suffer from the same problem in Chrome as the previous example.
Styling checkboxes and radio buttons
Styling a checkbox or a radio button is inherently complicated. The dimensions of checkboxes and radio buttons are not meant to be changed using their default styles, and browsers react very differently when you try.
Let's look at the following simple example:
<span><input type="checkbox"></span>
span {
display: inline-block;
background: red;
}
input[type="checkbox"] {
width: 100px;
height: 100px;
}
Different browsers display things differently:
appearance: none for checkboxes and radio buttons
Let's start with an example:
<form>
<fieldset>
<legend>Favorite fruits</legend>
<p>
<label>
<input type="checkbox" name="fruit-1" value="cherry">
I like cherries
</label>
</p>
<p>
<label>
<input type="checkbox" name="fruit-2" value="banana" disabled>
I don't like bananas
</label>
</p>
<p>
<label>
<input type="checkbox" name="fruit-3" value="strawberry">
I like strawberries
</label>
</p>
</fieldset>
</form>
Now, let's apply a custom design to the checkbox. We'll start by removing the default styling:
input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
}
We can use the :checked and :disabled pseudo-classes to change the appearance of the checkbox, since its state changes:
input[type="checkbox"] {
position: relative;
width: 1em;
height: 1em;
border: 1px solid gray;
/* Adjusting the checkbox position relative to the text */
vertical-align: -2px;
color: green;
}
input[type="checkbox"]::before {
content: "✔";
position: absolute;
font-size: 1.2em;
right: -1px;
top: -0.3em;
visibility: hidden;
}
input[type="checkbox"]:checked::before {
/* Using "visibility" instead of "display" to avoid layout recalculation */
visibility: visible;
}
input[type="checkbox"]:disabled {
border-color: black;
background: #ddd;
color: gray;
}
The pseudo-classes in the example above serve the following purposes:
- :checked – the checkbox (or radio button) is in a checked state – the user has clicked or activated it.
- :disabled – the checkbox (or radio button) is in a disabled state – it cannot be interacted with.
I've also created a few examples to give you more ideas:
If you see these checkboxes in a browser that doesn't support appearance, the custom design will be lost, but they will still look like checkboxes and remain usable.
What can we do with hard-to-style elements?
Now, let's turn our attention to the "ugly" controls — the ones that are truly difficult to style. In short, these include dropdown boxes, complex input types such as color, datetime-local, and feedback-oriented controls like progress and meter.
The issue is that these elements have very different default appearances across browsers and can be styled to a certain extent, but some of their internal components are literally impossible to style.
The following example demonstrates a range of characteristics of these unappealing controls in action:
You can open the page above in different browsers to observe rendering differences, and you can also view the page's source code to analyze the CSS used.
As you may have noticed, the controls in the example above look quite good and behave fairly consistently across modern browsers. We've applied some global CSS normalization properties to all controls and their labels to bring them to similar dimensions and to inherit the parent font.
button, label, input, select, progress, meter {
display: block;
font-family: inherit;
font-size: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
width: 100%;
padding: 5px;
height: 30px;
}
We also added some shadows and rounded corners to the controls where it made sense to do so:
input[type="text"], input[type="datetime-local"], input[type="color"], select {
box-shadow: inset 1px 1px 3px #ccc;
border-radius: 5px;
}
Now let's talk about some specific characteristics of each of these types of controls, highlighting the difficulties that may arise.
Select and datalist
Two things are somewhat problematic. First, the "arrow" icon that indicates a dropdown differs between browsers. It also tends to change if you increase the size of the select box or resize it. To fix this in our example, we first used our old friend appearance: none to remove the icon entirely:
select {
-webkit-appearance: none;
appearance: none;
}
We then created our own icon using generated content. We wrapped the control in an extra layer because ::before / ::after do not work on select elements (since generated content is positioned relative to the formatting box of an element):
<div class="select-wrapper">
<select id="select" name="select">
<option>Banana</option>
<option>Cherry</option>
<option>Lemon</option>
</select>
</div>
We then used generated content to create a downward arrow and positioned it correctly using CSS positioning:
.select-wrapper {
position: relative;
}
.select-wrapper::after {
content: "▼";
font-size: 1rem;
top: 6px;
right: 10px;
position: absolute;
}
The second issue, which is somewhat more significant, is that you have no control over the dropdown box that appears and contains the options when you click the select element to open it. You'll notice that the options do not inherit the font set on the parent. Additionally, you cannot consistently set things like spacing and colors. For example, Firefox will apply color and background color when set on option elements, but Chrome will not. None of the browsers will apply any kind of spacing (such as padding). The same applies to the autocomplete list that appears for a datalist.
If you truly need full control over the styling, you'll either need to use a library to generate a custom control, build your own custom control, or — in the case of select — use the multiple attribute, which causes all options to appear directly on the page, avoiding this issue altogether:
<select id="select" name="select" multiple>
...
</select>
Date Input Types
Date/time input types (datetime-local, time, week, month) share the same core issue. The input box itself is just as easy to style as any regular text input, and what we have in the example above looks good.
However, the internal components of the control (for example, the pop-up calendar used to select a date, or the spinner used to increase/decrease values) are not stylable at all, and you can't remove them using appearance: none;. If you truly need full control over the styling, you'll either need to use a library to generate a custom control, or build your own widget from scratch.
Range Input Type
<input type="range"> is notoriously difficult to style. You can use something like the following to completely remove the default slider track and replace it with a custom style (a thin red line, in this case):
input[type="range"] {
appearance: none;
-webkit-appearance: none;
background: red;
height: 2px;
padding: 0;
outline: 1px solid transparent;
}
Color Input Type
Color input controls aren't too bad. In most browsers, they tend to offer just a solid color block with a small border. You can remove the border and leave only the color block by using something like this:
input[type="color"] {
border: 0;
padding: 0;
}
File Input Type
The only issue with the file picker control is that the button you click to open the file selector is completely unstyleable — it cannot be resized or recolored, and it won't accept a different font.
One workaround is to take advantage of the fact that if you have a label associated with a form control, clicking the label will activate the control. So, you can completely hide the input using something like this:
input[type="file"] {
height: 0;
padding: 0;
opacity: 0;
}
And then we style the label to act as a button, which, when clicked, will open the file picker as expected:
label[for="file"] {
box-shadow: 1px 1px 3px #ccc;
background: linear-gradient(to bottom, #eee, #ccc);
border: 1px solid rgb(169, 169, 169);
border-radius: 5px;
text-align: center;
line-height: 1.5;
}
label[for="file"]:hover {
background: linear-gradient(to bottom, #fff, #ddd);
}
label[for="file"]:active {
box-shadow: inset 1px 1px 3px #ccc;
}
Counters and Progress Bars
<meter> and <progress> are probably the most frustrating. As you saw in the previous example, we can set their width fairly precisely. But beyond that, they are truly difficult to style in any meaningful way. They don't consistently support height settings across browsers, you can color the background, but not the foreground bar, and our friend appearance: none actually makes things worse.
It's often easier to create your own custom solution for these features if you want full control over styling, or to use a JavaScript script like progressbar.js
Useful Libraries and Polyfills
A polyfill, or polyfiller, is a piece of code (or plugin) that provides the technology you, as a developer, expect the browser to support natively.
As mentioned above, if you want full control over the “ugly” control types, you'll have no choice but to rely on JavaScript.
Here are some very useful libraries that can help:
- Uni-form – a framework that standardizes form markup and styles it with CSS. It also offers some additional features when used with jQuery, though it's optional.
- Formalize – an extension to common JavaScript frameworks (like jQuery, Dojo, YUI, etc.) that helps normalize and customize forms.
- jQuery UI – provides customizable widgets, such as date pickers, with special attention to accessibility.
- Twitter Bootstrap – can help you normalize your forms.
- WebShim – a powerful tool that helps with HTML5 browser support. Its form-related features can be especially useful.
UI Pseudo-classes
As mentioned earlier, it's not strictly necessary to go through this article right now. You can come back to it after you've studied and gained a solid understanding of CSS.
In this article, we'll explore in detail the various UI pseudo-classes available to us in modern browsers for styling form elements in different states.
What pseudo-classes are available?
The pseudo-classes available to us and relevant for forms include:
- :hover – targets an element only when the mouse pointer is hovering over it.
- :focus – targets an element only when it is focused (e.g., selected using the TAB key).
- :active – targets an element only while it is being activated (e.g., during a mouse click or when pressing ENTER via keyboard).
- :required and :optional – target required or optional form controls.
- :valid / :invalid and :in-range / :out-of-range – target form controls that are valid/invalid based on validation constraints, or within/outside a specified range.
- :enabled / :disabled and :read-only / :read-write – target enabled or disabled form controls (e.g., with the HTML disabled attribute), and read-write or read-only controls (e.g., with the readonly attribute).
- :checked, :indeterminate and :default – target checkboxes and radio buttons that are checked, in an indeterminate state (neither checked nor unchecked),
and the default option selected when the page loads (e.g., an
<input type="checkbox">with the checked attribute, or an option element with the selected attribute).
There are many others, of course, but the ones listed above are clearly the most useful and have fairly good browser support. Naturally, you should carefully test your form implementations to ensure they work for your target audience.
Styling Inputs Based on Required Data Entry
One of the most basic concepts in client-side form validation is the requirement to enter data before the form can be successfully submitted.
The <input>, <select> and <textarea> elements have a required attribute which, when set, means that the corresponding control must be filled out before the form can be submitted successfully. For example:
<form>
<fieldset>
<legend>Contact</legend>
<div>
<label for="fname">First Name: </label>
<input id="fname" name="fname" type="text" required>
</div>
<div>
<label for="lname">Last Name: </label>
<input id="lname" name="lname" type="text" required>
</div>
<div>
<label for="email">Email address (include it if you want a reply): </label>
<input id="email" name="email" type="email">
</div>
<div><button>Send</button></div>
</fieldset>
</form>
First and last name are required (:required), but the email address is optional (:optional).
You can target these two states using the :required and :optional pseudo-classes. For example, if we apply the following CSS lines to the HTML above:
input:required {
border: 2px solid red;
}
input:optional {
border: 2px solid green;
}
we get the following result:
You can try submitting the form without filling it out to see the default error message displayed by the browser when validating data on the client side.
The form above is not bad, but it's not great either. For starters, we indicate what is required and what is optional using only color, which is not helpful for visually impaired users. Secondly, the standard web convention for required fields is an asterisk (*) or the word “required” associated with the relevant controls. In the next section, we'll look at a better example of indicating required fields using :required, and we'll do this by using generated content.
☞ You probably won't use the :optional pseudo-class very often. Form controls are optional by default, so you can simply add styles for the required controls.
If a radio button in a group of buttons with the same name has the required attribute, all radio buttons will be invalid until one is selected, but only the one with the attribute set will actually match :required.
Using generated content with pseudo-classes
In previous articles, we've seen the use of generated content, but now would be a good time to talk about it in a bit more detail.
The idea is that we can use the ::before and ::after pseudo-elements together with the content property to make a piece of content appear before or after the affected element. The content part is not added to the DOM, so it's invisible to screen readers—it's part of the styling applied to the document. Because it's a pseudo-element, it can be styled in the same way we target any real node in the DOM.
This is especially useful when you want to add a visual indicator to an element, such as a label or an icon, but you don't want it to be detected by assistive technologies. In our example of custom radio buttons, we use generated content to manage the placement and animation of the inner circle when a radio button is selected:
input[type="radio"]::before {
display: block;
content: " ";
width: 10px;
height: 10px;
border-radius: 6px;
background-color: red;
font-size: 1.2em;
transform: translate(3px, 3px) scale(0);
transform-origin: center;
transition: all 0.3s ease-in;
}
input[type="radio"]:checked::before {
transform: translate(3px, 3px) scale(1);
transition: all 0.3s cubic-bezier(0.25, 0.25, 0.56, 2);
}
This is truly useful — assistive technologies already notify their users when a radio button or checkbox is selected/checked, so we don't want them to read another DOM element indicating the selection, to avoid confusion. The presence of a purely visual indicator solves this issue.
☞ This also shows how you can combine a pseudo-class and a pseudo-element, if needed.
Back to our earlier required/optional example, this time we won't change the appearance of the input itself — we'll use generated content to add an indicator label.
First, we'll add a paragraph at the top of the form:
<p>Required fields are labeled "required".</p>
Screen reader users will hear "required" as an extra bit of information when they reach each required input, and sighted users will be able to see the label.
Since form inputs don't directly support generated content applied to them (this is because the content is positioned relative to an element's formatting box, but form inputs behave like replaced elements, so they don't have their own box), we'll add an empty <span> element to apply the generated content to:
<div>
<label for="fname">Name: </label>
<input id="fname" name="fname" type="text" required>
<span></span>
</div>
The immediate issue that arises is that the span element drops to a new line below the input, because both the input and the label are set to width: 100%. To fix this, we turn the parent div into a flex container, instructing it to wrap the content onto a new line if it becomes too long:
fieldset > div {
margin-bottom: 20px;
display: flex;
flex-flow: row wrap;
}
The result is that the label and the input are placed on separate lines, because both have a width of 100%, but the span has a width of 0, allowing it to stay on the same line as the input.
Now we create the generated content using the following CSS lines:
input + span {
position: relative;
}
input:required + span::after {
font-size: 0.7rem;
position: absolute;
content: "obligatoriu";
color: white;
background-color: black;
padding: 5px 10px;
top: -26px;
left: -70px;
}
We set the span to position: relative; only so that we can set the generated content to position: absolute;, allowing it to be positioned relative to the span and not to the body (generated content behaves as if it were a child node of the element on which it is generated, for positioning purposes).
Then, we give the generated content the text "required", and style and position it as desired. The result is shown below:
Styling controls based on the validity of entered data
An important, fundamental concept in form validation refers to the validity of the data entered into a form control. Form controls with constraint limitations can be targeted based on these states:
:valid or :invalid
There are a few things to keep in mind:
- Controls without validation constraints will always be valid.
- Controls that have the required attribute set and no value are considered invalid.
- Controls with built-in implicit validation, such as <input type="email"> or <input type="url">, are invalid when the entered data doesn't match the expected pattern (but are valid when empty).
- Controls whose current value falls outside the range limits (:in-range) specified by the min and max attributes are considered invalid, but are valid for :out-of-range, as you'll see later.
- There are a few other ways to match an element's state with :valid / :invalid, as you'll see in the article on client-side form validation, but we'll keep things simple for now.
Let's take a look at a simple example of using :valid / :invalid (see the source code):
Just like in the previous example, we have an extra span element to generate content that we'll use to provide indicators (icons) for valid/invalid data:
<div>
<label for="fname">Name : </label>
<input id="fname" name="fname" type="text" required>
<span></span>
</div>
To provide these indicators, we use the following lines of CSS code:
input + span {
position: relative;
}
input + span::before {
position: absolute;
right: -20px;
top: 5px;
}
input:invalid {
border: 2px solid red;
}
input:invalid + span::before {
content: '✖';
color: red;
}
input:valid + span::before {
content: '✓';
color: green;
}
As before, we set the span to position: relative so that we can position the generated content relative to the inputs. Then we absolutely position the generated content based on the validity of the data—a green checkmark for valid data, and a red “x” for invalid data. To further highlight invalid data, we've added a thick red border to inputs when the data is invalid.
Notice how required text inputs are invalid when empty, but valid once some text is entered. The email input, on the other hand, is valid when empty because it doesn't have the required attribute set, but becomes invalid when it contains text that isn't correctly formatted as a valid email address.
In-range and out-of-range data
As shown above, there are two other related pseudo-classes to consider: :in-range and :out-of-range. These are used with numeric inputs where the range limits are specified by min and max, depending on whether the data falls inside or outside the specified range.
☞ The types of numeric inputs are: date, month, week, time, datetime-local, number, and range.
It's worth noting that inputs with in-range data will also match the :valid pseudo-class, while inputs with out-of-range data will match the :invalid pseudo-class. So why do we have both? It's a matter of semantics—out-of-range is a more specific type of invalid message, so you may want to provide users with a different message for out-of-range inputs, which can be more helpful than a generic “invalid” message. You might also want to use both.
Let's look at an example that does exactly that. Our demonstration builds on the previous example and provides messages for numeric inputs that are out of range, while also indicating whether they are required.
The numeric input looks like this:
<div>
<label for="age">Age (min 12 years): </label>
<input id="age" name="age" type="number" min="12" max="120" required>
<span></span>
</div>
And the CSS code:
input + span {
position: relative;
}
input + span::after {
font-size: 0.7rem;
position: absolute;
padding: 5px 10px;
top: -26px;
}
input:required + span::after {
color: white;
background-color: black;
content: "obligatoriu";
left: -70px;
}
input:out-of-range + span::after {
color: white;
background-color: red;
width: 155px;
content: "Outside the accepted range";
left: -182px;
}
This story is similar to the one we had earlier in the required example, except that here we've split the declarations applied to the ::after content into separate rules—one for :required and another for :out-of-range. You can try the live version here:
What happens if, for a numeric input, we use both required and out-of-range at the same time? Since the out-of-range rule appears later in the source code than required, CSS cascade rules come into play, and the message displayed is the one generated by out-of-range.
This works quite well—when the page first loads, “required” is displayed along with a red X and border. When you enter a valid age (e.g., within the range 12–120), the input becomes valid. If you then change the age input to an invalid value, the message “Out of accepted range” appears instead of the “required” message.
Styling enabled / disabled and read-only / read-write inputs
An enabled element is one that can be activated, selected, clicked, have data entered, etc. On the other hand, a disabled element cannot be interacted with in any way, and its data isn't even sent to the server.
These two states can be targeted using: :enabled and :disabled. Why are disabled inputs useful? Well, sometimes, if certain data doesn't apply to a specific user, you may not want to send it at all when the user submits the form. A classic example is a shipping form—you're usually asked if you want to use the same address for billing and shipping. If yes, you can send only one address to the server, and the billing address fields can be disabled.
Let's look at an example that does exactly this. The HTML markup is simple, containing text inputs plus a checkbox to enable/disable billing addresses. The billing address fields are disabled by default.
<form>
<fieldset id="shipping">
<legend>Shipping Address</legend>
<div>
<label for="name1">Name: </label>
<input id="name1" name="name1" type="text" required>
</div>
<div>
<label for="address1">Address: </label>
<input id="address1" name="address1" type="text" required>
</div>
<div>
<label for="pcode1">Postal Code: </label>
<input id="pcode1" name="pcode1" type="text" required>
</div>
</fieldset>
<fieldset id="billing">
<legend>Billing Address</legend>
<div>
<label for="billing-checkbox">Same as shipping address:</label>
<input type="checkbox" id="billing-checkbox" checked>
</div>
<div>
<label for="name" class="billing-label disabled-label">Name: </label>
<input id="name" name="name" type="text" disabled required>
</div>
<div>
<label for="address2" class="billing-label disabled-label">Address: </label>
<input id="address2" name="address2" type="text" disabled required>
</div>
<div>
<label for="pcode2" class="billing-label disabled-label">Postal Code: </label>
<input id="pcode2" name="pcode2" type="text" disabled required>
</div>
</fieldset>
<div><button>Submit</button></div>
</form>
The most relevant parts of the CSS code are as follows:
input[type="text"]:disabled {
background: #eee;
border: 1px solid #ccc;
}
.disabled-label {
color: #aaa;
}
We directly selected the inputs we wanted to disable using input[type="text"]:disabled, but we also wanted to hide the corresponding text labels. These weren't as easy to select, so we used a class to style them.
Finally, we used some JavaScript to disable the billing address fields:
// Wait for the page to fully load
document.addEventListener('DOMContentLoaded', function () {
// Attach the 'change' event to the checkbox
document.getElementById('billing-checkbox').addEventListener('change', toggleBilling);
}, false);
function toggleBilling() {
// Select billing fields
let billingItems = document.querySelectorAll('#billing input[type="text"]');
// Select billing field labels
let billingLabels = document.querySelectorAll('.billing-label');
// Toggle billing fields and corresponding labels
for (let i = 0; i < billingItems.length; i++) {
billingItems[i].disabled = !billingItems[i].disabled;
if (billingLabels[i].getAttribute('class') === 'billing-label disabled-label') {
billingLabels[i].setAttribute('class', 'billing-label');
} else {
billingLabels[i].setAttribute('class', 'billing-label disabled-label');
}
}
}
Styling read-only and read-write inputs
Similar to :enabled and :disabled, the read-only and read-write pseudo-classes target two states that inputs can switch between. Read-only inputs have their values sent to the server, but the user cannot edit them, while read-write means they can be edited — which is their default state.
An input is set to read-only using the readonly attribute. As an example, imagine a confirmation page where the developer has forwarded the details filled out on previous pages, with the goal of having the user review everything in one place, possibly add any final necessary data, and confirm the order by submitting. At this point, all final form data can be sent to the server at once.
One of the HTML fragments we're interested in is the one below — note the readonly attribute:
<div>
<label for="name">Name: </label>
<input id="name" name="name" type="text" value="Mr Soft" readonly>
</div>
If you try the live example above, you'll notice that the top set of form elements cannot be focused, yet their values are still submitted when the form is sent. We also styled the read-only form controls using the :read-only pseudo-class (with the -moz prefix for Firefox), as shown below:
input:-moz-read-only,
textarea:-moz-read-only {
border: 0;
box-shadow: none;
resize: none;
background-color: white;
}
input:read-only,
textarea:read-only {
border: 0;
box-shadow: none;
resize: none;
background-color: white;
}
☞ :enabled and :read-write are two more pseudo-classes that you'll probably use very rarely, considering they represent the default states of input elements.
Styling radio button and checkbox states
As we saw earlier, radio buttons and checkboxes can be checked or unchecked. However, there are a few other states to consider:
- :default – applies to radio buttons and checkboxes that are checked by default when the page loads (i.e., by setting the checked attribute on them). These match the :default pseudo-class even if the user unchecks them.
- :indeterminate – radio buttons and checkboxes that are neither checked nor unchecked are considered indeterminate and will match the :indeterminate pseudo-class.
:checked
The most common use is to apply a different style to the checkbox or radio button when it is checked, especially in cases where you've removed the system's default styling using appearance: none; and built a custom design. We've seen such examples in this article and the previous one.
You can revisit the earlier example here:
input[type="radio"]::before {
display: block;
content: " ";
width: 10px;
height: 10px;
border-radius: 6px;
background-color: red;
font-size: 1.2em;
transform: translate(3px, 3px) scale(0);
transform-origin: center;
transition: all 0.3s ease-in;
}
input[type="radio"]:checked::before {
transform: translate(3px, 3px) scale(1);
transition: all 0.3s cubic-bezier(0.25, 0.25, 0.56, 2);
}
Basically, we build the style for the inner circle of the radio button using the ::before pseudo-element and apply a scale(0) transformation to it. We then use a transition to create a smooth animation when the radio button is selected/checked. The advantage of using a transform rather than a width/height transition is that we can use transform-origin to make the circle grow from its center rather than from a corner.
:default and :indeterminate
As mentioned above, the :default pseudo-class matches radio buttons and checkboxes that are checked by default when the page loads, even if the user unchecks them. This can be useful for adding an indicator to a list of options to remind the user which values were default (or starting options), in case they want to reset their choices.
We also mentioned earlier that radio buttons and checkboxes will match the :indeterminate pseudo-class when they are in a state that is neither checked nor unchecked. But what does that mean? Elements that are :indeterminate include:
- <input/radio> – when all radio buttons in a group with the same name are unchecked,
- <input/checkbox> – inputs whose :indeterminate property is set to true via JavaScript,
- <progress> – elements that have no value.
This isn't something you'll use very often. One use case might be an indicator that tells users they need to actually select a radio button before proceeding.
Let's look at a few modified versions of the previous example that remind the user what the default option was and style the radio buttons when they are :indeterminate. Both use the following HTML structure for inputs:
<p>
<input type="radio" name="fruit" value="cherry" id="cherry">
<label for="cherry">Cherries</label>
<span></span>
</p>
For the :default example, we added the checked attribute to the middle radio button input, so that it will be selected by default on page load. We style this using the following CSS code:
input ~ span {
position: relative;
}
input:default ~ span::after {
font-size: 0.7rem;
position: absolute;
content: "Implicit";
color: white;
background-color: black;
padding: 5px 10px;
right: -65px;
top: -3px;
}
This provides a small "Default" label for the button initially selected when the page loads. Note that here we use the general sibling combinator (~) rather than the adjacent sibling combinator (+) — we need to do this because the span does not immediately follow the input in the source order.
For the :indeterminate example, we don't have any radio button selected by default — this is important — if one were selected, there would be no indeterminate state to style. We style the :indeterminate radio buttons using the following CSS code:
input[type="radio"]:indeterminate {
border: 2px solid red;
animation: 0.4s linear infinite alternate border-pulse;
}
@keyframes border-pulse {
from {
border: 2px solid red;
}
to {
border: 6px solid red;
}
}
This creates a small animation on the radio buttons, indicating that you need to select one of them.
See the result below:
More pseudo-classes
There are several other interesting pseudo-classes that we'll just mention here, but will use in future examples:
- :focus-within – matches an element that has received focus or contains an element that has received focus. This is useful if you want an entire form to be highlighted in some way when an input inside it is focused.
- :focus-visible – matches focused elements that were focused via keyboard interaction (rather than touch or mouse click) — useful if you want to show a different style for keyboard focus compared to mouse or other types of focus.
- :placeholder-shown – matches input and textarea elements that are displaying their placeholder (i.e., the content of the placeholder attribute), because the element's value is empty.
Client-side form validation
Before sending data to the server, it's important to ensure that all required form controls are filled out in the correct format. This process is called client-side form validation and helps verify the submitted data so that it meets the requirements defined in the various form controls. In this article, we discuss basic concepts used in client-side form validation.
Client-side validation is an initial check and an important feature for a good user experience. Catching invalid data entered by the user gives them the opportunity to fix things immediately. If the data reaches the server and is rejected, there's a delay caused by the round trip to the server and back to the client, which then informs the user that the data is invalid and needs correction.
However, client-side validation should not be considered a complete security measure! Your applications should always perform security checks on any data submitted by the form both on the server side and the client side, because client-side validation is too easy to disable, allowing malicious users to easily send harmful data to the server.
What is form validation?
Visit any site with a registration form and you'll notice that it provides feedback when you don't enter data in the required format.
You'll receive messages like:
- "This field is required" (You can't leave this field empty);
- "Please enter your phone number in the format xxx-xxx-xxx" (A specific data format is required for it to be considered valid);
- "Please enter a valid email address" (The entered data is not in the correct format);
- "Your password must be between 8 and 30 characters and contain an uppercase letter, a symbol, and a number."
This is called form validation. When you enter data, the browser and/or the web server will check whether the data is in the correct format and within the limits set by the application. Validation performed in the browser is called client-side validation, while validation performed on the server is called server-side validation. From here on, we focus on client-side validation.
If the information is correctly formatted, the application allows the data to be sent to the server and usually saves it in a database. If the information is not correctly formatted, it provides the user with an error message explaining what needs to be corrected and allows them to try again.
We need to make web form completion as easy as possible. So, why do we insist on validating our forms? There are three main reasons:
- We want to get the correct data in the right format. Our applications won't work properly if our users' data is stored in the wrong format, is incorrect, or is missing entirely.
- We want to protect our users' data. Forcing our users to enter secure passwords helps protect information about their accounts.
- We want to protect our own applications. There are many ways malicious users can misuse unprotected forms to harm the application.
☞ Warning. Never trust data sent to the server by users. Even if your form is properly validated and prevents malformed data from being entered by the client, a malicious user can still modify the network request.
Different types of client-side validation
There are two different types of client-side validation you'll encounter on the web:
- Built-in validation uses HTML5 form validation features, which we've discussed in many places throughout this module. This type of validation generally doesn't require much JavaScript. Built-in validation performs better than JavaScript, but it's not as customizable as JavaScript validation.
- JavaScript validation is done using the JavaScript language. This type of validation is fully customizable, but it must be created entirely from scratch or you can use a library.
Using built-in validation
One of the most significant features of HTML5 form controls is the ability to validate most user data without using JavaScript. This is achieved by using validation attributes on form elements. We've seen many of these earlier in this course, but let's recap:
- required – specifies whether a form field must be filled out before the form can be submitted.
- minlength and maxlength – specify the minimum and maximum length of textual data (strings).
- min and max – specify the minimum and maximum values for numeric input types.
- type – specifies whether the data should be a number, an email address, or another specific type.
- pattern – specifies a regular expression that defines a pattern the entered data must follow.
If the data entered in a form field meets all the rules specified by the attributes above, it is considered valid. If not, it is considered invalid.
When an element is valid, the following things are true:
- The element matches the CSS pseudo-class :valid, which allows you to apply specific styling to valid elements.
- If the user tries to submit the data, the browser will submit the form, provided nothing else prevents it (e.g., JavaScript).
When an element is invalid, the following things are true:
- The element matches the CSS pseudo-class :invalid and sometimes other UI pseudo-classes (e.g., :out-of-range) depending on the error, allowing you to apply specific styling to invalid elements.
- If the user tries to submit the data, the browser will block the form and display an error message.
Example of using built-in validation
Let's start with a simple example: an input that lets you choose whether you prefer a pear or a cherry. This example involves a basic input text field with an associated label and a button for submission.
<form>
<label for="choose">Do you prefer a pear or a cherry?</label>
<input id="choose" name="i_like" pattern="(pear|cherry)">
<button>Submit</button>
</form>
input:invalid {
border: 2px dashed red;
}
input:valid {
border: 2px solid black;
}
The required attribute
The simplest HTML5 validation feature is the required attribute. To make an input mandatory, add this attribute to the element. When this attribute is set, the element matches the UI pseudo-class :required and the form will not be submitted, displaying an error message upon submission when the input is empty. Although it is empty, the input will also be considered invalid, matching the UI pseudo-class :invalid.
We add a :required attribute as shown below:
<form>
<label for="choose">Do you prefer a pear or a cherry?</label>
<input id="choose" name="i_like" required>
<button>Submit</button>
</form>
Note the CSS code included in the example:
input:invalid {
border: 2px dashed red;
}
input:invalid:required {
background-image: linear-gradient(to bottom, tomato, lightgray);
}
input:valid {
border: 2px solid black;
}
The CSS code above makes the input have a red border when it is invalid and a solid black border when it is valid. Additionally, we added a background gradient when the input is required and invalid.
Try submitting the form without entering a value. A default error message ("Please fill out this field") will be displayed, and the form will not be submitted.
The presence of the required attribute on any element that accepts this attribute means that the element matches the :required pseudo-class, regardless of whether it has a value or not. If the input has no value, the input will match the :invalid pseudo-class.
☞ For a good experience, indicate to the user when there are required form fields to be filled in. It's not just a good user experience, it's also required by the WCAG accessibility guidelines. Also, only ask users to enter the data you absolutely need. For example, do you really need to know someone's gender or title?
Validation against a regular expression
Another useful validation feature is the pattern attribute, which expects a regular expression as its value. A regular expression (regex) is a pattern that can be used to match character combinations in text strings, so regexes are ideal for form validation and serve a variety of other uses in JavaScript.
Regexes are quite complex and we won't exhaust the topic here, but we'll at least demonstrate how to use them to give you a basic idea of how they work.
- a – matches a character that is a (not b, not aa, and so on).
- abc – matches a followed by b followed by c.
- ab?c – matches a optionally followed by a single b, followed by c (ac or abc).
- ab*c – matches a optionally followed by any number of b, followed by c (ac, abc, abbbbbc, and so on).
- a|b – matches a character that is either a or b.
- abc|xyz – matches exactly abc or exactly xyz (but not abcxyz or a or y, and so on).
There are many other possibilities that we will discuss at the appropriate time.
Let's implement an example. We'll update the HTML code to add a pattern attribute:
<form>
<label for="choose">Do you prefer a pear or a cherry?</label>
<input id="choose" name="i_like" required pattern="[Pp]ear|[Cc]herry">
<button>Submit</button>
</form>
In this example, the input element accepts one of four possible values: the strings "Para", "para", "Cherry", or "cherry". Regular expressions are case-sensitive, but our form supports both uppercase and lowercase versions by using an additional "Aa" pattern included in square brackets.
If an entered value does not match the regular expression pattern, the input will match the :invalid pseudo-class.
☞ Some types of input elements do not require a pattern attribute to be validated against a regular expression. Specifying the email type (type="email"), for example, validates the input value against a well-formed email address pattern or a pattern that matches a list of comma-separated email addresses if the multiple attribute is set.
The textarea element does not support the pattern attribute.
Restricting input length
You can constrain the character length of all text fields created by input or textarea using the minlength and maxlength attributes. A field is not valid if it has a value and that value has fewer characters than the minlength value or more than the maxlength value.
Often, browsers do not allow the user to type a value longer than expected into text fields. A better user experience than using maxlength is to provide character count feedback in an accessible manner and allow users to edit their content to fit the accepted size. An example of this is the character limit seen on Twitter. JavaScript, including solutions that use maxlength, can be used to achieve this.
Limiting input values
For numeric fields (e.g. <input type="number">), the min and max attributes can be used to provide a range of valid values. If the field contains a value outside this range, it will be invalid.
Let's look at another example:
<form>
<div>
<label for="choose">Do you prefer pears or apples?</label>
<input type="text" id="choose" name="i_like" required minlength="4" maxlength="4">
</div>
<div>
<label for="number">How many would you like?</label>
<input type="number" id="number" name="amount" value="1" min="1" max="10">
</div>
<div>
<button>Submit</button>
</div>
</form>
As you can see, we gave the text field a minimum and maximum length of four characters, which matches our example fruits (apples / pears).
We also gave the number field a minimum of one and a maximum of ten. Numbers entered outside this range will appear as invalid; users will not be able to use the increment/decrement arrows to move the value outside this range. If the user manually enters a number outside this range, the data is invalid. The numeric input is not required, so removing the value will result in a valid input.
Form validation with JavaScript
You need to use JavaScript if you want to customize the appearance of native error messages or deal with older browsers that do not support built-in HTML validation.
Form validation with JavaScript is a more complex topic, which we will cover in detail in the course dedicated to learning JavaScript.
Submitting form data
After learning how to structure and style forms, the final step is to understand how the data filled in by the user is submitted. This process is essential for server interaction and for collecting information from the client side.
Submission method
HTML forms can submit data using two main methods:
- GET – sends the data in the URL. It is suitable for searches or simple forms.
- POST – sends the data in the body of the HTTP request. It is recommended for sensitive data or complex forms.
<form action="processing.php" method="post"><label for="name">Name:</label><input type="text" name="name" id="name"><button>Send</button></form>
The Action Attribute
Defines the address (URL) to which the data is sent. It can be a PHP page, a server-side script, or an API endpoint.
The Method Attribute
Defines how the data is sent. GET appends the data to the URL, POST sends it in the background.
What Happens on Submission
When the user clicks the “Submit” button, the browser:
- Checks if the form is valid (if attributes like required are present)
- Sends the data according to the specified method and action
- Opens the page specified in action (if JavaScript is not used)
Submitting with JavaScript (Optional)
We can intercept the submission and process the data manually:
document.querySelector("form").addEventListener("submit", function(e) {
e.preventDefault(); // stop automatic submission
const formData = new FormData(this);
fetch("procesare.php", {
method: "POST",
body: formData
}).then(r => r.text()).then(data => {
console.log("Server response:", data);
});
});
Conclusion
Submitting data is the final step in interacting with the form. You can use simple methods with HTML or dynamic solutions with JavaScript, depending on your application's needs.
