Skip to content

Dropdown

A trigger + popup menu, built as a single component with slots for each part (root, trigger, menu, item, divider). Open/closed state lives on the menu as a data-state attribute; per-item variants (e.g. a destructive "Delete") live on items as data-variant attributes. Runtime state lives on data attributes, not varia variants, so it can pair directly with the JavaScript that toggles them.

Authoring

ts
// recipes/dropdown.config.ts
import { defineComponent } from 'varia'

export default defineComponent('dropdown', {
  slots: {
    root: 'relative inline-block',
    trigger: [
      'inline-flex items-center justify-between gap-2 px-3 py-2',
      'rounded-md border border-gray-300 bg-white text-sm font-medium text-gray-700',
      'shadow-sm hover:bg-gray-50',
      'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
    ],
    menu: [
      'absolute z-10 mt-2 min-w-40 origin-top-right',
      'rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5',
      // Hidden by default; data-state=open reveals it (higher specificity wins).
      'hidden data-[state=open]:block',
    ],
    item: [
      'block w-full px-4 py-2 text-left text-sm text-gray-700',
      'hover:bg-gray-100 focus:bg-gray-100 focus:outline-none',
      'disabled:text-gray-400 disabled:cursor-not-allowed',
      // Per-item destructive variant via data-variant.
      'data-[variant=danger]:text-red-700 data-[variant=danger]:hover:bg-red-50',
      'data-[variant=danger]:focus:bg-red-50',
    ],
    divider: 'my-1 border-t border-gray-200',
  },
  variants: {
    align: {
      start: { menu: 'left-0' },
      end: { menu: 'right-0' },
    },
  },
})

Three things to call out:

  1. align is a varia variant rather than a data-attr because it's a build-time configuration choice (which side of the trigger the menu opens from), not a runtime state. The variant class goes on the root (<div class="dropdown dropdown-align-end">) because it's slot-keyed: it emits .dropdown-align-end .dropdown__menu { right: 0 }, a descendant rule that needs the alignment class on an ancestor.

  2. Open/closed is a data-state attribute on the menu. The base expansion includes hidden data-[state=open]:block, so the menu is hidden by default and revealed when data-state="open" is present. JS sets or clears that attribute. No varia variant exists for "open" because it's runtime state, not configuration.

  3. Per-item destructive styling is a data-variant attribute, not a separate slot or boolean variant. Adding more item modes later means more data-[variant=...]: rules in the item slot expansion, not new slots, so the slot list stays small.

Live preview

A dropdown rendered statically with data-state="open" so you can see all the slots at once:

Consumption

html
<div class="dropdown dropdown-align-end">
  <button class="dropdown__trigger" aria-haspopup="menu" aria-expanded="false">
    Options
    <svg>...chevron...</svg>
  </button>

  <div class="dropdown__menu" data-state="closed" role="menu">
    <button class="dropdown__item" role="menuitem">Edit</button>
    <button class="dropdown__item" role="menuitem">Duplicate</button>
    <hr class="dropdown__divider" />
    <button class="dropdown__item" data-variant="danger" role="menuitem">Delete</button>
  </div>
</div>

A minimal JS toggle that pairs with this markup:

ts
const trigger = document.querySelector('.dropdown__trigger')
const menu = document.querySelector('.dropdown__menu')

function setOpen(open: boolean) {
  menu.setAttribute('data-state', open ? 'open' : 'closed')
  trigger.setAttribute('aria-expanded', String(open))
}

trigger.addEventListener('click', () => {
  setOpen(menu.getAttribute('data-state') !== 'open')
})
document.addEventListener('click', (e) => {
  if (!menu.contains(e.target) && !trigger.contains(e.target))
    setOpen(false)
})

Generated class names

ClassElement
dropdownThe root wrapper (position: relative)
dropdown__triggerThe button that opens the menu
dropdown__menuThe popup container (hidden until data-state="open")
dropdown__itemA clickable menu row
dropdown__dividerA horizontal separator
dropdown-align-start / dropdown-align-endVariant on the root that anchors the menu's left or right edge

Six classes (plus the two data-attrs you set in markup) for trigger, menu, items, divider, and alignment.

Released under the MIT License.