Editor’s note: This article was last updated by Rahul Chhodde on 13 February 2024 to cover integrating responsive design into forms, as well as information about styling form elements like buttons and labels using CSS properties and a few notable CSS frameworks.
Websites and apps rely on forms to gather data from users. For example, a typical login form provides users with dedicated fields to collect their username, password, and a login button.
It is crucial for frontend developers to understand how to properly style form elements while ensuring accessibility. With this knowledge, you can create more engaging forms using the latest CSS features and a little creativity.
Form elements appear plain and simple by default to offer you a neutral foundation with basic accessibility features for building your unique UI. Here’s an example of an unstyled HTML form:
See the Pen HTML form elements with no CSS styles by Rahul (@_rahul)
on CodePen.
The form above might look slightly different across browsers. This is due to the different interpretations of rendering and web standards as well as optimizing HTML elements according to the browser UI.
In this tutorial, you’ll learn how to recreate HTML forms, ensuring cross-browser compatibility and enhancing them for visual appeal and user-friendliness. The process involves establishing a baseline for form elements and refining them step-by-step with interactive states and animations.
We’ll use SCSS nesting to streamline the CSS process; all the SCSS and the generated CSS code involved here are accessible on this GitHub repository.
box-sizing
Modern browsers use a padding box for form inputs (especially text fields), which excludes padding and borders from declared dimensions. This can lead to unexpected behavior, especially in responsive layouts.
Setting border-box
as the box-sizing
for all elements ensures consistent behavior across browsers:
:root { box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; }
Let’s discuss CSS selectors that are used exclusively to select form elements. You may already be familiar with some of these; we’ll go through them quickly and use these techniques later when styling the form elements.
You’re probably familiar with the different input elements used in forms, each designated by the input tag with a specific type attribute.
Here’s a simple HTML form demonstrating the structure of a login form using various types of HTML input elements:
<form> <input type="text" placeholder="Username" /> <input type="password" placeholder="Password" /> <input type="submit" value="Login" /> </form>
As you can see, each input element has a type attribute set to its specific function: "text"
for username, "password"
for password entry, and "submit"
for the button. These attributes allow us to target them individually with CSS rules as shown below:
input[type="text"] { ... } input[type="password"] { ... } input[type="submit"] { ... }
In CSS, form inputs do not support insertion with ::before
and ::after
pseudo-elements. The only pseudo-elements supported by inputs are as follows, primarily used for decorating elements rather than inserting content:
::placeholder
and ::selection
: Supported by text inputs to style placeholder text and text selection, respectively::file-selection-button
: Supported only by native HTML file inputs to style the button in the file inputYou’ll see the implementation of some of these pseudo-elements in a later section when customizing these particular input types. Check out this guide for more information about CSS pseudo-elements.
Note: This article excludes vendor-specific pseudo-elements because they lack standardization and can’t guarantee consistent appearance across different browsers.
In addition to :hover
, :active
, and :focus
, other pseudo-classes help us quantify the validation and perceive different states of input elements. Here’s a quick list of these pseudo-classes, which are fairly self-explanatory:
/* Validation */ input:valid {} input:invalid {} /* Active and inactive */ input:enabled {} input:disabled {} /* Required inputs */ input:required {} /* Read-only text inputs */ input:read-only {} /* Inputs with their value to be autofilled by the browser */ input:autofill {}
You can write custom default styles or use a pre-built CSS reset like normalize.css for consistent form styling across browsers. Here, we’ll focus on the manual method.
First, let’s identify all the key input elements, which you can find in this SCSS file, and set their font and color properties to inherit from their parent elements. Typically, this involves inheriting from the body element’s font-family
and font-size
.
We can then specify properties that require explicit definitions to differentiate elements from their parent’s styles, such as a shorter line height for form elements:
input, select, button, ... input::file-selector-button { font: inherit; color: inherit; line-height: 120%; }
Next, let’s add a pointer cursor to the actionable form inputs like buttons. We may also do the same with the label element, but for now, let’s just focus on the buttons:
button, input[type="button"], input[type="submit"], input[type="reset"], input::file-selector-button { cursor: pointer; }
Finally, let’s add CSS properties to standardize the shape, size, and spacing of the elements. We should avoid styling the input tag directly to prevent applying unconventional styles to all the input types, like borders on checkboxes and radio elements.
Specifying the input type for selection is the right way to apply CSS to similar input elements. Here’s a simple demonstration of type selection to shape things up:
:root { --width-input-border: 2px; --radius-inputs: 0.25em; --padding-inputs: 0.75em; } select, textarea, input[type="text"] ... { padding: var(--padding-inputs); border: var(--width-input-border) solid; border-radius: var(--radius-inputs); }
Adding a solid border for text fields, select boxes, and buttons will keep them equal in height. Adding a slight border-radius
will make them look more polished, and padding will provide some breathing room for the buttons.
Tip: Prioritize using CSS custom properties whenever possible to simplify organization, maintenance, and customization.
Some state-based styles, like focus outline and disabled element fading, are often handled by user-agent styles, which provide consistent visual hints for websites. These hints are expected by the users too, who rely on them to navigate a webpage.
For optimal accessibility and user experience, it’s important to carefully decide what states of form elements to depend on browser defaults — such as focus outlines — and which ones require manual styling, such as custom cursors, and disabled, required, or read-only styles:
:root { --opacity-input-disabled: 0.5; } :read-only { cursor: default } :disabled { opacity: var(--opacity-input-disabled); cursor: not-allowed; }
The above rules ensure that read-only inputs have the default cursor and disabled ones have reduced opacity with a default arrow cursor. When styling individual form elements, we’ll address hover, focus, and active states.
We’ll combine width and max-width to size elements, ensuring they stay within the defined max-width (100%) for a responsive design (RWD) approach. Consider setting a minimum height for textareas to prevent an awkwardly wide but short appearance:
:root { ... --width-inputs: 250px; --width-textarea: 450px; --height-textarea: 250px; } select, input[type="text"], ... { width: var(--width-inputs); max-width: 100%; } textarea { width: var(--width-textarea); min-height: var(--height-textarea); max-width: 100%; }
Now, let’s add some style to the elements individually, and also tweak them when they get paired with certain elements. Most of our structuring and shaping work is done; the remaining tasks primarily involve refining colors and cosmetics.
Let’s first start with managing the color themes using CSS custom properties:
:root { ... --co-body-accent: #07c; }
Targeting and theming text, borders, outlines, and backgrounds using relevant properties is straightforward. However, certain areas and elements, such as the checked state of radio buttons and checkboxes, are controlled by default by the operating system or browser and cannot be customized using regular CSS properties.
The accent-color
CSS property comes in handy here. Applying it to elements that typically rely on browser or OS accent colors for their default styling allows you to somewhat alter their appearance:
input, select, button, textarea, ... input::file-selector-button { accent-color: var(--co-body-accent); }
Consider using the dark
value for the color-theme
property when implementing dark mode. This instructs the browser to utilize optimized UI decorations for various elements, including form inputs, in dark interfaces. It ensures that decorations in elements like number fields, date pickers, etc., contrast nicely with the dark theme:
body { color-scheme: dark; }
Setting border colors and defining different borders for hover, active, and focus states can help guide users regarding the various states of text inputs, including textareas.
Because we’ll use borders to emphasize various states, hiding the default browser outline added on focus is a good idea. We’ll achieve this by specifying it through type-based selection, as demonstrated below:
:root { --co-textfld-bg: #222; --co-textfld-border: #333; --co-textfld-active-border: #444; --co-textfld-focus-border: var(--co-body-accent); } select, textarea, input[type="text"], ... { ... border: var(--width-input-border) solid var(--co-textfld-border); background-color: var(--co-textfld-bg); &:focus { outline: 0; } } select, textarea, input[type="text"], ... { &:hover, &:active { border-color: var(--co-textfld-active-border); } &:focus { border-color: var(--co-textfld-focus-border); } }
Similar to text fields, we should also add colors to our buttons. The background and border colors will be the same, with an additional text color added to provide contrast between the text and the background:
:root { --co-btn-text: #fff; --co-btn-bg: var(--co-body-accent); --co-btn-active-bg: #333; --co-btn-focus-bg: #333; } button, input[type="button"], ... input[type="file"]::file-selector-button { border-color: var(--co-btn-bg); background-color: var(--co-btn-bg); color: var(--co-btn-text); &:hover, &:active { background-color: var(--co-btn-active-bg); border-color: var(--co-btn-active-bg); } &:focus { background-color: var(--co-btn-active-bg); } }
We ensured that the file selector button for file inputs is styled like regular button inputs by using the ::file-selector-button
pseudo-element.
While our initial setup automatically optimizes the UI of radio and checkbox buttons, nesting them within their label elements creates an even cleaner and more efficient appearance:
<label for="my-radio"> <input id="my-radio" type="radio" /> <span>Radio input with a label</span> </label> <label for="my-checkbox"> <input id="my-checkbox" type="checkbox" /> <span>Checkbox input with a label</span> </label>
We have already addressed most aspects of file inputs when setting defaults except the spacing between the file selector button and the label necessitates additional styling. Here’s how you can achieve it:
:root { --margin-form-gap: 1.5em; } input::file-selector-button { margin-right: var(--margin-form-gap) }
Converting the default labels to block elements allows inputs to position naturally below them without modifying the default display of inputs, ensuring a clean and efficient design.
Also, spacing between the input and label improves form readability and scannability. These adjustments can be implemented as follows:
:root { --margin-label: 0.5em; } label { cursor: pointer; display: block; & + &, & + input, & + select, & + button, & + textarea { margin-top: var(--margin-label); } }
We can achieve basic form validation with visual feedback using just CSS and HTML functionalities.
By adding the required
attribute to essential fields, we can then target them all using the :required
pseudo-class and then combine them with the :valid
and :invalid
pseudo-classes to provide clear visual cues for valid and invalid input:
:root { ... --co-textfld-valid-border: hsl(140 90% 20%); --co-textfld-valid-active-border: hsl(140 90% 30%); --co-textfld-valid-focus-border: hsl(140 90% 45%); --co-textfld-invalid-border: ...; ... } select, textarea, input[type="text"], ... { ... &:required { &:valid { &:hover, &:active { &:not([readonly], [disabled]) { border-color: var(--co-textfld-valid-active-border); } } &:focus { &:not([readonly], [disabled]) { border-color: var(--co-textfld-valid-focus-border); } } } &:invalid { /* Similar steps as above */ } } }
In this article, we won’t delve deeply into animations. Instead, we’ll utilize the transition
property to create smooth color transitions for text, borders, and backgrounds:
:root { --transition-duration-inputs: 250ms; --transition-function-inputs: ease-in-out; --transition-inputs: color var(--transition-duration-inputs), background-color var(--transition-duration-inputs), border-color var(--transition-duration-inputs) var(--transition-function-inputs); } input, button, select, ... input::file-selector-button { transition: var(--transition-inputs); }
A clear separation is vital for establishing a layout hierarchy on larger forms. Introduce this separation strategically within each form area, grouping similar inputs and elements using div
containers. This distinction ensures they visually stand apart from identical siblings, enhanced by appropriate margin application:
.form-row { & + & { margin-top: var(--margin-row-gap); } }
For side-by-side inputs, wrap them in a flexbox and set its flex-wrap
property to wrap
. This automatically arranges elements vertically when they don’t fit horizontally:
.form-row, .btn-group { display: flex; flex-wrap: wrap; } .form-row { gap: var(--margin-row-gap) } .btn-group { gap: var(--margin-btn-gap) }
I’ve combined everything into a CodePen demo below with a few more optimizations. Play around and see how easily you can spin up a dark mode with CSS custom properties:
See the Pen Form Elements styles w/ Pure CSS by Rahul (@_rahul)
on CodePen.
Frameworks like Tailwind CSS, Bootstrap, and Bulma offer resets and extensive utility classes. However, effectively using them requires careful consideration:
With this article, you should hopefully now have a grasp of styling form elements with CSS. These techniques serve as the foundation for more advanced CSS form styling.
These strategies can be leveraged to develop highly customized and accessible controls using popular JavaScript frameworks and WAI-ARIA tech. Find out all the code discussed above in this GitHub repo, and feel free to ask questions in the comments.
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
7 Replies to "How to style forms with CSS: A beginner’s guide"
missing `label > input[type=checkbox] + span` and related for styling checkbox and radio input… by practice, I will wrap them in a label, and separate the text for the label next to the input element.
Amazing guide. Thank you!
It is an amazing post and you explained in a detailed way. Nice to see this here. I will bookmark your blog for more details. Keep sharing the new things like this.
Nice tutorial! The checkbox hack was dope!
This was helpful. Keep it up Sir
Very helpful and easy to undrestand! Thank you for your help!
This beginner-friendly guide to styling forms with CSS is incredibly helpful. It breaks down the process into easy-to-follow steps, making it accessible for web developers at any level.