Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Reusable UI Components

Building a modal, tabs, dropdown and toast you can drop into any app.

Almost every app needs the same handful of UI pieces: a modal, tabs, a dropdown menu and toast notifications. Write each one once, as a well-designed component, and every screen gets it for free.

This lesson builds all four. Each is small enough to read in one go, and each shows a pattern you'll use again:

Component Pattern it teaches
Modal v-model on a component, <Teleport>, keyboard handling, focus management
Tabs Parent/child coordination with provide / inject
Dropdown Scoped slots and "click outside to close"
Toast Shared state through a composable, <TransitionGroup>

What makes a component reusable

A reusable component is about its API, not its looks:

  • Props in, events out. It never reaches into its parent. The parent decides what happens.
  • v-model for open/selected state, so the parent can open, close or read it.
  • Slots for content. The component owns the structure, and the caller fills in the words.
  • Sensible defaults, so the simple case is one line.
  • Accessible by default: the right role, aria-* and keyboard support, written once.

A good test: could you drop this component into a different project without changing it? If it imports a store or calls an API directly, the answer is no. Move that work to the parent and pass the result in.

Modal

A modal needs to: render above everything (<Teleport to="body">), close on Escape or a backdrop click, move focus into the dialog when it opens, and give focus back to the button that opened it.

<!-- components/BaseModal.vue -->
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue';

const props = defineProps({ modelValue: Boolean, title: String });
const emit = defineEmits(['update:modelValue']);

const dialog = ref(null);
let opener = null;

function close() { emit('update:modelValue', false); }
function onKey(e) { if (e.key === 'Escape') close(); }

watch(() => props.modelValue, async (open) => {
    if (open) {
        opener = document.activeElement;           // remember who opened us
        document.addEventListener('keydown', onKey);
        await nextTick();
        dialog.value?.focus();                     // move focus into the dialog
    } else {
        document.removeEventListener('keydown', onKey);
        opener?.focus();                           // give focus back
    }
});
onUnmounted(() => document.removeEventListener('keydown', onKey));
</script>

<template>
    <Teleport to="body">
        <div v-if="modelValue" class="backdrop" @click.self="close">
            <div ref="dialog" role="dialog" aria-modal="true" :aria-label="title" tabindex="-1">
                <header>
                    <slot name="title">{{ title }}</slot>
                    <button aria-label="Close" @click="close">&times;</button>
                </header>
                <slot />
                <footer v-if="$slots.footer">
                    <slot name="footer" :close="close" />
                </footer>
            </div>
        </div>
    </Teleport>
</template>

Using it is one line plus your content. The footer is a scoped slot, so the caller's buttons can call close without the caller writing any close logic:

<BaseModal v-model="open" title="Delete project?">
    <p>This removes the project and its tasks.</p>
    <template #footer="{ close }">
        <button @click="close">Cancel</button>
        <button @click="confirmDelete(close)">Delete</button>
    </template>
</BaseModal>

Vue 3.4 added defineModel(), which replaces the modelValue prop + update:modelValue emit pair with one line: const open = defineModel(). The longhand above is what defineModel() generates, and it's still what you'll see in most codebases.

Tabs

Tabs are two components that must cooperate: TabGroup knows which tab is active, and each TabPanel needs to know whether it's the one. Passing props down by hand gets messy, so the group provides its state and each panel injects it.

<!-- TabGroup.vue -->
<script setup>
import { ref, computed, provide } from 'vue';

const props = defineProps({ modelValue: String });
const emit = defineEmits(['update:modelValue']);

const tabs = ref([]);
const active = computed({
    get: () => props.modelValue ?? tabs.value[0]?.key,
    set: (key) => emit('update:modelValue', key),
});

provide('tabs', {
    active,
    register: (tab) => tabs.value.push(tab),
    unregister: (key) => (tabs.value = tabs.value.filter((t) => t.key !== key)),
});
</script>

<template>
    <div role="tablist">
        <button v-for="tab in tabs" :key="tab.key" role="tab"
                :aria-selected="active === tab.key" @click="active = tab.key">
            {{ tab.label }}
        </button>
    </div>
    <slot />
</template>
<!-- TabPanel.vue -->
<script setup>
import { inject, computed, onUnmounted } from 'vue';

const props = defineProps({ name: { type: String, required: true }, label: String });
const group = inject('tabs');

group.register({ key: props.name, label: props.label ?? props.name });
onUnmounted(() => group.unregister(props.name));

const isActive = computed(() => group.active.value === props.name);
</script>

<template>
    <div v-show="isActive" role="tabpanel"><slot /></div>
</template>

The panels register themselves, so the caller just writes the markup:

<TabGroup v-model="current">
    <TabPanel name="overview" label="Overview">…</TabPanel>
    <TabPanel name="tasks" label="Tasks">…</TabPanel>
</TabGroup>

v-show (not v-if) keeps each panel's DOM and state alive when you switch away. A half-filled form in the Settings tab survives a trip to Overview and back.

Dropdown menu

A dropdown's tricky part is closing: on Escape, after a choice, and when the user clicks anywhere outside it. The component listens on document and checks whether the click landed inside its own root element.

<!-- BaseDropdown.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

defineProps({ label: { type: String, default: 'Options' } });

const open = ref(false);
const root = ref(null);

function onDocClick(e) {
    if (root.value && !root.value.contains(e.target)) open.value = false;
}
function onKey(e) { if (e.key === 'Escape') open.value = false; }

onMounted(() => {
    document.addEventListener('click', onDocClick);
    document.addEventListener('keydown', onKey);
});
onUnmounted(() => {
    document.removeEventListener('click', onDocClick);
    document.removeEventListener('keydown', onKey);
});
</script>

<template>
    <div ref="root" class="dropdown">
        <button :aria-expanded="open" aria-haspopup="menu" @click="open = !open">{{ label }} ▾</button>
        <ul v-if="open" role="menu">
            <slot :close="() => (open = false)" />
        </ul>
    </div>
</template>
<BaseDropdown label="Task actions" v-slot="{ close }">
    <li v-for="action in actions" :key="action">
        <button role="menuitem" @click="run(action); close()">{{ action }}</button>
    </li>
</BaseDropdown>

The "listen on document, check contains()" logic is needed by dropdowns, popovers, date pickers and search suggestions. Rather than copy it, turn it into a v-click-outside directive. The Custom Directives & Plugins lesson builds exactly that.

Toast notifications

Toasts are different: any component, anywhere, needs to say "Saved!" without being a parent of the toast list. The fix is module-level state. The toasts array lives outside the function, so every caller of useToast() shares it, and one <ToastContainer> near the root renders it.

// composables/useToast.js
import { ref } from 'vue';

const toasts = ref([]);   // module scope: one list shared by the whole app
let nextId = 1;

export function useToast() {
    function dismiss(id) {
        toasts.value = toasts.value.filter((t) => t.id !== id);
    }

    function show(message, type = 'info', timeout = 3000) {
        const id = nextId++;
        toasts.value.push({ id, message, type });
        setTimeout(() => dismiss(id), timeout);
    }

    return {
        toasts,
        dismiss,
        success: (m) => show(m, 'success'),
        error: (m) => show(m, 'error', 5000),   // errors stay longer
        info: (m) => show(m, 'info'),
    };
}
<!-- ToastContainer.vue, mounted once in App.vue -->
<script setup>
import { useToast } from '@/composables/useToast';
const { toasts, dismiss } = useToast();
</script>

<template>
    <Teleport to="body">
        <TransitionGroup name="toast" tag="div" class="toasts" aria-live="polite">
            <div v-for="t in toasts" :key="t.id" :class="['toast', `toast--${t.type}`]">
                {{ t.message }}
                <button aria-label="Dismiss" @click="dismiss(t.id)">&times;</button>
            </div>
        </TransitionGroup>
    </Teleport>
</template>

Any component can then do:

const toast = useToast();

async function save() {
    try {
        await api.saveTask(task);
        toast.success('Task saved');
    } catch {
        toast.error('Could not reach the server. Try again.');
    }
}

aria-live="polite" makes screen readers announce each new toast without interrupting the user. In a large app you might move this state into a Pinia store. For a single list like this, a composable is enough.

An accordion component

An accordion (a list of collapsible sections) is another everyday component. This version allows one open section at a time. Change openIndex to an array to allow several, or edit the items list:

Designing a badge component's API

Before building a component, sketch how it will be called. This playground renders a "status badge" from a small map of variants. Add a blocked variant, or change the colours, and press Run:

Key takeaways

  • Design the API first: props in, events out, v-model for open/selected state, slots for content.
  • Modals belong in <Teleport to="body">, close on Escape and backdrop click, and move focus in and back out.
  • Cooperating components (tabs, accordions, forms) share state with provide / inject instead of long prop chains.
  • "Click outside to close" is document listener + root.contains(e.target), removed in onUnmounted.
  • App-wide UI like toasts uses module-level state in a composable, rendered once by a container component.
  • Add role, aria-* and keyboard support once in the component, and every screen that uses it gets them.