Updated June 2026. Tested on Vue 3 with <script setup>. Part of the Techalyst Vue 3 series.

v-on is Vue's addEventListener. It listens for a DOM event and runs a handler. You will mostly type its shorthand, @, but the mechanics behind it (especially around the event object and the modifiers) are worth getting right.

The basics

In the Composition API your handlers are plain functions defined in <script setup>. No methods block, no this.

<script setup>
import { ref } from 'vue'

const number = ref(0)

function increment() {
  number.value++
}
</script>

<template>
  <h2>{{ number }}</h2>
  <button v-on:click="increment">Add 1</button>
  <button @click="increment">Add 1 (shorthand)</button>
</template>

The two buttons are identical. From here on I will use the @ shorthand.

The event object, and how parentheses kill it

When you pass only the handler name, Vue passes the native event object as the first argument automatically.

<script setup>
function increment(e) {
  console.log(e) // PointerEvent
}
</script>

<template>
  <button @click="increment">Add 1</button>
</template>

Add empty parentheses and that auto-passing stops. The event becomes undefined:

<!-- e is now undefined inside the handler -->
<button @click="increment()">Add 1</button>

If you need to pass your own arguments and keep the event, use the special $event variable as the first argument:

<script setup>
function increment(e, step) {
  console.log(e)    // PointerEvent
  number.value += step
}
</script>

<template>
  <button @click="increment($event, 5)">Add 5</button>
</template>

Rule of thumb: skip the parentheses unless you are passing extra arguments, and when you do, lead with $event.

Inline handlers and triggering several

The directive value is an expression, so simple things can go inline. It reads your state directly with no prefix.

<button @click="number++">Inline add 1</button>

Keep real logic in named functions, though, inline expressions cannot reach window (so no console.log) and get unreadable fast. To fire more than one handler, separate them with commas, and remember parentheses are required here:

<button @click="one(), two()">Do both</button>
<button @click="one($event), number++">Mix named and inline</button>

A closure gotcha

If you are coming from vanilla JavaScript, you might expect a returned inner function to build up a closure across clicks. It does not. In native JS you call the outer function once at bind time and the returned inner function keeps the same scope:

btn.addEventListener('click', outer()) // outer runs once, inner is bound

With @click="outer()", Vue runs outer() fresh on every click, so a new scope is created each time and no counter persists. If you genuinely want that pattern, build the closure once in setup and bind the returned function, do not call the factory from the template.

Event modifiers

Modifiers are dot-suffixed flags that handle common event plumbing so you do not write event.stopPropagation() by hand. There are six, in three groups.

.once

Runs the handler a single time, then detaches.

<button @click.once="claim">Claim (one time)</button>

.stop, .self, .capture (propagation)

.stop stops the event bubbling up to parents. Put it on the child where you want propagation to end.

<div @click="outer">
  <div @click.stop="inner">
    <button @click="btn">Click</button>
  </div>
</div>

Clicking the button now fires btn then inner, and outer never sees it.

.self is the near-opposite: the element only reacts to events that originated on itself, ignoring ones bubbling up from children.

<div @click.self="outer">
  <button @click="btn">Click</button>
</div>

Clicking the button fires btn only, because the event began on the child, so the .self outer ignores it.

.capture flips the element into the capture phase, so it handles the event top-down before normal bubbling. With nested .capture handlers, the outermost wins first.

.prevent and .passive (default behaviour)

.prevent calls event.preventDefault(), the classic way to stop a link navigating or a form submitting.

<a href="/somewhere" @click.prevent="handle">No navigation</a>
<form @submit.prevent="save">...</form>

.passive promises the browser you will never call preventDefault(), so it can skip the check on every fire. That is a real win on high-frequency events like scroll and wheel.

<div @scroll.passive="onScroll">...</div>

The two are mutually exclusive for an obvious reason, .prevent calls preventDefault() and .passive swears it never will. Combine them and the browser logs an error and ignores the prevent.

Modifier Effect Put it on
.once Fires once, then detaches Any element
.stop Stops bubbling to parents The child
.self Ignores events from children The parent
.capture Handles top-down before bubbling The parent
.prevent Calls preventDefault() Element with default behaviour
.passive Promises no preventDefault() High-frequency events (scroll, wheel)

Modifiers chain, and order matters: @click.stop.prevent stops propagation then prevents the default. The only pairing that is illegal is .prevent with .passive.

Wrapping up

@ is your event listener. Pass the bare handler name to get the event object for free, and only add parentheses (with $event first) when you have extra arguments. Lean on modifiers instead of hand-writing stopPropagation and preventDefault, and reach for .passive on scroll-heavy handlers. Keep real logic in named functions and the template stays readable.

More in the series: v-model in Vue 3 and props and emits. Questions welcome below.