Vue UI primitives are the small, durable components that make a product interface consistent without making every screen look or behave the same. A good primitive owns semantics and interaction details, exposes a deliberate API, and leaves product composition and visual decisions to the teams using it.
In this guide, we will build a practical foundation for Vue UI primitives using a dialog as the running example. The same approach applies to buttons, menus, popovers, comboboxes, tabs, drawers, and form controls.
Before we begin, make sure we have:
- A Vue 3 application using Single File Components and
script setup - A place for shared UI code, such as
src/ui/primitives - A styling approach, whether CSS variables, Tailwind, CSS modules, or a design-token layer
- A keyboard test plan and a browser accessibility inspection workflow
The goal is testable: by the end, we will have a primitive that has a stable public contract, handles keyboard interaction intentionally, can be styled without rewriting its behavior, and is easy to document for other developers.

Table of contents
- 1. Start with the behavior your primitive must own
- 2. Design a small Vue API that composes cleanly
- 3. Make accessibility part of the primitive contract
- 4. Keep behavior, styling, and product composition separate
- 5. Compose primitives into a feature without leaking feature logic downward
- 6. Test the contract at the interaction level
- 7. Document the primitive, then scale it into application patterns
- Build your next Vue screen from contracts, not copies
1. Start with the behavior your primitive must own
We begin by naming the job, not the markup. A dialog primitive is responsible for opening and closing, moving focus predictably, exposing an accessible name, preventing accidental interaction with the background when it is modal, and returning focus after close. Product-specific content, copy, form fields, and visual treatment stay outside the primitive.
Write a short behavior contract before creating a component. For our dialog, the contract is:
- It is controlled through
v-model:open. - It accepts a required accessible
title. - It provides slots for trigger, content, and footer.
- It closes on Escape and an explicit close action.
- It restores focus to the trigger after close.
- It does not decide whether the product uses a billing form, deletion confirmation, or onboarding flow.
Expected result: a teammate can tell what belongs inside BaseDialog and what belongs in the feature that consumes it.
Troubleshooting: if the primitive needs props such as invoiceId, userRole, or saveCustomer, it is probably absorbing feature logic. Move those concerns up to the consuming screen.
2. Design a small Vue API that composes cleanly
Vue props declare the external data a component accepts, while slots let consumers supply richer markup and nested components. We use both to keep the primitive opinionated about behavior but flexible about content.
Here is a compact public API for a dialog primitive:
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
const props = defineProps<{
open: boolean
title: string
closeOnBackdrop?: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const triggerRef = ref<HTMLElement | null>(null)
const dialogRef = ref<HTMLDialogElement | null>(null)
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
function openDialog() {
triggerRef.value = document.activeElement as HTMLElement
isOpen.value = true
}
function closeDialog() {
isOpen.value = false
}
watch(isOpen, async (open) => {
if (open) {
await nextTick()
dialogRef.value?.showModal()
dialogRef.value?.querySelector<HTMLElement>('[data-autofocus]')?.focus()
} else if (dialogRef.value?.open) {
dialogRef.value.close()
triggerRef.value?.focus()
}
})
</script>
<template>
<slot name="trigger" :open="openDialog" />
<dialog
ref="dialogRef"
:aria-label="title"
@cancel.prevent="closeDialog"
@click.self="closeOnBackdrop !== false && closeDialog()"
>
<header>
<h2>{{ title }}</h2>
<button type="button" aria-label="Close dialog" @click="closeDialog">
<span aria-hidden="true">×</span>
</button>
</header>
<slot :close="closeDialog" />
<footer v-if="$slots.footer">
<slot name="footer" :close="closeDialog" />
</footer>
</dialog>
</template>
Expected result: consumers can use v-model:open, render their own trigger and content, and call close from a slot when a task succeeds.
Troubleshooting: do not expose every possible HTML attribute as a first-class prop. Start with the inputs that change behavior. Pass presentation classes or attributes through only when there is a real consumer need.
3. Make accessibility part of the primitive contract
Accessibility is not a final styling pass. For interactive primitives, it is behavior that must be designed and verified. A modal dialog needs a meaningful label, focus moved into the dialog when it opens, keyboard focus contained while it is active, Escape-to-close behavior, and a logical focus destination when it closes.
For dialogs, prefer the native HTML <dialog> element when it fits the browser support and product requirements. It provides useful platform behavior, but we still need to test our focus target, close paths, and return focus. When we build menus, tabs, comboboxes, or custom listboxes, we should use the relevant WAI-ARIA Authoring Practices pattern as the acceptance criteria rather than approximating keyboard behavior from memory.
Verification check: open the dialog with the keyboard, press Tab repeatedly, press Shift+Tab, press Escape, then confirm focus returns to the trigger. Repeat with a mouse or touch device. Test both a short confirmation dialog and a long form dialog, because initial focus may need to land on a heading instead of the first control in a dense form.
Troubleshooting: if the visible dialog works but a screen reader can still reach the page behind it, or Tab reaches controls outside the modal, do not ship it. Fix the modal behavior or use a vetted primitive that already implements the pattern.

4. Keep behavior, styling, and product composition separate
A primitive becomes reusable when visual styling can change without reimplementing focus management, ARIA relationships, events, and keyboard logic. We recommend maintaining three distinct layers:
- Behavior layer: state transitions, semantics, focus management, keyboard handlers, and emitted events.
- Vue wrapper layer: typed props,
v-model, slots, Vue lifecycle coordination, and composable markup. - Product layer: design tokens, utility classes, layouts, feature copy, data loading, and business rules.
This is the approach behind DOM Studio’s headless layer: framework-neutral controls carry interaction and accessibility behavior, while Vue wrappers and editable styles can sit above them. It is especially useful when we want an application-specific design language without rebuilding expensive interaction patterns for every product surface.
If a polished suite is the better fit, tools such as PrimeVue package a broad set of styled and configurable Vue components. If we need low-level unstyled foundations, Reka UI is another Vue-focused option. The decision is not about which library is universally best. It is about whether our team needs editable source and blocks, an unstyled accessible base, or a prebuilt component suite.
Expected result: changing border radius, spacing, color tokens, or layout classes does not alter keyboard behavior or event semantics.
Troubleshooting: if a styling change requires copying the dialog internals into a feature component, the boundary is too rigid. Add a slot, a class hook, or a token at the right layer instead of duplicating behavior.

5. Compose primitives into a feature without leaking feature logic downward
Now use the dialog primitive in a real feature. The parent owns the workflow state and the save action. The primitive owns open state coordination and accessible dialog behavior.
<script setup lang="ts">
import { ref } from 'vue'
import BaseDialog from '@/ui/primitives/BaseDialog.vue'
const inviteOpen = ref(false)
const email = ref('')
async function sendInvite() {
await fetch('/api/invitations', {
method: 'POST',
body: JSON.stringify({ email: email.value }),
})
inviteOpen.value = false
email.value = ''
}
</script>
<template>
<BaseDialog v-model:open="inviteOpen" title="Invite a teammate">
<template #trigger="{ open }">
<button type="button" @click="open">Invite teammate</button>
</template>
<form @submit.prevent="sendInvite">
<label for="invite-email">Email address</label>
<input id="invite-email" v-model="email" data-autofocus type="email" required />
<div class="actions">
<button type="button" @click="inviteOpen = false">Cancel</button>
<button type="submit">Send invite</button>
</div>
</form>
</BaseDialog>
</template>
Expected result: the invitation workflow remains easy to read, while the dialog can be reused for unrelated workflows.
Troubleshooting: avoid putting network requests, validation schemas, analytics names, and feature-specific error messages into BaseDialog. Those vary by workflow and belong in the parent or a feature-level component.
6. Test the contract at the interaction level
A primitive is not complete because it renders. We test the interactions consumers rely on. Start with these checks for every interactive primitive:
- Keyboard: expected keys work and unexpected keys do not trigger destructive actions.
- Focus: opening, closing, disabled states, and dynamic content all leave focus somewhere sensible.
- Semantics: native elements are used where possible, labels and descriptions resolve correctly, and state is exposed to assistive technology.
- Events: emitted values and
v-modelupdates are stable and documented. - Slots and styling: consumer content renders in the expected location without breaking layout or accessibility.
- Responsive behavior: overlays still work within scroll containers, narrow viewports, and zoomed pages.
We should run the manual keyboard pass alongside automated unit and end-to-end checks. DOM Studio’s component playground illustrates another valuable documentation practice: expose the primitive’s props and show the resulting rendered component and source together. That shortens the feedback loop for developers, designers, and AI-assisted workflows.
Verification check: write one test for the public API and one test for the important user behavior. For example, assert that update:open fires when Escape is pressed, then use a browser test to prove that focus returns to the trigger after close.
Troubleshooting: a snapshot that only confirms markup is not enough for a dialog, menu, or combobox. Add interaction tests before expanding the component API.
7. Document the primitive, then scale it into application patterns
A UI primitive should be easier to adopt than to reimplement. For every primitive, document:
- The problem it solves and when not to use it
- Props, events, slots, defaults, and controlled versus uncontrolled state
- Keyboard behavior and accessibility expectations
- One minimal example and one realistic composition example
- Styling hooks, design tokens, and limitations
- Test cases that must remain true during refactors
Then compose approved primitives into product-level blocks. A reusable application layout block can combine navigation, cards, dropdowns, and form controls without turning the layout itself into a low-level primitive. This distinction keeps the system understandable: primitives solve interaction problems, while blocks solve recurring product-screen problems.
For teams that want source-aware documentation and editable building blocks, DOM Studio combines headless controls, Vue wrappers, form tools, component metadata, playgrounds, and application blocks in one library. That makes it practical to start with a dialog or field, then grow into complete product surfaces without abandoning the same underlying primitives.
Expected result: a new developer can find the primitive, understand its contract, use it correctly, and compose it into a feature without reading its implementation.
Troubleshooting: if the documentation has many screenshots but no keyboard notes, API examples, or failure cases, it is marketing material rather than usable component documentation. Add the missing operational detail.
Build your next Vue screen from contracts, not copies
We now have a repeatable method for Vue UI primitives: define the behavior contract, create a compact Vue API, make accessibility testable, keep styling separate, compose features above the primitive, verify interaction behavior, and document the result.
Start with the two or three interaction patterns your application repeats most often, usually buttons, dialogs, and form fields. Stabilize those contracts before adding dozens of components. If we want an editable starting point with headless behavior, Vue wrappers, live component metadata, and production-shaped blocks, DOM Studio gives us a practical path from primitive to application UI.
