Select basic
セレクトボックス。JS によるカスタム UI。
2023年12月以降、すべてのブラウザーで利用可能です。
- Baseline
- BaselineWidely available
- 対応ブラウザ
- Chrome120+Edge120+Firefox117+Safari17.2+
プレビューを読み込み中
<select class="select-basic js-select-basic" aria-label="選択">
<option value="" disabled selected hidden>選択してください</option>
<option value="1">選択肢1</option>
<option value="2">選択肢2</option>
<option value="3">選択肢3</option>
</select>
<select class="select-basic js-select-basic" aria-label="選択">
<option value="" disabled selected hidden>選択してください</option>
<option value="1">選択肢1</option>
<option value="2">選択肢2</option>
<option value="3">選択肢3</option>
</select>
/* reset */
:where(select).select-basic__native {
box-sizing: border-box;
font: inherit;
}
/* snippet */
.select-basic {
--select-border: #e7e7e7;
--select-text-color: #364153;
--select-background: #fff;
--select-option-hover: #f3f4f6;
--select-focus-color: #2b36f5;
--select-radius: 8px;
--select-list-max-height: 240px;
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6l4 4 4-4' stroke='%236a7282' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
position: relative;
width: 100%;
max-width: 320px;
font-size: 14px;
line-height: 1.6;
color: var(--select-text-color);
}
.select-basic__native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
white-space: nowrap;
border: 0;
clip-path: inset(50%);
}
.select-basic__trigger {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
padding: 14px 40px 14px 16px;
font: inherit;
font-size: inherit;
line-height: inherit;
color: var(--select-text-color);
text-align: start;
cursor: pointer;
background-color: var(--select-background);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 16px center;
border: 1px solid var(--select-border);
border-radius: var(--select-radius);
transition: background-color 0.15s ease;
&:focus:not(:focus-visible) {
outline: none;
}
&:focus-visible {
outline: 2px solid var(--select-focus-color);
outline-offset: 2px;
}
}
.select-basic:not([data-open]) .select-basic__trigger:hover {
background-color: var(--select-option-hover);
}
.select-basic__list {
position: absolute;
top: 100%;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
max-height: var(--select-list-max-height);
padding: 4px;
margin: 4px 0 0;
overflow-y: auto;
list-style: none;
scrollbar-width: none;
background-color: var(--select-background);
border: 1px solid var(--select-border);
border-radius: var(--select-radius);
}
.select-basic__list::-webkit-scrollbar {
display: none;
}
.select-basic__option {
padding: 10px 16px;
color: var(--select-text-color);
cursor: pointer;
border-radius: calc(var(--select-radius) - 2px);
transition: background-color 0.15s ease;
&[aria-selected="true"],
&:hover,
&:focus {
outline: none;
background-color: var(--select-option-hover);
}
}
/* reset */
:where(select).select-basic__native {
box-sizing: border-box;
font: inherit;
}
/* snippet */
.select-basic {
--select-border: #e7e7e7;
--select-text-color: #364153;
--select-background: #fff;
--select-option-hover: #f3f4f6;
--select-focus-color: #2b36f5;
--select-radius: 8px;
--select-list-max-height: 240px;
--select-arrow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6l4 4 4-4' stroke='%236a7282' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
position: relative;
width: 100%;
max-width: 320px;
font-size: 14px;
line-height: 1.6;
color: var(--select-text-color);
}
.select-basic__native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
white-space: nowrap;
border: 0;
clip-path: inset(50%);
}
.select-basic__trigger {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
padding: 14px 40px 14px 16px;
font: inherit;
font-size: inherit;
line-height: inherit;
color: var(--select-text-color);
text-align: start;
cursor: pointer;
background-color: var(--select-background);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 16px center;
border: 1px solid var(--select-border);
border-radius: var(--select-radius);
transition: background-color 0.15s ease;
&:focus:not(:focus-visible) {
outline: none;
}
&:focus-visible {
outline: 2px solid var(--select-focus-color);
outline-offset: 2px;
}
}
.select-basic:not([data-open]) .select-basic__trigger:hover {
background-color: var(--select-option-hover);
}
.select-basic__list {
position: absolute;
top: 100%;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
max-height: var(--select-list-max-height);
padding: 4px;
margin: 4px 0 0;
overflow-y: auto;
list-style: none;
scrollbar-width: none;
background-color: var(--select-background);
border: 1px solid var(--select-border);
border-radius: var(--select-radius);
}
.select-basic__list::-webkit-scrollbar {
display: none;
}
.select-basic__option {
padding: 10px 16px;
color: var(--select-text-color);
cursor: pointer;
border-radius: calc(var(--select-radius) - 2px);
transition: background-color 0.15s ease;
&[aria-selected="true"],
&:hover,
&:focus {
outline: none;
background-color: var(--select-option-hover);
}
}
(function () {
let instanceIndex = 0;
let documentClickListenerBound = false;
function bindDocumentClickListener() {
if (documentClickListenerBound) return;
documentClickListenerBound = true;
document.addEventListener("click", (event) => {
for (const wrapper of document.querySelectorAll(
'.select-basic[data-open="true"]',
)) {
if (wrapper.contains(event.target)) continue;
const trigger = wrapper.querySelector(".select-basic__trigger");
const list = wrapper.querySelector(".select-basic__list");
if (trigger && list) {
closeList(wrapper, trigger, list);
}
}
});
}
function getSelectableOptions(select) {
return [...select.options].filter((option) => !option.hidden);
}
function updateTriggerText(trigger, select) {
const selected = select.options[select.selectedIndex];
trigger.textContent = selected?.textContent?.trim() ?? "";
}
function syncOptionStates(optionItems, select) {
optionItems.forEach(({ li, option }) => {
li.setAttribute("aria-selected", String(option.selected));
});
}
function setSelectedIndex(select, index) {
select.selectedIndex = index;
select.dispatchEvent(new Event("change", { bubbles: true }));
}
function closeList(wrapper, trigger, list) {
delete wrapper.dataset.open;
trigger.setAttribute("aria-expanded", "false");
list.hidden = true;
}
function openList(wrapper, trigger, list, optionItems) {
wrapper.dataset.open = "true";
trigger.setAttribute("aria-expanded", "true");
list.hidden = false;
const selectedItem =
optionItems.find(({ option }) => option.selected) ?? optionItems[0];
selectedItem?.li.focus();
}
function initSelectBasic(select) {
if (select.dataset.selectBasicInit === "true") return;
select.dataset.selectBasicInit = "true";
instanceIndex += 1;
const wrapper = document.createElement("div");
wrapper.className = "select-basic";
const ariaLabel = select.getAttribute("aria-label");
const listId = `select-basic-list-${instanceIndex}`;
const triggerId = `select-basic-trigger-${instanceIndex}`;
select.parentNode?.insertBefore(wrapper, select);
wrapper.appendChild(select);
select.classList.remove("select-basic");
select.classList.add("select-basic__native");
select.tabIndex = -1;
select.setAttribute("aria-hidden", "true");
const trigger = document.createElement("button");
trigger.type = "button";
trigger.id = triggerId;
trigger.className = "select-basic__trigger";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-controls", listId);
if (ariaLabel) {
trigger.setAttribute("aria-label", ariaLabel);
}
const list = document.createElement("ul");
list.id = listId;
list.className = "select-basic__list";
list.setAttribute("role", "listbox");
if (ariaLabel) {
list.setAttribute("aria-labelledby", triggerId);
}
list.hidden = true;
const optionItems = getSelectableOptions(select).map((option) => {
const li = document.createElement("li");
li.className = "select-basic__option";
li.setAttribute("role", "option");
li.tabIndex = -1;
li.textContent = option.textContent?.trim() ?? "";
li.dataset.index = String(option.index);
li.addEventListener("click", () => {
setSelectedIndex(select, option.index);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
closeList(wrapper, trigger, list);
trigger.focus();
});
list.appendChild(li);
return { li, option };
});
wrapper.append(trigger, list);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
trigger.addEventListener("click", () => {
const isOpen = wrapper.dataset.open === "true";
if (isOpen) {
closeList(wrapper, trigger, list);
return;
}
openList(wrapper, trigger, list, optionItems);
});
trigger.addEventListener("keydown", (event) => {
const isOpen = wrapper.dataset.open === "true";
if (
!isOpen &&
(event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " ")
) {
event.preventDefault();
openList(wrapper, trigger, list, optionItems);
return;
}
if (!isOpen) return;
const currentIndex = optionItems.findIndex(
({ li }) => li === document.activeElement,
);
if (event.key === "Escape") {
event.preventDefault();
closeList(wrapper, trigger, list);
trigger.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
const next = optionItems[currentIndex + 1] ?? optionItems[0];
next?.li.focus();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const next = optionItems[currentIndex - 1] ?? optionItems.at(-1);
next?.li.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const focused = optionItems[currentIndex];
if (!focused) return;
setSelectedIndex(select, focused.option.index);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
closeList(wrapper, trigger, list);
trigger.focus();
}
});
list.addEventListener("keydown", (event) => {
const currentIndex = optionItems.findIndex(
({ li }) => li === document.activeElement,
);
if (event.key === "Escape") {
event.preventDefault();
closeList(wrapper, trigger, list);
trigger.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
const next = optionItems[currentIndex + 1] ?? optionItems[0];
next?.li.focus();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const next = optionItems[currentIndex - 1] ?? optionItems.at(-1);
next?.li.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const focused = optionItems[currentIndex];
if (!focused) return;
setSelectedIndex(select, focused.option.index);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
closeList(wrapper, trigger, list);
trigger.focus();
}
});
select.addEventListener("change", () => {
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
});
}
function initAll() {
bindDocumentClickListener();
document.querySelectorAll(".js-select-basic").forEach(initSelectBasic);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAll);
} else {
initAll();
}
})();
(function () {
let instanceIndex = 0;
let documentClickListenerBound = false;
function bindDocumentClickListener() {
if (documentClickListenerBound) return;
documentClickListenerBound = true;
document.addEventListener("click", (event) => {
for (const wrapper of document.querySelectorAll(
'.select-basic[data-open="true"]',
)) {
if (wrapper.contains(event.target)) continue;
const trigger = wrapper.querySelector(".select-basic__trigger");
const list = wrapper.querySelector(".select-basic__list");
if (trigger && list) {
closeList(wrapper, trigger, list);
}
}
});
}
function getSelectableOptions(select) {
return [...select.options].filter((option) => !option.hidden);
}
function updateTriggerText(trigger, select) {
const selected = select.options[select.selectedIndex];
trigger.textContent = selected?.textContent?.trim() ?? "";
}
function syncOptionStates(optionItems, select) {
optionItems.forEach(({ li, option }) => {
li.setAttribute("aria-selected", String(option.selected));
});
}
function setSelectedIndex(select, index) {
select.selectedIndex = index;
select.dispatchEvent(new Event("change", { bubbles: true }));
}
function closeList(wrapper, trigger, list) {
delete wrapper.dataset.open;
trigger.setAttribute("aria-expanded", "false");
list.hidden = true;
}
function openList(wrapper, trigger, list, optionItems) {
wrapper.dataset.open = "true";
trigger.setAttribute("aria-expanded", "true");
list.hidden = false;
const selectedItem =
optionItems.find(({ option }) => option.selected) ?? optionItems[0];
selectedItem?.li.focus();
}
function initSelectBasic(select) {
if (select.dataset.selectBasicInit === "true") return;
select.dataset.selectBasicInit = "true";
instanceIndex += 1;
const wrapper = document.createElement("div");
wrapper.className = "select-basic";
const ariaLabel = select.getAttribute("aria-label");
const listId = `select-basic-list-${instanceIndex}`;
const triggerId = `select-basic-trigger-${instanceIndex}`;
select.parentNode?.insertBefore(wrapper, select);
wrapper.appendChild(select);
select.classList.remove("select-basic");
select.classList.add("select-basic__native");
select.tabIndex = -1;
select.setAttribute("aria-hidden", "true");
const trigger = document.createElement("button");
trigger.type = "button";
trigger.id = triggerId;
trigger.className = "select-basic__trigger";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-controls", listId);
if (ariaLabel) {
trigger.setAttribute("aria-label", ariaLabel);
}
const list = document.createElement("ul");
list.id = listId;
list.className = "select-basic__list";
list.setAttribute("role", "listbox");
if (ariaLabel) {
list.setAttribute("aria-labelledby", triggerId);
}
list.hidden = true;
const optionItems = getSelectableOptions(select).map((option) => {
const li = document.createElement("li");
li.className = "select-basic__option";
li.setAttribute("role", "option");
li.tabIndex = -1;
li.textContent = option.textContent?.trim() ?? "";
li.dataset.index = String(option.index);
li.addEventListener("click", () => {
setSelectedIndex(select, option.index);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
closeList(wrapper, trigger, list);
trigger.focus();
});
list.appendChild(li);
return { li, option };
});
wrapper.append(trigger, list);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
trigger.addEventListener("click", () => {
const isOpen = wrapper.dataset.open === "true";
if (isOpen) {
closeList(wrapper, trigger, list);
return;
}
openList(wrapper, trigger, list, optionItems);
});
trigger.addEventListener("keydown", (event) => {
const isOpen = wrapper.dataset.open === "true";
if (
!isOpen &&
(event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " ")
) {
event.preventDefault();
openList(wrapper, trigger, list, optionItems);
return;
}
if (!isOpen) return;
const currentIndex = optionItems.findIndex(
({ li }) => li === document.activeElement,
);
if (event.key === "Escape") {
event.preventDefault();
closeList(wrapper, trigger, list);
trigger.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
const next = optionItems[currentIndex + 1] ?? optionItems[0];
next?.li.focus();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const next = optionItems[currentIndex - 1] ?? optionItems.at(-1);
next?.li.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const focused = optionItems[currentIndex];
if (!focused) return;
setSelectedIndex(select, focused.option.index);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
closeList(wrapper, trigger, list);
trigger.focus();
}
});
list.addEventListener("keydown", (event) => {
const currentIndex = optionItems.findIndex(
({ li }) => li === document.activeElement,
);
if (event.key === "Escape") {
event.preventDefault();
closeList(wrapper, trigger, list);
trigger.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
const next = optionItems[currentIndex + 1] ?? optionItems[0];
next?.li.focus();
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const next = optionItems[currentIndex - 1] ?? optionItems.at(-1);
next?.li.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const focused = optionItems[currentIndex];
if (!focused) return;
setSelectedIndex(select, focused.option.index);
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
closeList(wrapper, trigger, list);
trigger.focus();
}
});
select.addEventListener("change", () => {
updateTriggerText(trigger, select);
syncOptionStates(optionItems, select);
});
}
function initAll() {
bindDocumentClickListener();
document.querySelectorAll(".js-select-basic").forEach(initSelectBasic);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAll);
} else {
initAll();
}
})();