HeadlessUI Vue
- Overview
HeadlessUI provides completely unstyled, fully accessible UI components for Vue 3. Components handle all the complex accessibility and interaction logic — you provide all the styling. 30 playground examples are available via MCP.
All HeadlessUI Vue examples are available through the frontend-components MCP server under the headlessui-vue framework.
- Installation
npm install @headlessui/vue
Optional (for icons):
npm install @heroicons/vue
- MCP Workflow
3.1 Browse Available Examples
list_components(framework: "headlessui-vue")
Components: combobox, dialog, disclosure, focus-trap, listbox, menu, popover, radio-group, switch, tabs, combinations.
3.2 Get Example Code
get_component(framework: "headlessui-vue", category: "components", component_type: "dialog", variant: "Dialog") get_component(framework: "headlessui-vue", category: "components", component_type: "menu", variant: "Menu")
3.3 Search
search_components(query: "listbox", framework: "headlessui-vue") search_components(query: "disclosure", framework: "headlessui-vue")
- Core API Patterns
4.1 Compound Components
HeadlessUI Vue uses compound component patterns with slots:
<template> <Menu> <MenuButton class="...">Options</MenuButton> <MenuItems class="..."> <MenuItem v-slot="{ active }"> <a :class="{ 'bg-gray-100': active }" href="/edit">Edit</a> </MenuItem> <MenuItem v-slot="{ active }"> <a :class="{ 'bg-gray-100': active }" href="/delete">Delete</a> </MenuItem> </MenuItems> </Menu> </template>
<script setup> import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue"; </script>
4.2 The as Prop
Change the rendered element:
<MenuButton as="div" class="...">Options</MenuButton> <MenuItem as="button" class="...">Edit</MenuItem> <DialogPanel as="form" class="..." @submit.prevent="handleSubmit">
4.3 Slot Props
HeadlessUI Vue exposes state through slot props for v-slot binding:
<MenuItem v-slot="{ active, disabled }"> <a :class="{ 'bg-blue-100': active, 'opacity-50': disabled }"> Edit </a> </MenuItem>
<Switch v-model="enabled" :class="enabled ? 'bg-blue-600' : 'bg-gray-200'" class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full"
<span :class="enabled ? 'translate-x-5' : 'translate-x-0.5'" class="..." /> </Switch>
4.4 Data Attributes
Like the React version, state is also exposed via data attributes:
<MenuItem> <a class="data-[focus]:bg-blue-100 data-[disabled]:opacity-50" href="/edit"> Edit </a> </MenuItem>
Key slot props and data attributes:
Slot Prop Data Attribute Components
active
data-[focus]
MenuItem, ListboxOption, ComboboxOption
selected
data-[selected]
ListboxOption, ComboboxOption, Tab
checked
data-[checked]
Switch, RadioGroupOption
open
data-[open]
Disclosure, Popover, Menu
disabled
data-[disabled]
Most components
- Component Reference
5.1 Dialog (Modal)
<template> <Dialog :open="isOpen" @close="isOpen = false" class="relative z-50"> <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> <div class="fixed inset-0 flex items-center justify-center p-4"> <DialogPanel class="max-w-lg rounded bg-white p-6 shadow-xl"> <DialogTitle class="text-lg font-bold">Title</DialogTitle> <p>Content here</p> <button @click="isOpen = false">Close</button> </DialogPanel> </div> </Dialog> </template>
<script setup> import { ref } from "vue"; import { Dialog, DialogPanel, DialogTitle } from "@headlessui/vue";
const isOpen = ref(false); </script>
5.2 Listbox (Select)
<template> <Listbox v-model="selected"> <ListboxButton class="...">{{ selected.name }}</ListboxButton> <ListboxOptions anchor="bottom" class="..."> <ListboxOption v-for="option in options" :key="option.id" :value="option" v-slot="{ active, selected }" class="cursor-pointer px-4 py-2" :class="{ 'bg-blue-100': active }" > <span :class="{ 'font-bold': selected }">{{ option.name }}</span> </ListboxOption> </ListboxOptions> </Listbox> </template>
<script setup> import { ref } from "vue"; import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from "@headlessui/vue";
const options = [ { id: 1, name: "Option A" }, { id: 2, name: "Option B" }, ]; const selected = ref(options[0]); </script>
5.3 Combobox (Autocomplete)
<template> <Combobox v-model="selected" @update:modelValue="query = ''"> <ComboboxInput class="..." :displayValue="(o) => o?.name" @change="query = $event.target.value" /> <ComboboxButton class="...">▼</ComboboxButton> <ComboboxOptions anchor="bottom" class="..."> <ComboboxOption v-for="option in filtered" :key="option.id" :value="option" v-slot="{ active }" :class="{ 'bg-blue-100': active }" class="px-4 py-2" > {{ option.name }} </ComboboxOption> </ComboboxOptions> </Combobox> </template>
<script setup> import { ref, computed } from "vue"; import { Combobox, ComboboxInput, ComboboxButton, ComboboxOptions, ComboboxOption } from "@headlessui/vue";
const query = ref(""); const selected = ref(null); const options = [/* ... */]; const filtered = computed(() => query.value === "" ? options : options.filter((o) => o.name.toLowerCase().includes(query.value.toLowerCase())) ); </script>
5.4 Switch (Toggle)
<template> <Switch v-model="enabled" :class="enabled ? 'bg-blue-600' : 'bg-gray-200'" class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors"
<span
:class="enabled ? 'translate-x-5' : 'translate-x-0.5'"
class="pointer-events-none inline-block size-5 rounded-full bg-white shadow transition-transform"
/>
</Switch> </template>
<script setup> import { ref } from "vue"; import { Switch } from "@headlessui/vue";
const enabled = ref(false); </script>
5.5 Disclosure (Accordion)
<template> <Disclosure v-slot="{ open }"> <DisclosureButton class="flex w-full justify-between rounded-lg bg-gray-100 px-4 py-2"> Section Title <ChevronUpIcon :class="{ 'rotate-180': !open }" class="size-5 transition-transform" /> </DisclosureButton> <DisclosurePanel class="px-4 py-2 text-gray-500"> Content here </DisclosurePanel> </Disclosure> </template>
<script setup> import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue"; </script>
5.6 Popover
<template> <Popover class="relative"> <PopoverButton class="...">Info</PopoverButton> <PopoverPanel anchor="bottom" class="..."> Popover content </PopoverPanel> </Popover> </template>
<script setup> import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue"; </script>
5.7 RadioGroup
<template> <RadioGroup v-model="selected"> <RadioGroupOption v-for="option in options" :key="option.id" :value="option" v-slot="{ checked }" :class="{ 'bg-blue-50 border-blue-500': checked }" class="cursor-pointer rounded-lg border px-5 py-4" > <RadioGroupLabel class="font-medium">{{ option.name }}</RadioGroupLabel> <RadioGroupDescription class="text-gray-500">{{ option.desc }}</RadioGroupDescription> </RadioGroupOption> </RadioGroup> </template>
<script setup> import { ref } from "vue"; import { RadioGroup, RadioGroupOption, RadioGroupLabel, RadioGroupDescription } from "@headlessui/vue";
const selected = ref(null); const options = [/* ... */]; </script>
5.8 Tabs
<template> <TabGroup> <TabList class="flex gap-4"> <Tab v-for="tab in tabs" :key="tab" v-slot="{ selected }" :class="{ 'bg-blue-100 text-blue-700': selected }" class="rounded-full px-3 py-1" > {{ tab }} </Tab> </TabList> <TabPanels class="mt-3"> <TabPanel v-for="tab in tabs" :key="tab"> {{ tab }} content </TabPanel> </TabPanels> </TabGroup> </template>
<script setup> import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@headlessui/vue";
const tabs = ["Tab 1", "Tab 2", "Tab 3"]; </script>
- Transitions
6.1 TransitionRoot and TransitionChild
<template> <TransitionRoot :show="isOpen" as="template"> <Dialog @close="isOpen = false"> <TransitionChild enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0" > <div class="fixed inset-0 bg-black/25" /> </TransitionChild>
<TransitionChild
enter="ease-out duration-300"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="...">Content</DialogPanel>
</TransitionChild>
</Dialog>
</TransitionRoot> </template>
6.2 Built-in Transition
Components that support transitions can use the transition prop with data attributes:
<MenuItems transition class="transition duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0"
- Floating UI (Positioning)
Control dropdown positioning with the anchor prop:
<MenuItems anchor="bottom start"> <!-- Below, left-aligned --> <MenuItems anchor="bottom end"> <!-- Below, right-aligned --> <ListboxOptions anchor="top start"> <!-- Above, left-aligned -->
Add offset: :anchor="{ to: 'bottom', gap: 4 }" .
- Vue-Specific Patterns
8.1 v-model Support
Components with state support v-model :
<Switch v-model="enabled" /> <Listbox v-model="selected" /> <Combobox v-model="selected" /> <RadioGroup v-model="plan" /> <TabGroup :selectedIndex="index" @change="index = $event" />
8.2 Form Integration
<Listbox v-model="selected" name="country"> <!-- renders hidden input with selected value --> </Listbox>
8.3 Multiple Selection
<Listbox v-model="selected" multiple> <!-- selected is an array --> </Listbox>
- Accessibility Features
HeadlessUI handles automatically:
-
ARIA attributes (role , aria-expanded , aria-haspopup , aria-labelledby )
-
Keyboard navigation (arrow keys, Enter, Space, Escape, Home, End)
-
Focus management (trapping in dialogs, return focus on close)
-
Screen reader announcements
-
Click-outside-to-close
- Workflow Summary
Step Action
-
Pick component Dialog, Menu, Listbox, Combobox, Switch, etc.
-
Fetch example get_component(framework: "headlessui-vue", ...)
-
Import import { Component } from "@headlessui/vue"
-
Style with Tailwind Use class binding + slot props or data attributes
-
Add transitions Use TransitionRoot/TransitionChild or transition prop
-
Position Use anchor prop for dropdowns/popovers
-
v-model Bind state with v-model for controlled components