Skip to the solution
MediumFrontend9 min read

How do you create a custom directive in Vue?

394 views

The problem

Question presented to candidate: "Build me a custom directive, say v-click-outside or v-focus. Show how you define and register it, what arguments the hooks receive, and when you would choose a directive over a component or a composable."

What a strong answer should cover:

  • A directive is an object of lifecycle hooks (created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted) or a function shorthand that runs on mounted and updated.
  • Hooks receive (el, binding, vnode, prevVnode); binding has value, oldValue, arg, modifiers, instance and dir.
  • Registration: in <script setup> any camelCase variable starting with v (vFocus becomes v-focus); directives: {} in the Options API; app.directive('name', def) globally.
  • Cleanup: anything added in mounted (listeners, observers, timers) must be removed in unmounted.
  • Directives are for direct DOM access only; reusable logic belongs in composables and reusable UI in components. Using them on components is discouraged (multi-root components warn).
  • SSR: hooks do not run on the server; getSSRProps can return attributes for the HTML.

Clarifying questions expected:

  • "Does this need direct DOM access, or could a component or composable do it?"
  • "Will the app be server-rendered?"

Code / implementation expected: Yes β€” a working directive with an argument or modifier and proper cleanup.

  • directives
  • custom

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 10 sections

Read the code

Vuev-focus, v-highlight with arg and modifier, v-click-outside with cleanup
<script setup>
import { ref } from "vue";

// Log with console.log, NOT into reactive state the template renders: writing it
// from a directive's updated hook re-renders the component, which calls updated
// again, forever ("Maximum recursive updates exceeded").
const say = (msg) => console.log(msg);

// 1. Object form: pick the lifecycle hooks you need.
//    In <script setup>, any camelCase variable named vSomething is usable as v-something.
const vFocus = {
  mounted(el) {
    el.focus();
    say("v-focus mounted: focused <" + el.tagName.toLowerCase() + ">");
  },
};

// 2. Function shorthand = the same function for mounted AND updated.
//    binding.arg is the part after ":", binding.modifiers the ".flags".
const vHighlight = (el, binding) => {
  const prop = binding.arg === "bg" ? "backgroundColor" : "color";
  el.style[prop] = binding.value;
  el.style.fontWeight = binding.modifiers.bold ? "bold" : "";
  say(`v-highlight:${binding.arg} value=${binding.value} old=${binding.oldValue} bold=${!!binding.modifiers.bold}`);
};

// 3. A directive that owns a resource: add in mounted, remove in unmounted.
//    Keep the latest handler on the element: binding in mounted is a snapshot.
const vClickOutside = {
  mounted(el, binding) {
    el._handler = binding.value;
    el._onDocClick = (event) => {
      if (!el.contains(event.target)) el._handler(event);
    };
    document.addEventListener("click", el._onDocClick);
    say("v-click-outside: listener added");
  },
  updated(el, binding) {
    el._handler = binding.value;
  },
  unmounted(el) {
    document.removeEventListener("click", el._onDocClick);
    say("v-click-outside: listener removed");
  },
};

const color = ref("crimson");
const open = ref(true);
const outsideClicks = ref(0);
</script>

<template>
  <input v-focus placeholder="I get focus on mount" />

  <p v-highlight:color.bold="color">Text colour follows the select</p>
  <p v-highlight:bg="'lightyellow'">Static background</p>
  <select v-model="color">
    <option>crimson</option>
    <option>teal</option>
  </select>

  <button id="toggle" @click="open = !open">{{ open ? "close" : "open" }} panel</button>
  <div v-if="open" id="panel" v-click-outside="() => outsideClicks++">
    Panel. Clicks outside it: {{ outsideClicks }}
  </div>
</template>
Typed, globally registered directive with SSR support
// directives/tooltip.ts
import type { App, Directive, DirectiveBinding } from "vue";

type TooltipEl = HTMLElement & { _tip?: HTMLSpanElement };
type Placement = "top" | "bottom";

// Directive<Element, Value, Modifiers, Arg> types el, binding.value, modifiers and arg.
export const vTooltip: Directive<TooltipEl, string, "fast", Placement> = {
  mounted(el, binding) {
    const tip = document.createElement("span");
    tip.className = `tip tip-${binding.arg ?? "top"}`;
    tip.textContent = binding.value;
    el._tip = tip;
    el.append(tip);
  },
  updated(el, binding: DirectiveBinding<string, "fast", Placement>) {
    if (binding.value !== binding.oldValue && el._tip) el._tip.textContent = binding.value;
  },
  beforeUnmount(el) {
    el._tip?.remove();
    delete el._tip;
  },
  // Only used by server rendering: attributes to put in the HTML string.
  getSSRProps(binding) {
    return { "aria-label": binding.value };
  },
};

// Global registration, usually in main.ts: app.use(directives)
export default {
  install(app: App) {
    app.directive("tooltip", vTooltip);
  },
};

// Let templates type-check v-tooltip everywhere (vue-tsc reads this).
declare module "vue" {
  interface GlobalDirectives {
    vTooltip: typeof vTooltip;
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 57 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

What are fallthrough attributes ($attrs)?

Continue