<!-- Article: https://css-tricks.com/striking-a-balance-between-native-and-custom-select-elements/ -->
<h1 class="title">Custom Select: "Hybrid Select"</h1>

<div class="card">
  <p class="inst">Try to select an option with whatever tool you are using (e.g. mouse, touch, keyboard, etc...)<p>
      <div class="select">
        <span class="selectLabel" id="jobLabel"> Main job role</span>
        <div class="selectWrapper">
          <select class="selectNative js-selectNative" aria-labelledby="jobLabel">
            <option value="sel" disabled="" selected=""> Select role...</span>
            <option value="ds">UI/UX Designer</option>
            <option value="fe">Frontend Engineer</option>
            <option value="be">Backend Engineer</option>
            <option value="qa">QA Engineer</option>
            <option value="un">Unicorn</option>
          </select>

          <!-- Hide the custom select from AT (e.g. SR) using aria-hidden -->
          <div class="selectCustom js-selectCustom" aria-hidden="true">
            <div class="selectCustom-trigger">Select role...</div>
            <div class="selectCustom-options">
              <div class="selectCustom-option" data-value="ds">UI/UX Designer</div>
              <div class="selectCustom-option" data-value="fe">Frontend Engineer</div>
              <div class="selectCustom-option" data-value="be">Backend Engineer</div>
              <div class="selectCustom-option" data-value="qa">QA Engineer</div>
              <div class="selectCustom-option" data-value="un">Unicorn</div>
            </div>
          </div>
        </div>
      </div>
      <p class="note">If you struggled to select an option, please reach out to me by e-mail at <a href="mailto:a.sandrina.p@gmail.com" class="link" target="_blank">a.sandrina.p@gmail.com</a>.</p>
</div>
// Both native and custom selects must have the same width/height.
.selectNative,
.selectCustom {
  position: relative;
  width: 22rem;
  height: 4rem;
}

// Make sure the custom select does not mess with the layout
.selectCustom {
  position: absolute;
  top: 0;
  left: 0;
  display: none;
}

// This media query detects devices where the primary
// input mechanism can hover over elements. (e.g. computers with a mouse)
@media (hover: hover) {
  // Since we are using a mouse, it's safe to show the custom select.
  .selectCustom {
    display: block;
  }

  // In a computer using keyboard? Then let's hide back the custom select
  // while the native one is focused:
  .selectNative:focus + .selectCustom {
    display: none;
  }
}

/* Add the focus states too, They matter, always! */
.selectNative:focus,
.selectCustom.isActive .selectCustom-trigger {
  outline: none;
  box-shadow: white 0 0 0 0.2rem, #ff821f 0 0 0 0.4rem;
}

//
// Rest of the styles to create the custom select.
// Just make sure the native and the custom have a similar "box" (the trigger).
//

.select {
  position: relative;
}

.selectLabel {
  display: block;
  font-weight: bold;
  margin-bottom: 0.4rem;
}

.selectWrapper {
  position: relative;
}

.selectNative,
.selectCustom-trigger {
  font-size: 1.6rem;
  background-color: #fff;
  border: 1px solid #6f6f6f;
  border-radius: 0.4rem;
}

.selectNative {
  -webkit-appearance: none;
  -moz-appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg fill='black' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
  background-repeat: no-repeat;
  background-position-x: 100%;
  background-position-y: 0.8rem;
  padding: 0rem 0.8rem;
}

.selectCustom-trigger {
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #fff;
  padding: 0.8rem 0.8rem;
  cursor: pointer;
}

.selectCustom-trigger::after {
  content: "▾";
  position: absolute;
  top: 0;
  line-height: 3.8rem;
  right: 0.8rem;
}

.selectCustom-trigger:hover {
  border-color: #8c00ff;
}

.selectCustom-options {
  position: absolute;
  top: calc(3.8rem + 0.8rem);
  left: 0;
  width: 100%;
  border: 1px solid #6f6f6f;
  border-radius: 0.4rem;
  background-color: #fff;
  box-shadow: 0 0 4px #e9e1f8;
  z-index: 1;
  padding: 0.8rem 0;
  display: none;
}

.selectCustom.isActive .selectCustom-options {
  display: block;
}

.selectCustom-option {
  position: relative;
  padding: 0.8rem;
  padding-left: 2.5rem;
}

.selectCustom-option.isHover {
  background-color: #865bd7; // contrast AA
  color: white;
}

.selectCustom-option:not(:last-of-type)::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  border-bottom: 1px solid #d3d3d3;
}

.selectCustom-option.isActive::before {
  content: "✓";
  position: absolute;
  left: 0.8rem;
}

// ----- Theme styles -----

html {
  font-size: 62.5%;
}
body {
  background: #f8f3ef;
  font-family: Arial, Helvetica, sans-serif;
  box-sizing: border-box;
  color: #343434;
  line-height: 1.5;
  font-size: 1.6rem;
  min-height: 120vh; /* using arrow keys in the select, does not scroll the page */
}

body * {
  box-sizing: inherit;
}

strong {
  font-weight: 600;
}

.title {
  font-size: 2rem;
  font-weight: 600;
  margin: 1.6rem;
  line-height: 1.2;
  text-align: center;
}

.card {
  position: relative;
  margin: 2rem auto;
  max-width: calc(100% - 2rem);
  width: 40rem;
  background: white;
  padding: 3rem;
  box-shadow: 0.2rem 0.2rem #e9e1f8;
}

.inst {
  margin-bottom: 1rem;
}

.note {
  font-size: 1.4rem;
  margin: 2rem 0 0;
  color: #6b6b6b;
}

.link {
  display: inline-block;
  color: inherit;
  text-decoration-color: #9b78de;
  padding: 0.1rem 0.2rem;
  transform: translateX(-0.1em);
  margin-right: -0.1em;

  &:hover {
    color: #8c00ff;
  }

  &:focus {
    outline: none;
    background-color: #e9e1f8;
  }
}
View Compiled
/* Features needed to make the selectCustom work for mouse users.

- Toggle custom select visibility when clicking the "box"
- Update custom select value when clicking in a option
- Navigate through options when using keyboard up/down
- Pressing Enter or Space selects the current hovered option
- Close the select when clicking outside of it
- Sync both selects values when selecting a option. (native or custom)

*/

const elSelectNative = document.getElementsByClassName("js-selectNative")[0];
const elSelectCustom = document.getElementsByClassName("js-selectCustom")[0];
const elSelectCustomBox = elSelectCustom.children[0];
const elSelectCustomOpts = elSelectCustom.children[1];
const customOptsList = Array.from(elSelectCustomOpts.children);
const optionsCount = customOptsList.length;
const defaultLabel = elSelectCustomBox.getAttribute("data-value");

let optionChecked = "";
let optionHoveredIndex = -1;

// Toggle custom select visibility when clicking the box
elSelectCustomBox.addEventListener("click", (e) => {
  const isClosed = !elSelectCustom.classList.contains("isActive");

  if (isClosed) {
    openSelectCustom();
  } else {
    closeSelectCustom();
  }
});

function openSelectCustom() {
  elSelectCustom.classList.add("isActive");
  // Remove aria-hidden in case this was opened by a user
  // who uses AT (e.g. Screen Reader) and a mouse at the same time.
  elSelectCustom.setAttribute("aria-hidden", false);

  if (optionChecked) {
    const optionCheckedIndex = customOptsList.findIndex(
      (el) => el.getAttribute("data-value") === optionChecked
    );
    updateCustomSelectHovered(optionCheckedIndex);
  }

  // Add related event listeners
  document.addEventListener("click", watchClickOutside);
  document.addEventListener("keydown", supportKeyboardNavigation);
}

function closeSelectCustom() {
  elSelectCustom.classList.remove("isActive");

  elSelectCustom.setAttribute("aria-hidden", true);

  updateCustomSelectHovered(-1);

  // Remove related event listeners
  document.removeEventListener("click", watchClickOutside);
  document.removeEventListener("keydown", supportKeyboardNavigation);
}

function updateCustomSelectHovered(newIndex) {
  const prevOption = elSelectCustomOpts.children[optionHoveredIndex];
  const option = elSelectCustomOpts.children[newIndex];

  if (prevOption) {
    prevOption.classList.remove("isHover");
  }
  if (option) {
    option.classList.add("isHover");
  }

  optionHoveredIndex = newIndex;
}

function updateCustomSelectChecked(value, text) {
  const prevValue = optionChecked;

  const elPrevOption = elSelectCustomOpts.querySelector(
    `[data-value="${prevValue}"`
  );
  const elOption = elSelectCustomOpts.querySelector(`[data-value="${value}"`);

  if (elPrevOption) {
    elPrevOption.classList.remove("isActive");
  }

  if (elOption) {
    elOption.classList.add("isActive");
  }

  elSelectCustomBox.textContent = text;
  optionChecked = value;
}

function watchClickOutside(e) {
  const didClickedOutside = !elSelectCustom.contains(event.target);
  if (didClickedOutside) {
    closeSelectCustom();
  }
}

function supportKeyboardNavigation(e) {
  // press down -> go next
  if (event.keyCode === 40 && optionHoveredIndex < optionsCount - 1) {
    let index = optionHoveredIndex;
    e.preventDefault(); // prevent page scrolling
    updateCustomSelectHovered(optionHoveredIndex + 1);
  }

  // press up -> go previous
  if (event.keyCode === 38 && optionHoveredIndex > 0) {
    e.preventDefault(); // prevent page scrolling
    updateCustomSelectHovered(optionHoveredIndex - 1);
  }

  // press Enter or space -> select the option
  if (event.keyCode === 13 || event.keyCode === 32) {
    e.preventDefault();

    const option = elSelectCustomOpts.children[optionHoveredIndex];
    const value = option && option.getAttribute("data-value");

    if (value) {
      elSelectNative.value = value;
      updateCustomSelectChecked(value, option.textContent);
    }
    closeSelectCustom();
  }

  // press ESC -> close selectCustom
  if (event.keyCode === 27) {
    closeSelectCustom();
  }
}

// Update selectCustom value when selectNative is changed.
elSelectNative.addEventListener("change", (e) => {
  const value = e.target.value;
  const elRespectiveCustomOption = elSelectCustomOpts.querySelectorAll(
    `[data-value="${value}"]`
  )[0];

  updateCustomSelectChecked(value, elRespectiveCustomOption.textContent);
});

// Update selectCustom value when an option is clicked or hovered
customOptsList.forEach(function (elOption, index) {
  elOption.addEventListener("click", (e) => {
    const value = e.target.getAttribute("data-value");

    // Sync native select to have the same value
    elSelectNative.value = value;
    updateCustomSelectChecked(value, e.target.textContent);
    closeSelectCustom();
  });

  elOption.addEventListener("mouseenter", (e) => {
    updateCustomSelectHovered(index);
  });

  // TODO: Toggle these event listeners based on selectCustom visibility
});
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.