Skip to content

Event Handling

Events allow your Vue application to respond to user interactions. In this tutorial, you'll learn how to handle DOM events, pass arguments, use modifiers, and create custom events.

Listening to Events

Use the v-on directive (or @ shorthand) to listen to DOM events:

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

const count = ref(0)

function increment() {
  count.value++
}

function greet() {
  alert('Hello!')
}
</script>

<template>
  <!-- Full syntax -->
  <button v-on:click="increment">Count: {{ count }}</button>

  <!-- Shorthand (recommended) -->
  <button @click="increment">Count: {{ count }}</button>

  <!-- Inline handler -->
  <button @click="count++">Count: {{ count }}</button>

  <!-- Method handler -->
  <button @click="greet">Say Hello</button>
</template>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

const count: Ref<number> = ref(0)

function increment(): void {
  count.value++
}

function greet(): void {
  alert('Hello!')
}
</script>

<template>
  <!-- Full syntax -->
  <button v-on:click="increment">Count: {{ count }}</button>

  <!-- Shorthand (recommended) -->
  <button @click="increment">Count: {{ count }}</button>

  <!-- Inline handler -->
  <button @click="count++">Count: {{ count }}</button>

  <!-- Method handler -->
  <button @click="greet">Say Hello</button>
</template>

Accessing the Event Object

Automatic Event Parameter

vue
<script setup>
function handleClick(event) {
  console.log('Event type:', event.type)
  console.log('Target:', event.target)
  console.log('Coordinates:', event.clientX, event.clientY)
}

function handleInput(event) {
  console.log('Input value:', event.target.value)
}
</script>

<template>
  <button @click="handleClick">Click Me</button>
  <input @input="handleInput" placeholder="Type something..." />
</template>
vue
<script setup lang="ts">
function handleClick(event: MouseEvent): void {
  console.log('Event type:', event.type)
  console.log('Target:', event.target)
  console.log('Coordinates:', event.clientX, event.clientY)
}

function handleInput(event: Event): void {
  const target = event.target as HTMLInputElement
  console.log('Input value:', target.value)
}
</script>

<template>
  <button @click="handleClick">Click Me</button>
  <input @input="handleInput" placeholder="Type something..." />
</template>

Using $event

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

const message = ref('')

function handleClick(msg, event) {
  console.log(msg)
  console.log(event.target)
}

function updateMessage(event) {
  message.value = event.target.value
}
</script>

<template>
  <!-- Pass custom argument AND event -->
  <button @click="handleClick('Hello!', $event)">Click</button>

  <!-- Using $event inline -->
  <input
    :value="message"
    @input="message = $event.target.value"
  />
</template>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

const message: Ref<string> = ref('')

function handleClick(msg: string, event: MouseEvent): void {
  console.log(msg)
  console.log(event.target)
}
</script>

<template>
  <!-- Pass custom argument AND event -->
  <button @click="handleClick('Hello!', $event)">Click</button>

  <!-- Using $event inline -->
  <input
    :value="message"
    @input="message = ($event.target as HTMLInputElement).value"
  />
</template>

Passing Arguments

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

const items = ref([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Orange' }
])

function selectItem(item) {
  console.log('Selected:', item.name)
}

function deleteItem(id, event) {
  console.log('Deleting item:', id)
  console.log('Event:', event)
  items.value = items.value.filter(item => item.id !== id)
}

function handleAction(action, item, event) {
  console.log(`Action: ${action}, Item: ${item.name}`)
  event.stopPropagation()
}
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!-- Pass single argument -->
      <span @click="selectItem(item)">{{ item.name }}</span>

      <!-- Pass multiple arguments with event -->
      <button @click="deleteItem(item.id, $event)">Delete</button>

      <!-- Pass multiple custom arguments -->
      <button @click="handleAction('edit', item, $event)">Edit</button>
    </li>
  </ul>
</template>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

interface Item {
  id: number
  name: string
}

const items: Ref<Item[]> = ref([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Orange' }
])

function selectItem(item: Item): void {
  console.log('Selected:', item.name)
}

function deleteItem(id: number, event: MouseEvent): void {
  console.log('Deleting item:', id)
  console.log('Event:', event)
  items.value = items.value.filter(item => item.id !== id)
}

function handleAction(action: string, item: Item, event: MouseEvent): void {
  console.log(`Action: ${action}, Item: ${item.name}`)
  event.stopPropagation()
}
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!-- Pass single argument -->
      <span @click="selectItem(item)">{{ item.name }}</span>

      <!-- Pass multiple arguments with event -->
      <button @click="deleteItem(item.id, $event)">Delete</button>

      <!-- Pass multiple custom arguments -->
      <button @click="handleAction('edit', item, $event)">Edit</button>
    </li>
  </ul>
</template>

Event Modifiers

Vue provides modifiers that handle common event patterns:

Common Modifiers

vue
<template>
  <!-- .prevent - calls event.preventDefault() -->
  <form @submit.prevent="onSubmit">
    <button type="submit">Submit</button>
  </form>

  <!-- .stop - calls event.stopPropagation() -->
  <div @click="handleOuter">
    <button @click.stop="handleInner">Won't bubble</button>
  </div>

  <!-- .once - event fires only once -->
  <button @click.once="doOnce">Click me (once only)</button>

  <!-- .self - only trigger if event.target is the element itself -->
  <div @click.self="handleSelf">
    <button>Click me (won't trigger parent)</button>
  </div>

  <!-- .capture - use capture mode -->
  <div @click.capture="handleCapture">
    Capture phase
  </div>

  <!-- .passive - improves scroll performance -->
  <div @scroll.passive="onScroll">
    Passive scroll
  </div>
</template>

Chaining Modifiers

vue
<template>
  <!-- Chain multiple modifiers -->
  <a @click.stop.prevent="handleClick" href="#">
    Stop propagation AND prevent default
  </a>

  <!-- Order matters for some combinations -->
  <form @submit.prevent.stop="onSubmit">
    Prevent then stop
  </form>
</template>

Key Modifiers

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

const searchQuery = ref('')

function search() {
  console.log('Searching for:', searchQuery.value)
}

function save() {
  console.log('Saving...')
}

function selectAll() {
  console.log('Select all')
}
</script>

<template>
  <!-- Specific keys -->
  <input
    v-model="searchQuery"
    @keyup.enter="search"
    placeholder="Press Enter to search"
  />

  <!-- Key aliases -->
  <input @keyup.escape="searchQuery = ''" placeholder="Press Esc to clear" />

  <!-- Key combinations -->
  <input @keyup.ctrl.s="save" placeholder="Ctrl+S to save" />
  <input @keyup.ctrl.a="selectAll" placeholder="Ctrl+A" />

  <!-- Multiple keys (any of them) -->
  <input @keyup.enter.tab="search" placeholder="Enter or Tab" />
</template>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

const searchQuery: Ref<string> = ref('')

function search(): void {
  console.log('Searching for:', searchQuery.value)
}

function save(): void {
  console.log('Saving...')
}

function selectAll(): void {
  console.log('Select all')
}
</script>

<template>
  <!-- Specific keys -->
  <input
    v-model="searchQuery"
    @keyup.enter="search"
    placeholder="Press Enter to search"
  />

  <!-- Key aliases -->
  <input @keyup.escape="searchQuery = ''" placeholder="Press Esc to clear" />

  <!-- Key combinations -->
  <input @keyup.ctrl.s="save" placeholder="Ctrl+S to save" />
  <input @keyup.ctrl.a="selectAll" placeholder="Ctrl+A" />
</template>

Key Aliases

AliasKey
.enterEnter
.tabTab
.deleteDelete or Backspace
.escEscape
.spaceSpace
.upArrow Up
.downArrow Down
.leftArrow Left
.rightArrow Right

System Modifiers

vue
<template>
  <!-- System modifier keys -->
  <input @keyup.ctrl="onCtrl" />
  <input @keyup.alt="onAlt" />
  <input @keyup.shift="onShift" />
  <input @keyup.meta="onMeta" /> <!-- Cmd on Mac, Windows key on Windows -->

  <!-- Exact modifier -->
  <button @click.ctrl.exact="onCtrlClick">
    Only fires with Ctrl (no other modifiers)
  </button>

  <button @click.exact="onClick">
    Only fires without any modifiers
  </button>
</template>

Mouse Button Modifiers

vue
<template>
  <button @click.left="onLeftClick">Left Click</button>
  <button @click.right="onRightClick">Right Click</button>
  <button @click.middle="onMiddleClick">Middle Click</button>
</template>

Common DOM Events

Mouse Events

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

const position = ref({ x: 0, y: 0 })
const isHovered = ref(false)

function onMouseMove(event) {
  position.value = {
    x: event.clientX,
    y: event.clientY
  }
}
</script>

<template>
  <div
    class="mouse-area"
    @click="console.log('Clicked')"
    @dblclick="console.log('Double clicked')"
    @mouseenter="isHovered = true"
    @mouseleave="isHovered = false"
    @mousemove="onMouseMove"
    @mousedown="console.log('Mouse down')"
    @mouseup="console.log('Mouse up')"
    @contextmenu.prevent="console.log('Right click')"
    :class="{ hovered: isHovered }"
  >
    <p>Move mouse here</p>
    <p>Position: {{ position.x }}, {{ position.y }}</p>
  </div>
</template>

<style scoped>
.mouse-area {
  padding: 40px;
  background: #f0f0f0;
  border: 2px solid #ddd;
  transition: all 0.3s;
}
.mouse-area.hovered {
  background: #e0f0e0;
  border-color: #42b883;
}
</style>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

interface Position {
  x: number
  y: number
}

const position: Ref<Position> = ref({ x: 0, y: 0 })
const isHovered: Ref<boolean> = ref(false)

function onMouseMove(event: MouseEvent): void {
  position.value = {
    x: event.clientX,
    y: event.clientY
  }
}
</script>

<template>
  <div
    class="mouse-area"
    @click="console.log('Clicked')"
    @dblclick="console.log('Double clicked')"
    @mouseenter="isHovered = true"
    @mouseleave="isHovered = false"
    @mousemove="onMouseMove"
    @mousedown="console.log('Mouse down')"
    @mouseup="console.log('Mouse up')"
    @contextmenu.prevent="console.log('Right click')"
    :class="{ hovered: isHovered }"
  >
    <p>Move mouse here</p>
    <p>Position: {{ position.x }}, {{ position.y }}</p>
  </div>
</template>

<style scoped>
.mouse-area {
  padding: 40px;
  background: #f0f0f0;
  border: 2px solid #ddd;
  transition: all 0.3s;
}
.mouse-area.hovered {
  background: #e0f0e0;
  border-color: #42b883;
}
</style>

Keyboard Events

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

const pressedKeys = ref([])
const lastKey = ref('')

function onKeyDown(event) {
  lastKey.value = event.key
  if (!pressedKeys.value.includes(event.key)) {
    pressedKeys.value.push(event.key)
  }
}

function onKeyUp(event) {
  pressedKeys.value = pressedKeys.value.filter(k => k !== event.key)
}
</script>

<template>
  <div>
    <input
      @keydown="onKeyDown"
      @keyup="onKeyUp"
      @keypress="console.log('Key press:', $event.key)"
      placeholder="Press keys here..."
    />
    <p>Last key: {{ lastKey }}</p>
    <p>Currently pressed: {{ pressedKeys.join(', ') }}</p>
  </div>
</template>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

const pressedKeys: Ref<string[]> = ref([])
const lastKey: Ref<string> = ref('')

function onKeyDown(event: KeyboardEvent): void {
  lastKey.value = event.key
  if (!pressedKeys.value.includes(event.key)) {
    pressedKeys.value.push(event.key)
  }
}

function onKeyUp(event: KeyboardEvent): void {
  pressedKeys.value = pressedKeys.value.filter(k => k !== event.key)
}
</script>

<template>
  <div>
    <input
      @keydown="onKeyDown"
      @keyup="onKeyUp"
      @keypress="console.log('Key press:', $event.key)"
      placeholder="Press keys here..."
    />
    <p>Last key: {{ lastKey }}</p>
    <p>Currently pressed: {{ pressedKeys.join(', ') }}</p>
  </div>
</template>

Focus Events

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

const isFocused = ref(false)
const focusHistory = ref([])

function onFocus(event) {
  isFocused.value = true
  focusHistory.value.push(`Focused: ${event.target.name}`)
}

function onBlur(event) {
  isFocused.value = false
  focusHistory.value.push(`Blurred: ${event.target.name}`)
}
</script>

<template>
  <div>
    <input
      name="field1"
      @focus="onFocus"
      @blur="onBlur"
      placeholder="Focus me"
      :class="{ focused: isFocused }"
    />

    <div class="history">
      <p v-for="(event, index) in focusHistory" :key="index">{{ event }}</p>
    </div>
  </div>
</template>

<style scoped>
input.focused {
  outline: 2px solid #42b883;
}
</style>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

const isFocused: Ref<boolean> = ref(false)
const focusHistory: Ref<string[]> = ref([])

function onFocus(event: FocusEvent): void {
  isFocused.value = true
  const target = event.target as HTMLInputElement
  focusHistory.value.push(`Focused: ${target.name}`)
}

function onBlur(event: FocusEvent): void {
  isFocused.value = false
  const target = event.target as HTMLInputElement
  focusHistory.value.push(`Blurred: ${target.name}`)
}
</script>

<template>
  <div>
    <input
      name="field1"
      @focus="onFocus"
      @blur="onBlur"
      placeholder="Focus me"
      :class="{ focused: isFocused }"
    />

    <div class="history">
      <p v-for="(event, index) in focusHistory" :key="index">{{ event }}</p>
    </div>
  </div>
</template>

<style scoped>
input.focused {
  outline: 2px solid #42b883;
}
</style>

Custom Component Events

See Components & Props for detailed coverage of emits.

vue
<!-- Child: SearchInput.vue -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['search', 'clear'])
const query = ref('')

function handleSearch() {
  emit('search', query.value)
}

function handleClear() {
  query.value = ''
  emit('clear')
}
</script>

<template>
  <div class="search-input">
    <input
      v-model="query"
      @keyup.enter="handleSearch"
      placeholder="Search..."
    />
    <button @click="handleSearch">Search</button>
    <button @click="handleClear">Clear</button>
  </div>
</template>
vue
<!-- Child: SearchInput.vue -->
<script setup lang="ts">
import { ref, Ref } from 'vue'

const emit = defineEmits<{
  search: [query: string]
  clear: []
}>()

const query: Ref<string> = ref('')

function handleSearch(): void {
  emit('search', query.value)
}

function handleClear(): void {
  query.value = ''
  emit('clear')
}
</script>

<template>
  <div class="search-input">
    <input
      v-model="query"
      @keyup.enter="handleSearch"
      placeholder="Search..."
    />
    <button @click="handleSearch">Search</button>
    <button @click="handleClear">Clear</button>
  </div>
</template>

Using the component:

vue
<template>
  <SearchInput
    @search="handleSearch"
    @clear="handleClear"
  />
</template>

Practical Example: Interactive Card

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

const cards = ref([
  { id: 1, title: 'Card 1', content: 'Content for card 1', likes: 0, selected: false },
  { id: 2, title: 'Card 2', content: 'Content for card 2', likes: 5, selected: false },
  { id: 3, title: 'Card 3', content: 'Content for card 3', likes: 10, selected: false }
])

const contextMenu = ref({ visible: false, x: 0, y: 0, cardId: null })

function toggleSelect(card) {
  card.selected = !card.selected
}

function like(card, event) {
  event.stopPropagation()
  card.likes++
}

function showContextMenu(card, event) {
  event.preventDefault()
  contextMenu.value = {
    visible: true,
    x: event.clientX,
    y: event.clientY,
    cardId: card.id
  }
}

function hideContextMenu() {
  contextMenu.value.visible = false
}

function deleteCard(id) {
  cards.value = cards.value.filter(c => c.id !== id)
  hideContextMenu()
}

function duplicateCard(id) {
  const card = cards.value.find(c => c.id === id)
  if (card) {
    const newCard = {
      ...card,
      id: Date.now(),
      title: card.title + ' (copy)',
      likes: 0,
      selected: false
    }
    cards.value.push(newCard)
  }
  hideContextMenu()
}
</script>

<template>
  <div class="card-container" @click="hideContextMenu">
    <div
      v-for="card in cards"
      :key="card.id"
      class="card"
      :class="{ selected: card.selected }"
      @click="toggleSelect(card)"
      @dblclick="like(card, $event)"
      @contextmenu="showContextMenu(card, $event)"
      @mouseenter="card.hovered = true"
      @mouseleave="card.hovered = false"
    >
      <h3>{{ card.title }}</h3>
      <p>{{ card.content }}</p>
      <div class="card-footer">
        <span>{{ card.likes }} likes</span>
        <button @click="like(card, $event)">Like</button>
      </div>
    </div>

    <!-- Context Menu -->
    <div
      v-if="contextMenu.visible"
      class="context-menu"
      :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
      @click.stop
    >
      <button @click="duplicateCard(contextMenu.cardId)">Duplicate</button>
      <button @click="deleteCard(contextMenu.cardId)" class="danger">Delete</button>
    </div>
  </div>
</template>

<style scoped>
.card-container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
  padding: 20px;
}

.card {
  padding: 20px;
  border: 2px solid #ddd;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  user-select: none;
}

.card:hover {
  border-color: #42b883;
  transform: translateY(-2px);
}

.card.selected {
  border-color: #42b883;
  background: #f0fff4;
}

.card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 15px;
}

.context-menu {
  position: fixed;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  z-index: 1000;
}

.context-menu button {
  display: block;
  width: 100%;
  padding: 10px 20px;
  border: none;
  background: none;
  cursor: pointer;
  text-align: left;
}

.context-menu button:hover {
  background: #f0f0f0;
}

.context-menu button.danger:hover {
  background: #ffe0e0;
  color: red;
}
</style>
vue
<script setup lang="ts">
import { ref, Ref } from 'vue'

interface Card {
  id: number
  title: string
  content: string
  likes: number
  selected: boolean
  hovered?: boolean
}

interface ContextMenu {
  visible: boolean
  x: number
  y: number
  cardId: number | null
}

const cards: Ref<Card[]> = ref([
  { id: 1, title: 'Card 1', content: 'Content for card 1', likes: 0, selected: false },
  { id: 2, title: 'Card 2', content: 'Content for card 2', likes: 5, selected: false },
  { id: 3, title: 'Card 3', content: 'Content for card 3', likes: 10, selected: false }
])

const contextMenu: Ref<ContextMenu> = ref({ visible: false, x: 0, y: 0, cardId: null })

function toggleSelect(card: Card): void {
  card.selected = !card.selected
}

function like(card: Card, event: MouseEvent): void {
  event.stopPropagation()
  card.likes++
}

function showContextMenu(card: Card, event: MouseEvent): void {
  event.preventDefault()
  contextMenu.value = {
    visible: true,
    x: event.clientX,
    y: event.clientY,
    cardId: card.id
  }
}

function hideContextMenu(): void {
  contextMenu.value.visible = false
}

function deleteCard(id: number | null): void {
  if (id !== null) {
    cards.value = cards.value.filter(c => c.id !== id)
  }
  hideContextMenu()
}

function duplicateCard(id: number | null): void {
  if (id === null) return
  const card = cards.value.find(c => c.id === id)
  if (card) {
    const newCard: Card = {
      ...card,
      id: Date.now(),
      title: card.title + ' (copy)',
      likes: 0,
      selected: false
    }
    cards.value.push(newCard)
  }
  hideContextMenu()
}
</script>

<template>
  <div class="card-container" @click="hideContextMenu">
    <div
      v-for="card in cards"
      :key="card.id"
      class="card"
      :class="{ selected: card.selected }"
      @click="toggleSelect(card)"
      @dblclick="like(card, $event)"
      @contextmenu="showContextMenu(card, $event)"
      @mouseenter="card.hovered = true"
      @mouseleave="card.hovered = false"
    >
      <h3>{{ card.title }}</h3>
      <p>{{ card.content }}</p>
      <div class="card-footer">
        <span>{{ card.likes }} likes</span>
        <button @click="like(card, $event)">Like</button>
      </div>
    </div>

    <!-- Context Menu -->
    <div
      v-if="contextMenu.visible"
      class="context-menu"
      :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
      @click.stop
    >
      <button @click="duplicateCard(contextMenu.cardId)">Duplicate</button>
      <button @click="deleteCard(contextMenu.cardId)" class="danger">Delete</button>
    </div>
  </div>
</template>

<style scoped>
.card-container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
  padding: 20px;
}

.card {
  padding: 20px;
  border: 2px solid #ddd;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  user-select: none;
}

.card:hover {
  border-color: #42b883;
  transform: translateY(-2px);
}

.card.selected {
  border-color: #42b883;
  background: #f0fff4;
}

.card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 15px;
}

.context-menu {
  position: fixed;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  z-index: 1000;
}

.context-menu button {
  display: block;
  width: 100%;
  padding: 10px 20px;
  border: none;
  background: none;
  cursor: pointer;
  text-align: left;
}

.context-menu button:hover {
  background: #f0f0f0;
}

.context-menu button.danger:hover {
  background: #ffe0e0;
  color: red;
}
</style>

Summary

ConceptSyntaxExample
Listen to event@event@click="handler"
Event object$event@click="fn($event)"
Prevent default.prevent@submit.prevent
Stop propagation.stop@click.stop
Key modifier.key@keyup.enter
System modifier.ctrl@click.ctrl
Once.once@click.once
Custom emitemit()emit('custom', data)

What's Next?

In the next chapter, we'll learn about Computed & Watchers - derived state and side effects.


Previous: Reactivity & State | Next: Computed & Watchers →