Web Development
HTML Course
CSS Course
JavaScript Course
PHP Course
Python Course
SQL Course
SEO Course

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:

Relatively hard to style.

Some elements are harder to style, requiring more complex CSS code or a few specific tricks:

Very hard to style.

These elements cannot be fully styled using CSS:

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:

Choose your favorite vegetables
What is your favorite food?

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:

🔧 Appearence demo

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.

🔧 Appearance search demo

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:

checkbox-chrome
Chrome
checkbox-chrome
Firefox
checkbox-chrome
Opera

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:

🔧 Example: appearance: none

I've also created a few examples to give you more ideas:

🔧 Custom Radio Buttons
🔧 Custom Checkbox

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:

🔧 Live Example

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;
}
🔧 Restyled File Input Example

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:

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:

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:

🔧 Example: :required and :optional

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:

🔧 Example: :required and :optional; generated content

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:

Let's take a look at a simple example of using :valid / :invalid (see the source code):

🔧 Example: :valid / :invalid

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:

🔧 Example: :out-of-range

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');
    }
  }
}
🔧 Example: enabled / disabled

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.

🔧 Example: read-only

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:

: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:

🔧 Custom radio buttons
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:

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.

🔧 Example: :checked / :default

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:

🔧 Radio buttons :checked, :indeterminate

More pseudo-classes

There are several other interesting pseudo-classes that we'll just mention here, but will use in future examples:

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 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:

  1. 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.
  2. We want to protect our users' data. Forcing our users to enter secure passwords helps protect information about their accounts.
  3. 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:

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:

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:

When an element is invalid, the following things are true:

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;
}
🔧 Example of using built-in validation

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.

🔧 Client-side form validation:required

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.

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>
🔧 Client-side form validation:pattern

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.

🔧 Client-side form validation:input value limitation

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:

<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:

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.


🧠 Quiz – Web Forms and Validation

Top