<select class="select w-[180px]">
<optgroup label="Fruits">
<option>Apple</option>
<option>Banana</option>
<option>Blueberry</option>
<option>Grapes</option>
<option>Pineapple</option>
</select>
<script>
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function (Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data("select", (name = null, initialValue = null) => ({
open: false,
name: null,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: "",
init() {
this.$nextTick(() => {
if (name) this.name = name;
this.options = Array.from(this.$el.querySelectorAll("[role=option]:not([aria-disabled])"));
this.disabledOptions = Array.from(this.$el.querySelectorAll("[role=option][aria-disabled=true]"));
if (this.options.length === 0) return;
if (initialValue) {
const option = this.options.find((opt) => opt.getAttribute("data-value") === initialValue);
this.selectOption(option, false);
this.focusedIndex = this.options.indexOf(option);
} else {
this.selectOption(this.options[0], false);
}
});
},
focusOption() {
if (this.options.length === 0) return;
if (this.focusedIndex >= this.options.length) {
this.focusedIndex = this.options.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.options.forEach((opt) => opt.removeAttribute("data-focus"));
const option = this.options[this.focusedIndex];
option.setAttribute("data-focus", "");
option.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
focusOnSelectedOption() {
if (this.options.length === 0 || this.selectedValue === null) return;
const option = this.options.find((opt) => opt.getAttribute("data-value") === this.selectedValue);
if (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
if (!this.open) {
this.open = true;
this.focusOnSelectedOption();
} else {
this.focusedIndex = this.focusedIndex === null ? 0 : this.focusedIndex + delta;
}
this.focusOption();
},
handleOptionClick(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.selectOption(option);
this.open = false;
this.$nextTick(() => this.$refs.trigger.focus());
}
},
handleOptionMousemove(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
}
},
handleOptionEnter(event) {
this.selectOption(this.options[this.focusedIndex]);
this.open = !this.open;
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute("data-value") == null) {
return;
}
this.options.forEach((opt) => {
opt.setAttribute("aria-selected", opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute("data-value");
if (dispatch) {
this.$dispatch("select:change", {
value: this.selectedValue,
label: this.selectedLabel,
});
this.$dispatch("change");
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach((opt) => {
opt.setAttribute("aria-hidden", "true");
});
} else {
this.disabledOptions.forEach((opt) => {
opt.removeAttribute("aria-hidden");
});
}
this.options.forEach((opt) => {
opt.removeAttribute("aria-hidden");
if (opt.getAttribute("data-value") != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
opt.setAttribute("aria-hidden", "true");
}
});
},
$trigger: {
"@click"() {
this.open = !this.open;
this.focusOnSelectedOption();
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
if (this.open) this.handleOptionEnter();
else this.open = true;
},
":aria-expanded"() {
return this.open;
},
"x-ref": "trigger",
},
$content: {
"@click"(e) {
this.handleOptionClick(e);
},
"@mouseover"(e) {
this.handleOptionMousemove(e);
},
":aria-hidden"() {
return !this.open;
},
"x-cloak": "",
},
$filter: {
"@input"(e) {
this.filterOptions(e.target.value);
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
this.handleOptionEnter();
},
},
}));
};
document.addEventListener("alpine:init", () => {
window.basecoat.registerSelect(Alpine);
});
</script>
<div class="popover" x-data="select('select-js', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[180px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<div role="listbox" aria-orientation="vertical">
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
<div role="option" data-value="apple">Apple</div>
<div role="option" data-value="banana">Banana</div>
<div role="option" data-value="blueberry">Blueberry</div>
<div role="option" data-value="pineapple">Grapes</div>
<div role="option" data-value="pineapple">Pineapple</div>
</div>
</div>
</div>
<input type="hidden" name="select-js" x-model="selectedValue" />
</div>
Usage
HTML
If you use a <select>
element, just add the select
class to it or have a parent with the form
class (read more about form).
<select class="select w-[180px]">
<optgroup label="Fruits">
<option>Apple</option>
<option>Banana</option>
<option>Blueberry</option>
<option>Grapes</option>
<option>Pineapple</option>
</select>
HTML + Javascript
If you need to do more than what <select>
allows (e.g. HTML options), you can use this component. It is structured as such:
- A
<div class="popover">
which wraps around the entire component and holds it state (e.g. open/close). - A
<button>
that acts as the trigger to open or close the popover. - A
<div data-popover>
that holds the content of the popover. - Inside of the
<div data-popover>
is a<nav role="listbox">
that contains a combination of:<div role="option">
for the options with adata-value
attribute.<hr role="separator">
to display a horizontal separator.<div role="group">
to group options.<span role="heading">
for group headings.
- An
<input type="hidden">
to hold the value of the field (if needed).
As with the Popover component, you can set up a few additional options on the <div data-popover>
element:
data-side
can be set totop
,bottom
,left
, orright
to change the side of the popover.data-align
can be set tostart
,center
, orend
to change the alignment of the popover.
You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. x-bind
, x-data
, @click
).
<div class="popover" x-data="select('select-js', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[180px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<div role="listbox" aria-orientation="vertical">
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
<div role="option" data-value="apple">Apple</div>
<div role="option" data-value="banana">Banana</div>
<div role="option" data-value="blueberry">Blueberry</div>
<div role="option" data-value="pineapple">Grapes</div>
<div role="option" data-value="pineapple">Pineapple</div>
</div>
</div>
</div>
<input type="hidden" name="select-js" x-model="selectedValue" />
</div>
<script>
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function (Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data("select", (name = null, initialValue = null) => ({
open: false,
name: null,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: "",
init() {
this.$nextTick(() => {
if (name) this.name = name;
this.options = Array.from(this.$el.querySelectorAll("[role=option]:not([aria-disabled])"));
this.disabledOptions = Array.from(this.$el.querySelectorAll("[role=option][aria-disabled=true]"));
if (this.options.length === 0) return;
if (initialValue) {
const option = this.options.find((opt) => opt.getAttribute("data-value") === initialValue);
this.selectOption(option, false);
this.focusedIndex = this.options.indexOf(option);
} else {
this.selectOption(this.options[0], false);
}
});
},
focusOption() {
if (this.options.length === 0) return;
if (this.focusedIndex >= this.options.length) {
this.focusedIndex = this.options.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.options.forEach((opt) => opt.removeAttribute("data-focus"));
const option = this.options[this.focusedIndex];
option.setAttribute("data-focus", "");
option.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
focusOnSelectedOption() {
if (this.options.length === 0 || this.selectedValue === null) return;
const option = this.options.find((opt) => opt.getAttribute("data-value") === this.selectedValue);
if (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
if (!this.open) {
this.open = true;
this.focusOnSelectedOption();
} else {
this.focusedIndex = this.focusedIndex === null ? 0 : this.focusedIndex + delta;
}
this.focusOption();
},
handleOptionClick(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.selectOption(option);
this.open = false;
this.$nextTick(() => this.$refs.trigger.focus());
}
},
handleOptionMousemove(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
}
},
handleOptionEnter(event) {
this.selectOption(this.options[this.focusedIndex]);
this.open = !this.open;
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute("data-value") == null) {
return;
}
this.options.forEach((opt) => {
opt.setAttribute("aria-selected", opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute("data-value");
if (dispatch) {
this.$dispatch("select:change", {
value: this.selectedValue,
label: this.selectedLabel,
});
this.$dispatch("change");
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach((opt) => {
opt.setAttribute("aria-hidden", "true");
});
} else {
this.disabledOptions.forEach((opt) => {
opt.removeAttribute("aria-hidden");
});
}
this.options.forEach((opt) => {
opt.removeAttribute("aria-hidden");
if (opt.getAttribute("data-value") != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
opt.setAttribute("aria-hidden", "true");
}
});
},
$trigger: {
"@click"() {
this.open = !this.open;
this.focusOnSelectedOption();
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
if (this.open) this.handleOptionEnter();
else this.open = true;
},
":aria-expanded"() {
return this.open;
},
"x-ref": "trigger",
},
$content: {
"@click"(e) {
this.handleOptionClick(e);
},
"@mouseover"(e) {
this.handleOptionMousemove(e);
},
":aria-hidden"() {
return !this.open;
},
"x-cloak": "",
},
$filter: {
"@input"(e) {
this.filterOptions(e.target.value);
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
this.handleOptionEnter();
},
},
}));
};
document.addEventListener("alpine:init", () => {
window.basecoat.registerSelect(Alpine);
});
</script>
The component will dispatch a select:change
event when a value is selected, along with the value and label of the selected option in the detail
object.
Jinja and Nunjucks
You can use the dropdown_menu()
Nunjucks or Jinja macro for this component.
{% call select(name="select-js") %}
<div role="group" aria-labelledby="fruit-options-label">
<span id="fruit-options-label" role="heading">Fruits</span>
<div role="option" data-value="apple">Apple</div>
<div role="option" data-value="banana">Banana</div>
<div role="option" data-value="blueberry">Blueberry</div>
<div role="option" data-value="pineapple">Grapes</div>
<div role="option" data-value="pineapple">Pineapple</div>
</div>
{% endcall %}
Examples
Scrollable
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<div role="listbox" aria-orientation="vertical" class="scrollbar overflow-y-auto max-h-64">
<div role="option" data-value="item-0">Item 0</div>
<div role="option" data-value="item-1">Item 1</div>
<div role="option" data-value="item-2">Item 2</div>
<div role="option" data-value="item-3">Item 3</div>
<div role="option" data-value="item-4">Item 4</div>
<div role="option" data-value="item-5">Item 5</div>
<div role="option" data-value="item-6">Item 6</div>
<div role="option" data-value="item-7">Item 7</div>
<div role="option" data-value="item-8">Item 8</div>
<div role="option" data-value="item-9">Item 9</div>
<div role="option" data-value="item-10">Item 10</div>
<div role="option" data-value="item-11">Item 11</div>
<div role="option" data-value="item-12">Item 12</div>
<div role="option" data-value="item-13">Item 13</div>
<div role="option" data-value="item-14">Item 14</div>
<div role="option" data-value="item-15">Item 15</div>
<div role="option" data-value="item-16">Item 16</div>
<div role="option" data-value="item-17">Item 17</div>
<div role="option" data-value="item-18">Item 18</div>
<div role="option" data-value="item-19">Item 19</div>
<div role="option" data-value="item-20">Item 20</div>
<div role="option" data-value="item-21">Item 21</div>
<div role="option" data-value="item-22">Item 22</div>
<div role="option" data-value="item-23">Item 23</div>
<div role="option" data-value="item-24">Item 24</div>
<div role="option" data-value="item-25">Item 25</div>
<div role="option" data-value="item-26">Item 26</div>
<div role="option" data-value="item-27">Item 27</div>
<div role="option" data-value="item-28">Item 28</div>
<div role="option" data-value="item-29">Item 29</div>
<div role="option" data-value="item-30">Item 30</div>
<div role="option" data-value="item-31">Item 31</div>
<div role="option" data-value="item-32">Item 32</div>
<div role="option" data-value="item-33">Item 33</div>
<div role="option" data-value="item-34">Item 34</div>
<div role="option" data-value="item-35">Item 35</div>
<div role="option" data-value="item-36">Item 36</div>
<div role="option" data-value="item-37">Item 37</div>
<div role="option" data-value="item-38">Item 38</div>
<div role="option" data-value="item-39">Item 39</div>
<div role="option" data-value="item-40">Item 40</div>
<div role="option" data-value="item-41">Item 41</div>
<div role="option" data-value="item-42">Item 42</div>
<div role="option" data-value="item-43">Item 43</div>
<div role="option" data-value="item-44">Item 44</div>
<div role="option" data-value="item-45">Item 45</div>
<div role="option" data-value="item-46">Item 46</div>
<div role="option" data-value="item-47">Item 47</div>
<div role="option" data-value="item-48">Item 48</div>
<div role="option" data-value="item-49">Item 49</div>
<div role="option" data-value="item-50">Item 50</div>
<div role="option" data-value="item-51">Item 51</div>
<div role="option" data-value="item-52">Item 52</div>
<div role="option" data-value="item-53">Item 53</div>
<div role="option" data-value="item-54">Item 54</div>
<div role="option" data-value="item-55">Item 55</div>
<div role="option" data-value="item-56">Item 56</div>
<div role="option" data-value="item-57">Item 57</div>
<div role="option" data-value="item-58">Item 58</div>
<div role="option" data-value="item-59">Item 59</div>
<div role="option" data-value="item-60">Item 60</div>
<div role="option" data-value="item-61">Item 61</div>
<div role="option" data-value="item-62">Item 62</div>
<div role="option" data-value="item-63">Item 63</div>
<div role="option" data-value="item-64">Item 64</div>
<div role="option" data-value="item-65">Item 65</div>
<div role="option" data-value="item-66">Item 66</div>
<div role="option" data-value="item-67">Item 67</div>
<div role="option" data-value="item-68">Item 68</div>
<div role="option" data-value="item-69">Item 69</div>
<div role="option" data-value="item-70">Item 70</div>
<div role="option" data-value="item-71">Item 71</div>
<div role="option" data-value="item-72">Item 72</div>
<div role="option" data-value="item-73">Item 73</div>
<div role="option" data-value="item-74">Item 74</div>
<div role="option" data-value="item-75">Item 75</div>
<div role="option" data-value="item-76">Item 76</div>
<div role="option" data-value="item-77">Item 77</div>
<div role="option" data-value="item-78">Item 78</div>
<div role="option" data-value="item-79">Item 79</div>
<div role="option" data-value="item-80">Item 80</div>
<div role="option" data-value="item-81">Item 81</div>
<div role="option" data-value="item-82">Item 82</div>
<div role="option" data-value="item-83">Item 83</div>
<div role="option" data-value="item-84">Item 84</div>
<div role="option" data-value="item-85">Item 85</div>
<div role="option" data-value="item-86">Item 86</div>
<div role="option" data-value="item-87">Item 87</div>
<div role="option" data-value="item-88">Item 88</div>
<div role="option" data-value="item-89">Item 89</div>
<div role="option" data-value="item-90">Item 90</div>
<div role="option" data-value="item-91">Item 91</div>
<div role="option" data-value="item-92">Item 92</div>
<div role="option" data-value="item-93">Item 93</div>
<div role="option" data-value="item-94">Item 94</div>
<div role="option" data-value="item-95">Item 95</div>
<div role="option" data-value="item-96">Item 96</div>
<div role="option" data-value="item-97">Item 97</div>
<div role="option" data-value="item-98">Item 98</div>
</div>
</div>
</div>
Disabled
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal" disabled="disabled">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<div role="listbox" aria-orientation="vertical">
<div role="option" data-value="disabled">Disabled</div>
</div>
</div>
</div>
With icon
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[180px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down text-muted-foreground opacity-50 shrink-0"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<div role="listbox" aria-orientation="vertical">
<div type="button" role="option" data-value="bar">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M7 16h8" />
<path d="M7 11h12" />
<path d="M7 6h3" />
</svg>
Bar
</div>
<div type="button" role="option" data-value="line">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="m19 9-5 5-4-4-3 3" />
</svg>
Line
</div>
<div type="button" role="option" data-value="pie">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<path d="M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z" />
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
</svg>
Pie
</div>
</div>
</div>
</div>