Custom directives and plugins
Reuse low-level DOM work with a custom directive, and set up app-wide features once with a plugin and app.use().
What you'll learn
When you finish, you can do these.- 1Write a custom directive that focuses a field as it opens
- 2Register a directive for the app or for one component
- 3Build a toast plugin and install it with app.use()
- 4Choose a directive, a plugin or a composable
Picture a profile page. Its Name field holds Ada and stays locked until you click Edit, and an Add a note button turns into a note field. Each field should get the cursor, or focus, as it opens. Other forms want the same, so the code that focuses a field gets copied from component to component. The page also shows a toast, the small notice that says Saved Ada. The whole app has one toast, and any component can show it.
Vue has a tool for each. A custom directive, a v- attribute like v-if that you write yourself, does work on one element: here v-focus. A plugin sets up a feature once for the whole app: here the toast. The Code tab holds that page; by the end you can read every file and add an option to the toast. You need one idea from Provide and inject: a component can provide() a value under a key, and any component nested inside it can inject() it.
A directive does low-level work on one element
A custom directive is work on one page element, such as focusing it, written once for every template to use. It is an object of hooks, functions that Vue calls at moments in the element's life: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount and unmounted.
Each hook receives the element and a binding: binding.value is the value of what you wrote after the equals sign, and binding.oldValue the value before the last update. focus.ts in the Code tab holds the directive, vFocus, with two hooks:
export const vFocus: Directive<HTMLElement, boolean | undefined> = {
mounted(el, binding) {
if (binding.value !== false) el.focus()
},
updated(el, binding) {
if (binding.value && !binding.oldValue) el.focus()
},
}
mounted runs once, when the element is on the page, and updated each time the component re-renders. App.vue, the profile page, uses the directive on two inputs. The note field carries plain v-focus, with no value, and only false stops the focus, so it gets the cursor as it appears.
The name field stays on the page, read-only until Edit sets the ref editing to true. It carries v-focus="editing", so mounted sees false and does nothing. updated compares the new value with the old one, and when editing turns true, the cursor lands in the field.
When mounted and updated should do the same thing, a directive can be one function that Vue calls for both, like this one that colours text: const vColor: Directive<HTMLElement, string> = (el, binding) => { el.style.color = binding.value }.
main.ts, the file that creates the app with createApp(App), registers the directive for every component: app.directive('focus', vFocus) names it v-focus. For one component only, import it in that component's <script setup>: a variable named v plus a capital letter, such as vFocus, works as v-focus in its template.
A plugin sets up the app once
The toast needs one message that the whole app shares, which is a plugin's job. A plugin is an object with an install(app, options) method, or a function that is the install function itself. app.use(plugin, options) calls it once with your app and options.
- createApp
- app.useinstall runs once
- app.provide / app.directive
- app.mount
- components inject
Inside install, a plugin can register directives and components, and provide values to every component with app.provide(), the app-wide form of provide(). Pinia and Vue Router are plugins too.
The Code tab's toast.ts holds the toast plugin. Its install creates the message once, as a ref, with a show() that sets it and clears it after options.duration milliseconds, and provides both:
export const toastPlugin = {
install(app: App, options: ToastOptions) {
const message = ref('')
let timer: ReturnType<typeof setTimeout> | undefined
function show(text: string) {
message.value = text
clearTimeout(timer)
timer = setTimeout(() => { message.value = '' }, options.duration)
}
app.provide(toastKey, { message: readonly(message), show })
},
}
main.ts installs the plugin with app.use(toastPlugin, { duration: 2000 }). Any component can then call inject(toastKey)!, with the key that toast.ts exports, and get the same show() and a read-only message. The ! tells TypeScript the value is always there.
In App.vue, Save calls show() with the name: click Edit, then Save, and a toast reads Saved Ada for two seconds.
Call app.use() before app.mount(), which puts the app on the page. Components inject as they are created, so a value provided later arrives too late, and Vue warns that the injection was not found.
Older plugins add helpers with app.config.globalProperties.$toast = show. That reaches only templates and this in the Options API, the older way of writing components, and TypeScript needs extra declarations. Prefer app.provide() with a typed key.
Which tool fits
The choice depends on how much of the app the work touches. A directive is for one element: focusing it, trapping keyboard focus inside a dialog, attaching a date picker from another library. A plugin is for setup the whole app shares, such as the toast or a router. Reusable logic with state, such as fetching a product list with loading and error refs, belongs in a composable, a useSomething() function any component can call. When two fit, take the one that touches less.
What goes wrong
The first mistake reads a changing value in mounted only.
export const vFocus: Directive<HTMLElement, boolean | undefined> = {
mounted(el, binding) {
if (binding.value !== false) el.focus()
},
}
With v-focus="editing", the name field is mounted while editing is false, so nothing happens. Clicking Edit makes the field editable, but the cursor stays where it was, and nothing warns. mounted never runs again for the same element. Add an updated hook for values that change.
export const vFocus: Directive<HTMLElement, boolean | undefined> = {
mounted(el, binding) {
if (binding.value !== false) el.focus()
},
updated(el, binding) {
if (binding.value && !binding.oldValue) el.focus()
},
}
Click Edit now, and the cursor lands in the name field.
The second mistake installs a plugin twice, often to change an option.
app.use(toastPlugin, { duration: 2000 })
app.use(toastPlugin, { duration: 5000 })
The preview shows Plugin has already been applied to target app. and the second call is skipped, so toasts still vanish after two seconds. Install each plugin once, with the options you want.
app.use(toastPlugin, { duration: 5000 })
The warning is gone, and toasts last five seconds.
What to remember
- A custom directive is a set of hooks on one element.
binding.valueholds its value, andupdatedis needed when that value changes after mounting. - Register a directive for the whole app with
app.directive(), or for one component as avSomethingvariable in<script setup>. Use it on elements, not on components with more than one root. - A plugin is an object with
install(app, options), or a function.app.use()runs it once, beforeapp.mount(). - Inside a plugin, prefer
app.provide()with a typed key toapp.config.globalProperties. - Directive for one element, plugin for app-wide setup, composable for reusable stateful logic.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In main.ts, delete the
app.directive('focus', vFocus)line. The preview warnsFailed to resolve directive: focus, and neither Edit nor Add a note moves the cursor any more. In App.vue, addimport { vFocus } from './focus': both work again, now registered for App.vue alone. - Give the toast plugin a
positionoption. In toast.ts, addposition: 'top' | 'bottom'toToastOptionsandToast, andposition: options.positionto the provided object. In main.ts, pass{ duration: 2000, position: 'top' }. In App.vue, takepositionfrominject(toastKey)!too and replaceclass="toast bottom"with:class="['toast', position]". Click Edit, then Save: the toast appears at the top. - In focus.ts, delete the
updatedhook. Click Edit: the name field becomes editable, but the cursor stays where it was, sincemountedran long beforeeditingturned true. Put the hook back, and Edit moves the cursor into the field.