Dropdown
Expandable menu of selectable options.
Preview
Code
<div class="grid gap-32px relative">
<button
id="dropdown-default-button"
data-dropdown-toggle="dropdown-default"
class="flex gap-4px items-center flex gap-8px items-center justify-center whitespace-nowrap py-4px px-16px text-btn2 [&>svg]:w-16px [&>svg]:h-16px rounded-8px text-white dark:text-black-900 border disabled:opacity-30 focus:shadow-[0_0_0_2px_#FFFFFF,0_0_0_4px_#0F172A] dark:focus:shadow-[0_0_0_2px_#2d2e33,0_0_0_4px_#FFFFFF] bg-black border-black hover:bg-black-700 hover:border-black-700 focus:bg-black-700 focus:border-black-700 active:bg-black-500 active:border-black-500 dark:bg-white dark:border-white dark:hover:bg-black-200 dark:hover:border-black-200 dark:focus:bg-black-200 dark:focus:border-black-200 dark:active:bg-black-400 dark:active:border-black-400"
type="button">
Click dropdown
<svg
class="w-[10px] h-[10px] ms-8px"
aria_hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewbox="0 0 10 6">
<path
stroke="currentColor"
stroke_linecap="round"
stroke_linejoin="round"
stroke_width="2"
d="m1 1 4 4 4-4">
</path>
</svg>
</button>
<!-- Dropdown menu -->
<div
id="dropdown-default"
class="z-10 hidden flex flex-col gap-2px bg-background dark:bg-black-800 rounded-12px shadow-[0px_6px_20px_0px_#00000017] w-[240px] p-12px">
<ul
class="flex flex-col gap-2px text-p2 text-foreground max-h-[180px] overflow-auto"
aria-labelledby="dropdown-default-button">
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
</ul>
</div>
</div>
Hover
Trigger the dropdown on hover by adding data-dropdown-trigger="hover" to the button. The menu will appear after a short delay (configurable) and stay open while hovering over the menu itself.
Preview
Code
<div class="grid gap-32px relative">
<button
id="dropdown-default-button_hover"
data-dropdown-toggle="dropdown-default_hover"
data-dropdown-trigger="hover"
class="flex gap-4px items-center flex gap-8px items-center justify-center whitespace-nowrap py-4px px-16px text-btn2 [&>svg]:w-16px [&>svg]:h-16px rounded-8px text-white dark:text-black-900 border disabled:opacity-30 focus:shadow-[0_0_0_2px_#FFFFFF,0_0_0_4px_#0F172A] dark:focus:shadow-[0_0_0_2px_#2d2e33,0_0_0_4px_#FFFFFF] bg-black border-black hover:bg-black-700 hover:border-black-700 focus:bg-black-700 focus:border-black-700 active:bg-black-500 active:border-black-500 dark:bg-white dark:border-white dark:hover:bg-black-200 dark:hover:border-black-200 dark:focus:bg-black-200 dark:focus:border-black-200 dark:active:bg-black-400 dark:active:border-black-400"
type="button">
Hover dropdown
<svg
class="w-[10px] h-[10px] ms-8px"
aria_hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewbox="0 0 10 6">
<path
stroke="currentColor"
stroke_linecap="round"
stroke_linejoin="round"
stroke_width="2"
d="m1 1 4 4 4-4">
</path>
</svg>
</button>
<!-- Dropdown menu -->
<div
id="dropdown-default_hover"
class="z-10 hidden flex flex-col gap-2px bg-background dark:bg-black-800 rounded-12px shadow-[0px_6px_20px_0px_#00000017] w-[240px] p-12px">
<ul
class="flex flex-col gap-2px text-p2 text-foreground max-h-[180px] overflow-auto"
aria-labelledby="dropdown-default-button_hover">
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
<li>
<a
href="#"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700">
Button
</a>
</li>
</ul>
</div>
</div>
Checkbox
Dropdowns can contain checkboxes for multi‑select options.
Preview
Code
<div class="relative">
<button
id="dropdown-checkbox-button"
data-dropdown-toggle="dropdown-checkbox"
class="flex gap-4px items-center flex gap-8px items-center justify-center whitespace-nowrap py-4px px-16px text-btn2 [&>svg]:w-16px [&>svg]:h-16px rounded-8px text-white dark:text-black-900 border disabled:opacity-30 focus:shadow-[0_0_0_2px_#FFFFFF,0_0_0_4px_#0F172A] dark:focus:shadow-[0_0_0_2px_#2d2e33,0_0_0_4px_#FFFFFF] bg-black border-black hover:bg-black-700 hover:border-black-700 focus:bg-black-700 focus:border-black-700 active:bg-black-500 active:border-black-500 dark:bg-white dark:border-white dark:hover:bg-black-200 dark:hover:border-black-200 dark:focus:bg-black-200 dark:focus:border-black-200 dark:active:bg-black-400 dark:active:border-black-400"
type="button">
Checkbox dropdown
<svg
class="w-[10px] h-[10px] ms-8px"
aria_hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewbox="0 0 10 6">
<path
stroke="currentColor"
stroke_linecap="round"
stroke_linejoin="round"
stroke_width="2"
d="m1 1 4 4 4-4">
</path>
</svg>
</button>
<!-- Dropdown menu -->
<div
id="dropdown-checkbox"
class="z-10 hidden flex flex-col gap-2px bg-background dark:bg-black-800 rounded-12px shadow-[0px_6px_20px_0px_#00000017] w-[240px] p-12px">
<ul
class="flex flex-col gap-2px text-p2 text-foreground max-h-[180px] overflow-auto"
aria-labelledby="dropdown-checkbox-button">
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700">
<label class="flex gap-8px text-p2 items-center relative has-disabled:cursor-no-drop cursor-pointer [&>svg]:left-[15px] w-full py-4px px-12px gap-12px!">
<input
type="checkbox"
name="checkbox-0"
class="peer w-16px h-16px rounded-4px appearance-none border transition-all disabled:bg-black-50 disabled:border-black-100 cursor-pointer bg-white border-black-300 hover:border-black active:border-black checked:border-black checked:bg-black checked:hover:bg-black-800 select-none"
onchange="this.closest('button').dataset.checked = this.checked.toString();"
autocomplete="off">
</input>
<svg
width="10"
height="8"
viewbox="0 0 10 8"
fill="none"
class="absolute left-[3px] hidden fill-white peer-checked:flex">
<path d="M3.84217 5.34246C3.72002 5.46514 3.5214 5.46514 3.39926 5.34246L1.63392 3.5693C1.33263 3.26667 0.842723 3.26667 0.541434 3.5693C0.242022 3.87003 0.242022 4.35622 0.541433 4.65696L3.1778 7.305C3.42209 7.55038 3.81933 7.55038 4.06362 7.30501L9.4584 1.88633C9.75781 1.58559 9.75781 1.0994 9.4584 0.79866C9.15711 0.496037 8.66721 0.496037 8.36592 0.798661L3.84217 5.34246Z">
</path>
</svg>
<span class="peer-disabled:text-black-500">
Button
</span>
</label>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700">
<label class="flex gap-8px text-p2 items-center relative has-disabled:cursor-no-drop cursor-pointer [&>svg]:left-[15px] w-full py-4px px-12px gap-12px!">
<input
type="checkbox"
name="checkbox-1"
class="peer w-16px h-16px rounded-4px appearance-none border transition-all disabled:bg-black-50 disabled:border-black-100 cursor-pointer bg-white border-black-300 hover:border-black active:border-black checked:border-black checked:bg-black checked:hover:bg-black-800 select-none"
onchange="this.closest('button').dataset.checked = this.checked.toString();"
autocomplete="off">
</input>
<svg
width="10"
height="8"
viewbox="0 0 10 8"
fill="none"
class="absolute left-[3px] hidden fill-white peer-checked:flex">
<path d="M3.84217 5.34246C3.72002 5.46514 3.5214 5.46514 3.39926 5.34246L1.63392 3.5693C1.33263 3.26667 0.842723 3.26667 0.541434 3.5693C0.242022 3.87003 0.242022 4.35622 0.541433 4.65696L3.1778 7.305C3.42209 7.55038 3.81933 7.55038 4.06362 7.30501L9.4584 1.88633C9.75781 1.58559 9.75781 1.0994 9.4584 0.79866C9.15711 0.496037 8.66721 0.496037 8.36592 0.798661L3.84217 5.34246Z">
</path>
</svg>
<span class="peer-disabled:text-black-500">
Button
</span>
</label>
</button>
</li>
<li>
<button
type="button"
data-checked="true"
class="flex gap-12px rounded-12px w-full items-center text-p2 hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700">
<label class="flex gap-8px text-p2 items-center relative has-disabled:cursor-no-drop cursor-pointer [&>svg]:left-[15px] w-full py-4px px-12px gap-12px!">
<input
type="checkbox"
name="checkbox-2"
class="peer w-16px h-16px rounded-4px appearance-none border transition-all disabled:bg-black-50 disabled:border-black-100 cursor-pointer bg-white border-black-300 hover:border-black active:border-black checked:border-black checked:bg-black checked:hover:bg-black-800 select-none"
checked="checked"
onchange="this.closest('button').dataset.checked = this.checked.toString();"
autocomplete="off">
</input>
<svg
width="10"
height="8"
viewbox="0 0 10 8"
fill="none"
class="absolute left-[3px] hidden fill-white peer-checked:flex">
<path d="M3.84217 5.34246C3.72002 5.46514 3.5214 5.46514 3.39926 5.34246L1.63392 3.5693C1.33263 3.26667 0.842723 3.26667 0.541434 3.5693C0.242022 3.87003 0.242022 4.35622 0.541433 4.65696L3.1778 7.305C3.42209 7.55038 3.81933 7.55038 4.06362 7.30501L9.4584 1.88633C9.75781 1.58559 9.75781 1.0994 9.4584 0.79866C9.15711 0.496037 8.66721 0.496037 8.36592 0.798661L3.84217 5.34246Z">
</path>
</svg>
<span class="peer-disabled:text-black-500">
Button
</span>
</label>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700">
<label class="flex gap-8px text-p2 items-center relative has-disabled:cursor-no-drop cursor-pointer [&>svg]:left-[15px] w-full py-4px px-12px gap-12px!">
<input
type="checkbox"
name="checkbox-3"
class="peer w-16px h-16px rounded-4px appearance-none border transition-all disabled:bg-black-50 disabled:border-black-100 cursor-pointer bg-white border-black-300 hover:border-black active:border-black checked:border-black checked:bg-black checked:hover:bg-black-800 select-none"
onchange="this.closest('button').dataset.checked = this.checked.toString();"
autocomplete="off">
</input>
<svg
width="10"
height="8"
viewbox="0 0 10 8"
fill="none"
class="absolute left-[3px] hidden fill-white peer-checked:flex">
<path d="M3.84217 5.34246C3.72002 5.46514 3.5214 5.46514 3.39926 5.34246L1.63392 3.5693C1.33263 3.26667 0.842723 3.26667 0.541434 3.5693C0.242022 3.87003 0.242022 4.35622 0.541433 4.65696L3.1778 7.305C3.42209 7.55038 3.81933 7.55038 4.06362 7.30501L9.4584 1.88633C9.75781 1.58559 9.75781 1.0994 9.4584 0.79866C9.15711 0.496037 8.66721 0.496037 8.36592 0.798661L3.84217 5.34246Z">
</path>
</svg>
<span class="peer-disabled:text-black-500">
Button
</span>
</label>
</button>
</li>
</ul>
</div>
</div>
Search
Preview
Code
Search dropdown
Button
Button
Button
Button
Button
Button
Button
<div class="relative">
<button
id="dropdown-search-button"
data-dropdown-toggle="dropdown-search"
class="flex gap-4px items-center flex gap-8px items-center justify-center whitespace-nowrap py-4px px-16px text-btn2 [&>svg]:w-16px [&>svg]:h-16px rounded-8px text-white dark:text-black-900 border disabled:opacity-30 focus:shadow-[0_0_0_2px_#FFFFFF,0_0_0_4px_#0F172A] dark:focus:shadow-[0_0_0_2px_#2d2e33,0_0_0_4px_#FFFFFF] bg-black border-black hover:bg-black-700 hover:border-black-700 focus:bg-black-700 focus:border-black-700 active:bg-black-500 active:border-black-500 dark:bg-white dark:border-white dark:hover:bg-black-200 dark:hover:border-black-200 dark:focus:bg-black-200 dark:focus:border-black-200 dark:active:bg-black-400 dark:active:border-black-400"
type="button">
Search dropdown
<svg
class="w-[10px] h-[10px] ms-8px"
aria_hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewbox="0 0 10 6">
<path
stroke="currentColor"
stroke_linecap="round"
stroke_linejoin="round"
stroke_width="2"
d="m1 1 4 4 4-4">
</path>
</svg>
</button>
<!-- Dropdown menu -->
<div
id="dropdown-search"
class="z-10 hidden flex flex-col gap-2px bg-background dark:bg-black-800 rounded-12px shadow-[0px_6px_20px_0px_#00000017] w-[240px] p-12px">
<div class="flex flex-col pb-8px">
<label class="flex flex-col gap-4px">
<div class="flex flex-col relative">
<input
type="text"
name="dropdown-search"
placeholder="Search"
class="flex gap-8px items-center text-p2 peer text-black py-8px rounded-8px border placeholder:text-black-500 outline-none transition-all dark:text-white dark:placeholder:text-black-400 ps-[40px] pe-16px bg-white border-black-200 hover:border-black-600 focus:border-black-600 active:border-black-600 user-invalid:border-red-600 user-invalid:hover:border-red-600 user-invalid:focus:border-red-600 user-invalid:active:border-red-600 user-invalid:bg-red-50 dark:bg-black dark:border-black-600 dark:hover:border-black-400 dark:focus:border-black-400 dark:active:border-black-400 dark:user-invalid:bg-red-900 dark:user-invalid:bg-black shadow-[0_1px_2px_0px_#0000000D] focus:shadow-[0_0_0_2px_#0F172A33] dark:shadow-[0_1px_2px_0px_rgba(255,255,255,0.06)] dark:focus:shadow-[0_0_0_2px_rgba(255,255,255,0.33)] disabled:bg-black-50 disabled:border-black-100 dark:disabled:bg-black-800 dark:disabled:border-black-700 disabled:shadow-none"
pattern="[a-zA-Z0-9_]+"
value=""
input_classes="">
</input>
<span class="absolute left-16px top-[14px] pointer-events-none">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_48_1939)">
<path
d="M6.66667 11.3333C9.244 11.3333 11.3333 9.244 11.3333 6.66667C11.3333 4.08934 9.244 2 6.66667 2C4.08934 2 2 4.08934 2 6.66667C2 9.244 4.08934 11.3333 6.66667 11.3333Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round">
</path>
<path
d="M14 14L10 10"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</g>
</svg>
</span>
</div>
</label>
</div>
<ul
class="flex flex-col gap-2px text-p2 text-foreground max-h-[180px] overflow-auto"
aria-labelledby="dropdown-search-button">
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
<li>
<button
type="button"
data-checked="true"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
<li>
<button
type="button"
data-checked="false"
class="flex gap-12px rounded-12px w-full items-center text-p2 py-4px px-12px hover:bg-black-50 dark:hover:bg-black-700 group justify-between data-[checked=true]:bg-black-50 dark:data-[checked=true]:bg-black-700"
onclick="this.dataset.checked = this.dataset.checked === 'true' ? 'false' : 'true'">
Button
<span class="hidden group-data-[checked=true]:flex">
<svg
width="16"
height="16"
viewbox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.33337 8.00033L6.66671 11.3337L13.3334 4.66699"
stroke="#8A8C97"
stroke-linecap="round"
stroke-linejoin="round">
</path>
</svg>
</span>
</button>
</li>
</ul>
</div>
</div>
Usage
Include the JavaScript code in your project:
class Dropdown {
#trigger;
#menu;
#options;
#visible = false;
#hover_timer = null;
#original_parent = null;
#using_portal = false;
#bound_reposition = null;
#outside_click_handler = null;
constructor(trigger, menu, options = {}) {
// Elements
this.#trigger = trigger;
this.#menu = menu;
this.#original_parent = menu.parentNode;
this.#bound_reposition = this.#set_position.bind(this);
// Default + user options
this.#options = {
placement: "bottom", // 'top' | 'bottom' | 'left' | 'right'
trigger_type: "click", // 'click' | 'hover'
offset_skidding: 0, // X offset
offset_distance: 10, // Y offset
delay: 300, // hover delay
ignore_click_outside_class: false, // ignore outside clicks for this class
use_portal: undefined, // true | false | undefined (auto)
on_show: () => {},
on_hide: () => {},
on_toggle: () => {},
...options,
};
this.#init();
}
/** Initialize dropdown */
#init() {
if (!this.#trigger || !this.#menu) return;
this.#menu.classList.add("hidden");
this.#menu.setAttribute("aria-hidden", "true");
this.#setup_triggers();
}
/** Setup event listeners */
#setup_triggers() {
const type = this.#options.trigger_type;
if (type === "click") {
this.#trigger.addEventListener("click", () => this.toggle());
}
if (type === "hover") {
const delay = this.#options.delay;
this.#trigger.addEventListener("mouseenter", () => {
clearTimeout(this.#hover_timer);
this.#hover_timer = setTimeout(() => this.show(), delay);
});
this.#trigger.addEventListener("mouseleave", () => {
this.#hover_timer = setTimeout(() => this.hide(), delay);
});
this.#menu.addEventListener("mouseenter", () => clearTimeout(this.#hover_timer));
this.#menu.addEventListener("mouseleave", () => {
this.#hover_timer = setTimeout(() => this.hide(), delay);
});
}
}
/** Check whether to use portal (<body> placement) */
#should_use_portal() {
const user_setting = this.#options.use_portal;
if (typeof user_setting === "boolean") return user_setting;
// Auto-detect layout context (overflow/transform)
let el = this.#trigger.parentElement;
while (el && el !== document.body) {
const style = getComputedStyle(el);
if (
/(hidden|auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY) ||
style.transform !== "none" ||
style.perspective !== "none"
) {
return true;
}
el = el.parentElement;
}
return false;
}
/** Attach global click listener for outside clicks */
#listen_outside_clicks() {
this.#outside_click_handler = (e) => {
const clicked = e.target;
const ignore_class = this.#options.ignore_click_outside_class;
const ignored =
ignore_class &&
[...document.querySelectorAll(`.${ignore_class}`)].some((el) => el.contains(clicked));
if (
this.#menu.contains(clicked) &&
clicked.dataset.dropdownHide === "true"
) {
this.hide();
} else if (
!this.#trigger.contains(clicked) &&
!this.#menu.contains(clicked) &&
!ignored &&
this.#visible
) {
this.hide();
}
};
document.addEventListener("click", this.#outside_click_handler, true);
}
/** Remove global click listener */
#remove_outside_click_listener() {
document.removeEventListener("click", this.#outside_click_handler, true);
}
/** Positioning logic with auto-flip */
#set_position() {
if (!this.#trigger || !this.#menu) return;
const trigger_rect = this.#trigger.getBoundingClientRect();
// Measure dropdown (temporarily visible)
const was_hidden =
this.#menu.classList.contains("hidden") || this.#menu.style.display === "none";
if (was_hidden) {
Object.assign(this.#menu.style, { visibility: "hidden", display: "block" });
}
const menu_rect = this.#menu.getBoundingClientRect();
const height = menu_rect.height || 200;
const width = menu_rect.width;
const { innerHeight: inner_height } = window;
if (was_hidden) {
Object.assign(this.#menu.style, { visibility: "", display: "" });
}
const scroll_y = window.scrollY || document.documentElement.scrollTop;
const scroll_x = window.scrollX || document.documentElement.scrollLeft;
let { placement, offset_skidding: dx, offset_distance: dy } = this.#options;
let x = trigger_rect.left + dx + scroll_x;
let y = trigger_rect.bottom + dy + scroll_y;
// Auto-flip
if (placement === "bottom") {
const space_below = inner_height - trigger_rect.bottom;
if (space_below < height + dy) placement = "top";
} else if (placement === "top") {
const space_above = trigger_rect.top;
if (space_above < height + dy) placement = "bottom";
}
switch (placement) {
case "top":
y = trigger_rect.top - height - dy + scroll_y;
break;
case "left":
x = trigger_rect.left - width - dy + scroll_x;
y = trigger_rect.top + scroll_y + dx;
break;
case "right":
x = trigger_rect.right + dy + scroll_x;
y = trigger_rect.top + scroll_y + dx;
break;
default:
y = trigger_rect.bottom + dy + scroll_y;
}
Object.assign(this.#menu.style, {
position: "absolute",
left: `${x}px`,
top: `${y}px`,
zIndex: "1000",
});
}
/** Toggle dropdown */
toggle() {
this.#visible ? this.hide() : this.show();
this.#options.on_toggle(this);
}
/** Show dropdown */
show() {
if (this.#visible) return;
this.#visible = true;
const portal = this.#should_use_portal();
this.#using_portal = portal;
if (portal) {
document.body.appendChild(this.#menu);
this.#set_position();
window.addEventListener("resize", this.#bound_reposition);
window.addEventListener("scroll", this.#bound_reposition, true);
} else {
this.#set_position();
}
this.#menu.classList.remove("hidden");
this.#menu.classList.add("block");
this.#menu.removeAttribute("aria-hidden");
this.#trigger.setAttribute("aria-expanded", "true");
this.#listen_outside_clicks();
this.#options.on_show(this);
}
/** Hide dropdown */
hide() {
if (!this.#visible) return;
this.#visible = false;
this.#menu.classList.remove("block");
this.#menu.classList.add("hidden");
this.#menu.setAttribute("aria-hidden", "true");
this.#trigger.setAttribute("aria-expanded", "false");
this.#remove_outside_click_listener();
if (this.#using_portal) {
window.removeEventListener("resize", this.#bound_reposition);
window.removeEventListener("scroll", this.#bound_reposition, true);
this.#original_parent.appendChild(this.#menu);
this.#using_portal = false;
}
this.#options.on_hide(this);
}
/** Update callback functions dynamically */
update_on_show(cb) { this.#options.on_show = cb; }
update_on_hide(cb) { this.#options.on_hide = cb; }
update_on_toggle(cb) { this.#options.on_toggle = cb; }
}
/** Initialize all dropdowns on the page */
const init_dropdowns = (options = {}) => {
document.querySelectorAll("[data-dropdown-toggle]").forEach((trigger) => {
const id = trigger.getAttribute("data-dropdown-toggle");
const menu = document.getElementById(id);
trigger.setAttribute("aria-expanded", "false");
if (!menu) {
console.error(`Dropdown "${id}" not found.`);
return;
}
const opt = {
placement: trigger.dataset.dropdownPlacement || "bottom",
trigger_type: trigger.dataset.dropdownTrigger || "click",
offset_skidding: +trigger.dataset.dropdownOffsetSkidding || 0,
offset_distance: +trigger.dataset.dropdownOffsetDistance || 10,
delay: +trigger.dataset.dropdownDelay || 300,
ignore_click_outside_class: trigger.dataset.dropdownIgnoreClickOutsideClass || false,
use_portal:
trigger.dataset.dropdownPortal === "true"
? true
: trigger.dataset.dropdownPortal === "false"
? false
: undefined,
...options,
};
new Dropdown(trigger, menu, opt);
});
};
window.init_dropdowns = init_dropdowns;
Then initialise all dropdowns on the page:
init_dropdowns();
You can pass global options directly to the function – see the Params section .
Auto‑initialisation
Add this at the end of your dropdown.js file:
document.addEventListener("DOMContentLoaded", () => init_dropdowns());
Then link the script in your HTML:
<script src="dropdown.js"></script>
Note: For a hover‑triggered dropdown, add data-dropdown-trigger="hover" to the button element.
Params
The init_dropdowns() function accepts an optional configuration object:
init_dropdowns({
// Defines where the dropdown will appear relative to the trigger element.
placement: "bottom", // string: 'top' | 'bottom' | 'left' | 'right'
// Determines how the dropdown is opened: on click or when hovering.
trigger_type: "click", // string: 'click' | 'hover'
// Moves dropdown slightly left/right from its trigger.
offset_skidding: 0, // number: horizontal (X-axis) offset in pixels
// Moves dropdown slightly above/below its trigger.
offset_distance: 10, // number: vertical (Y-axis) offset in pixels
// Used to avoid flickering when moving between trigger and menu.
delay: 300, // number: delay in milliseconds for hover-triggered dropdowns
// CSS class name; clicking inside elements with this class won't close the dropdown.
ignore_click_outside_class: false, // string | false
// true → dropdown always appended to <body>.
// false → dropdown stays inside its container.
// undefined → automatically decides based on layout (hybrid mode).
use_portal: undefined, // boolean | undefined: true | false | undefined
// Called when the dropdown is shown.
on_show: () => {}, // function(Dropdown instance)
// Called when the dropdown is hidden.
on_hide: () => {}, // function(Dropdown instance)
// Called whenever dropdown visibility changes (show/hide).
on_toggle: () => {}, // function(Dropdown instance)
});
Data attributes
You can override options per dropdown using data attributes on the trigger element:
data-dropdown-placement – string: 'top', 'bottom', 'left', 'right' (default: 'bottom')
data-dropdown-offset-skidding – number: horizontal offset in pixels
data-dropdown-offset-distance – number: vertical offset in pixels
data-dropdown-trigger – string: 'click' or 'hover'
data-dropdown-delay – number: delay in ms for hover trigger
data-dropdown-ignore-click-outside-class – string: CSS class name; clicking inside elements with this class won’t close the dropdown
data-dropdown-portal – string: 'true', 'false', or omit for auto detection
These attributes take precedence over the global options passed to init_dropdowns().