tutorial // Sep 30, 2022

How to Build a Custom Select Input with HTML and CSS

How to override the browser styling for an HTML select input with CSS while maintaining native browser behavior.

How to Build a Custom Select Input with HTML and CSS

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start:

Terminal

cd app && joystick start

After this, your app should be running and we're ready to get started.

Adding Font Awesome

Although technically optional, for this tutorial, we're going to utilize an icon from the Font Awesome library. This library has free and paid options that give you access to a large library of high-quality icons in the form of either an icon font or SVG graphics. To keep things simple, we're going to rely on their icon font option which is served via CDN (content delivery network) link.

If you don't have one already, to start, head over to the Font Awesome site and set up an account. Once you're in, head over to the Kits page and then click the "New Kit +" button at the top of the page.

nkKq28ov98it96Zi/xE9ZQGyhpgXXVMxw.0
Accessing your Kit's Code on Font Awesome.

On the next page, under the "Add Your Kit's Code to a Project" section (#1), click the blue "Copy Kit Code" button to the right of the input with the <script></script> tag in it.

Once you have this copied, open up the app we just created above and locate the /index.html file at the root of the project. Inside, just above the ${css} placeholder, paste in the <script></script> tag you just copied and save the file.

That's it. Now we have Font Awesome installed and can put it to use later in the tutorial.

Building a custom select component

To start, we're going to wire up a component to make rendering our custom select more reusable. Inside of the app we just created, we want to add a new file in the /ui/components directory at /ui/components/select/index.js:

/ui/components/select/index.js

import ui from '@joystick.js/ui';

const Select = ui.component({
  render: ({ props, each }) => {
    return `
      <div class="select">
        <select name="${props.name}">
          ${each(props.options, (option) => {
            return `<option value="${option.value}">${option.label}</option>`;
          })}
        </select>
        <i class="fas fa-chevron-down"></i>
      </div>
    `;
  },
});

export default Select;

To start, here, we're creating our component using the ui.component() function from @joystick.js/ui (the UI portion of the Joystick framework we used to create our app above). To that function, we pass an options object and on that, we've defined a function render() which returns the HTML for our component. Inside, we're returning the HTML for our custom select.

To start, we wrap a native HTML <select></select> tag in a <div></div> with a class select which will serve as the basis for our custom CSS styling. Just beneath the <select></select> tag, we've added an icon (we're using the old italics tag <i></i> to render this which is a common pattern) from the Font Awesome library we set up earlier, the chevron down.

Focusing on the <select></select> tag, we're adding a name attribute passing it a prop name we're anticipating being passed via our component's props. Inside of the select, to render our options, we're using a special function each() (what's known as a "render method" in Joystick) to render an array of options we expect to be passed via props. For each option, we expect it to be an object with two properties: value and label.

Here, for each item in our props.options array, the each() method calls the function passed as the second argument with the current item in the array being looped/iterated over. Inside of that function, we can return some HTML to render for each item. Here, we're rendering an <option></option> tag, setting the value attribute of that element to option.value and the label to option.label.

Adding the custom select CSS

Now for the important part. In order to actually have a custom-styled select, we need to add some CSS styling. But first, why are we building it this way? Isn't this just a <select></select> box?

It is. The reason we're doing this—with our custom part being the CSS we'll write next—is that we don't want to break the native <select></select> functionality. This solution is a "best of both worlds," in that it allows us to theme the select input to fit our app without having to completely rewrite the native behavior. The advantage is that it works universally in all browsers/devices and requires minimal maintenance overhead.

Let's take a look at how it works.

/ui/components/select/index.js

import ui from '@joystick.js/ui';

const Select = ui.component({
  css: `
    .select {
      position: relative;
    }

    .select .fa-chevron-down {
      position: absolute;
      top: 17px;
      right: 15px;
      color: #aaa;
    }

    .select select {
      display: block;
      width: 100%;
      padding: 15px;
      font-size: 15px;
      border: 1px solid #ddd;
      border-radius: 3px;
      -moz-appearance:none; /* Firefox */
      -webkit-appearance:none; /* Safari and Chrome */
      appearance:none;
    }

    .select select:focus {
      outline: 0;
      border-color: #aaa;
    }
  `,
  render: ({ props, each }) => {
    return `
      <div class="select">
        ...
      </div>
    `;
  },
});

export default Select;

To style up our select, we're adding another property to our component here css which is set to a string of CSS that Joystick will automatically scope to our component's HTML. Let's walk through the CSS so we can understand what it's up to.

First, at the top, we're setting the position of our wrapper <div></div> to be relative, which creates a "shrinkwrap" effect for any elements that are absolutely positioned inside of it. This comes into play for the style we're using on the chevron icon we added from Font Awesome.

Here, we're saying that we want to force the position of the icon to be 17 pixels from the top of the select, and 15px from the right. That "shrinkwrap" ensures that position is absolute, relative to the <div></div> wrapping it. Without this, our icon would shoot off somewhere randomly on the page until it hits its next relative positioned parent (and if that doesn't exist, the browser window itself).

Next, to style our select, we first make sure to set the element to be block-level so it expands to fill the width of its parent. To reinforce this, we also force the width to take up 100% of its parent. Here, that "parent" is the wrapper .select <div></div>.

After this, we add some padding/whitespace to our input, adjust its font size, and add a border with a rounded corner radius to add some basic styling (this can be whatever you want, we're just keeping it simple here for example sake).

The last part here is very important. To guarantee that we've disabled the default browser styling on our select input, we add three styles:

-moz-appearance: none; /* Firefox */
-webkit-appearance: none; /* Safari and Chrome */
appearance: none;

All three accomplish the same thing, but as our comments denote, do so in different web browsers. The first -moz-appearance strips the browser styling in Firefox, the second -webkit-appearance strips the browser styling in Safari, Chrome, Brave and any webkit-based browser, and finally appearance covers any other browser that supports it.

That's the core of it. For a little bit of polish, we add one additional style for when our select input is focused (via the keyboard or when the user clicks on it) to remove the default browser outline style (the chunky border) and instead change our custom border color to be a bit darker.

Putting the select component to use

With all of that set, now we're ready to put our component to use. To test it out, we're going to open up another component at /ui/pages/index/index.js and replace its contents with the following:

/ui/pages/index/index.js

import ui from '@joystick.js/ui';
import Select from '../../components/select';

const Index = ui.component({
  css: `
    form {
      padding: 20px;
    }

    form label {
      display: block;
      font-size: 15px;
      color: #888;
      margin-bottom: 10px;
    }
  `,
  events: {
    'change select': (event) => {
      console.log('Custom Select Value:', event.target.value);
    }
  },
  render: ({ component }) => {
    return `
      <div>
        <form>
          <label>Select a Food to Eat</label>
          ${component(Select, {
            options: [
              { value: 'broccoli', label: 'Broccoli' },
              { value: 'carrots', label: 'Carrots' },
              { value: 'italianSub', label: 'Italian Sub' },
              { value: 'mango', label: 'Mango' },
              { value: 'pizza', label: 'Pizza' },
            ],
          })}
        </form>
      </div>
    `;
  },
});

export default Index;

Up at the top of the file, we've imported our Select component from our other file. Down in the render() function for this component, we're putting it to use utilizing another render method in Joystick: component(). Like the name implies, this helps us to render a component inside of another.

Using JavaScript string interpolation, to an interpolation expression ${} we pass a call to component(), and to that, we pass our Select component we imported up top and as the second argument, an object of props to pass to it. Recall that earlier, we wrote our Select component to anticipate an options prop. Here, we pass the array of options objects we hinted at, with each containing a value and label.

That does it for the core test. Up above our render option, we've added an additional events property where we define JavaScript DOM event listeners for our component. Here, we're listening for a change event on the <select></select> that will be rendered inside of our Select component. When that change event occurs, we call the function assigned to the 'change select' property. Here, we just log out the current value of the <select></select> to confirm that we haven't broken the native behavior.

That's it! Now we have a custom-styled select that respects native browser behavior.

Making sure the select input fills its parent

One additional detail that may affect some but not others is the global box-sizing property on elements in your app. This property impacts the behavior of the native <select></select> element and how it calculates its width. To ensure that it fills the entirety of our custom select wrapper, you will want to make sure that your app has a global CSS style for this box-sizing property. For our needs, we can utilize the global index.css file at the root of the app we created earlier:

/index.css

*,
*:before,
*:after {
	box-sizing: border-box;
}

body {
  font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
  font-size: 16px;
  background: #fff;
}

The body style already exists in our template. Here, we're adding the * style part, forcing the box-sizing property on all elements to be border-box. This ensures that all elements fill their entire parent (so, in our case, if we click the left or right-most edge of our custom select, it will still trigger the input to display).

Wrapping up

In this tutorial, we learned how to create a custom styled <select></select> element using HTML and CSS. To do it, we used a Joystick component to make our CSS-styled input reusable, and then, put it to use inside of another component. Finally, we confirmed that we didn't break the native browser behavior of our input so that our input works universally in all browsers.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode