<!-- 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...</option>
<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>
<p class="note">Update 2022-04-23: New Codepen that adds support to <a href="https://codepen.io/sandrina-p/pen/yLprQgj?editors=1111" class="link" target="_blank">multiple selects in the page</a>.</p>
</div>
<footer class="footer">
<p>Made without coffee by <a href="https://twitter.com/a_sandrina_p" class="link">Sandrina Pereira</a>. Would you <a href="https://www.buymeacoffee.com/sandrinap" target="_blank" class="link">buy me one</a>?</p>
</footer>
// 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,
.selectCustom-option:hover {
background-color: #865bd7; // contrast AA
color: white;
cursor: default;
}
.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;
transform: translateX(-0.1em);
margin-right: -0.1em;
&:hover {
color: #8c00ff;
}
&:focus {
outline: none;
background-color: #e9e1f8;
}
}
.footer {
position: relative;
width: 100%;
margin-top: 60px;
padding: 24px 16px;
text-align: center;
font-size: 1.4rem;
background: white;
@media screen and (min-height: 26em) {
position: fixed;
left: 0;
bottom: 0;
}
}
View Compiled
/* Features 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
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.