BuDropdown
Bulma’s .dropdown with the JavaScript it never shipped: toggle, outside-click and Escape dismissal, and the aria wiring between trigger and menu.
Reference: Dropdown on bulma.io↗︎ — classes and visual variants. This page is the JavaScript.
Usage
Compose three parts: BuDropdown renders .dropdown and owns the open state, BuDropdownTrigger wraps your own button, and BuDropdownMenu renders .dropdown-menu with its .dropdown-content. v-model is the single source of truth for open and closed.
Positioning stays Bulma’s. The menu is placed by CSS against the trigger, so there is no floating engine to install and nothing measured at runtime — right and up are the two knobs. What the component adds is the part Bulma leaves to you: the open state, the aria wiring between trigger and menu, click-outside and Escape dismissal.
Anatomy
<script setup lang="ts">
import { BuDropdown, BuDropdownMenu, BuDropdownTrigger } from '@paper/bulma'
</script>
<template>
<BuDropdown>
<BuDropdownTrigger />
<BuDropdownMenu />
</BuDropdown>
</template>Composed on v0
Skips v0’s Popover entirely. Popover.Content hardwires popover="", which promotes the menu to the top layer and sets UA margin: unset — both fight Bulma’s in-flow .dropdown-menu { position: absolute }. Open state is a boolean v-model plus useClickOutside and a local Escape handler bound to the dropdown subtree, not the document: a dropdown inside BuModal must not close the modal on the first Escape.
Trigger aria is hand-bound (aria-haspopup, aria-expanded, aria-controls) because v0’s Toggle only emits aria-pressed. hoverable is CSS-only — is-hoverable on .dropdown, no JS listeners.
Collision-aware placement is Popover, and it will not give you Bulma’s markup.
The markup you know
The Bulma tab is the markup published on bulma.io↗︎, captured verbatim in the conformance fixture. The Vue tab is the component that renders it. The conformance suite diffs the two on every test run — the generated id and its matching aria-controls are the only tolerated difference.
<div class="dropdown is-active">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span>Dropdown button</span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a href="#" class="dropdown-item"> Dropdown item </a>
<a href="#" class="dropdown-item is-active"> Active dropdown item </a>
<hr class="dropdown-divider" />
<a href="#" class="dropdown-item"> With a divider </a>
</div>
</div>
</div><template>
<BuDropdown menu>
<BuDropdownTrigger v-slot="{ attrs }">
<button class="button" type="button" v-bind="attrs">
<span>Dropdown button</span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true" />
</span>
</button>
</BuDropdownTrigger>
<BuDropdownMenu v-slot="{ close, item }">
<a class="dropdown-item" v-bind="item" @click="close">Dropdown item</a>
<a class="dropdown-item is-active" v-bind="item" @click="close">Active dropdown item</a>
<hr class="dropdown-divider">
<a class="dropdown-item" v-bind="item" @click="close">With a divider</a>
</BuDropdownMenu>
</BuDropdown>
</template>You write no is-active and no id/aria-controls pair. The component owns the open class and generates a unique id, binding both ends of it for you.
Examples
Props
BuDropdown renders .dropdown and owns the open state and the modifiers; the regions are parts.
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | boolean | false | Open state |
hoverable | boolean | false | is-hoverable — Bulma’s CSS-only hover mode; wires no JavaScript |
right | boolean | false | is-right — align the menu to the right edge |
up | boolean | false | is-up — open the menu upwards |
menu | boolean | false | Emit role="menu"; only for dropdowns whose items are all actionable |
| Part | Renders | Slot props |
|---|---|---|
BuDropdownTrigger | div.dropdown-trigger | isOpen, toggle, attrs |
BuDropdownMenu | div.dropdown-menu + div.dropdown-content | isOpen, close, item |
Accessibility
BuDropdownTrigger hands out aria-haspopup, aria-expanded and an aria-controls that points at the id BuDropdownMenu generates, so the trigger and the menu it controls are wired to each other without you tracking an id.
Dismissal
Three ways out, and one deliberate non-way:
| Gesture | Behavior |
|---|---|
| Click outside | Closes. The listener is attached only while the dropdown is open |
| Escape | Closes the nearest open dropdown, and stops there |
| Click inside | Does not close — call the close slot prop from the entries that should |
Hover out (hoverable) | Closes, in CSS; no listener is ever attached |
Escape is bound to the dropdown’s own subtree rather than the document, and the handler stops propagation once it has closed something. That is what lets a dropdown live inside an open BuModal: pressing Escape closes the dropdown and leaves the modal open, and pressing it again closes the modal. A document-level handler would collapse both at once — and would swallow every Escape on the page while the dropdown sat there closed.
Menu semantics
role="menu" is opt-in through the menu prop, and it is a promise about the children: every entry must carry role="menuitem", which is what the item slot prop applies. Set the prop when the dropdown is a list of actions or links; leave it off when the menu holds arbitrary content, where the role would fail aria-required-children.[1]
Bulma’s documented arbitrary-content dropdown ships
role="menu"arounddiv.dropdown-itemprose, which is the axe failure this policy avoids — one of the declared deviations on the Bulma overview. ↩︎