Vue.js course · Module 5: Events & Forms

Event Modifiers

6 min read
In this lesson4

The first test of the Control Simulator ended in a failure. An operator submitted a form with a command, and the browser reloaded the page and wiped the entire simulator state. A moment later the operator clicked a button inside a panel that also reacts to clicks itself, and two procedures ran at once. In plain JavaScript you cure this with calls to event.preventDefault() and event.stopPropagation() at the start of the function. Vue has a shorter notation: modifiers, a dot and a name appended to the event name.

How an event travels through the DOM

To understand modifiers, you need to know how an event travels. A button click goes through three phases. In the capture phase the event descends from the browser window and document down the tree to the clicked element. In the target phase it reaches the button itself. In the bubble phase it goes back up: from the button to the parent <div>, then to <body>, <html> and document. A listener attached with @click works in the bubble phase by default, which is why clicking the button also runs the parent's procedure.

Event Modifiers

.prevent calls event.preventDefault(), which blocks the browser's default action, for example the page reload after a form is submitted. .stop calls event.stopPropagation() and stops the bubbling towards the parents. .once lets the procedure work only on the first event. The first part of the template shows these three modifiers.

1<template>
2  <!-- .prevent - prevents default action -->
3  <form @submit.prevent="onSubmit">
4    <button type="submit">Submit</button>
5  </form>
6
7  <!-- .stop - stops propagation -->
8  <div @click="outer">
9    <button @click.stop="inner">Only inner</button>
10  </div>
11
12  <!-- .once - only once -->
13  <button @click.once="runOnce">Only once</button>

The form submits without reloading the page and calls onSubmit. Clicking the button inside the <div> prints only inner, because .stop does not let the event out to the parent. The button with .once reacts once, and further clicks pass without a trace. The second part of the template shows the modifiers related to event phases.

1  <!-- .self - only on this element -->
2  <div @click.self="onlyThis">
3    <button>Click on button won't trigger onlyThis</button>
4  </div>
5
6  <!-- .capture - capture mode -->
7  <div @click.capture="captureFirst">
8    <button @click="child">Child</button>
9  </div>
10
11  <!-- .passive - for scroll performance -->
12  <div @scroll.passive="onScroll">Scroll content</div>
13
14  <!-- Combinations -->
15  <a @click.stop.prevent="handleLink">Link</a>
16</template>

.self runs the procedure only when the event happened on the element itself, not on one of its children. .capture attaches the listener in the capture phase, so captureFirst runs before the child's procedure. .passive tells the browser that the procedure will not block the default action, so scrolling can start right away, without waiting for it. It is most often added to touch and mouse wheel events. Do not combine it with .prevent, because the two contradict each other. Modifiers can be chained, as in @click.stop.prevent, but the order matters: @click.prevent.self blocks the default action for clicks on the children too, while @click.self.prevent blocks it only for clicks on the element itself. Finally, the script with the functions.

1<script setup>
2function onSubmit() {
3  console.log('Form submitted without page reload')
4}
5
6function outer() { console.log('outer') }
7function inner() { console.log('inner') }
8function runOnce() { console.log('This runs only once') }
9function onlyThis() { console.log('Clicked on div itself') }
10</script>

The functions are simple, because all the event logic lives in the template, and we left out the remaining procedures, such as captureFirst, for brevity. I recommend this rule: a modifier instead of a manual preventDefault in the function. The procedure stays pure mission logic, and the browser behavior is visible right in the template.

Key Modifiers

With keyboard events, keyup and keydown, a modifier can point to a specific key. Vue has the aliases .enter, .tab, .delete (captures both Delete and Backspace), .esc, .space and the arrows .up, .down, .left and .right. Any name from event.key also works when written in lowercase with dashes, for example .page-down.

1<template>
2  <!-- Specific keys -->
3  <input @keyup.enter="submit" placeholder="Enter to submit" />
4  <input @keyup.esc="cancel" placeholder="Esc to cancel" />
5
6  <!-- Arrow keys -->
7  <div tabindex="0" @keyup.up="moveUp" @keyup.down="moveDown"
8       @keyup.left="moveLeft" @keyup.right="moveRight">Control panel</div>
9
10  <!-- Function keys -->
11  <input @keyup.delete="deleteItem" />
12  <input @keyup.space="togglePlay" />
13  <input @keyup.tab="nextField" />

The arrow panel has tabindex="0", because a plain <div> does not take focus, and keyboard events go to the focused element. Watch out for @keyup.tab: Tab moves the focus to the next field, so the keyup event already lands there, and you catch leaving a field with @keydown.tab. The system keys .ctrl, .alt, .shift and .meta (Cmd on a Mac, the Windows key on a PC) combine with others into shortcuts.

1  <!-- Combinations with modifiers -->
2  <input @keyup.ctrl.enter="submitWithCtrl" />
3  <input @keyup.shift.delete="forceDelete" />
4  <input @keyup.alt.s="saveShortcut" />
5  <input @keyup.meta.s="macSave" /> <!-- Cmd on Mac -->
6
7  <!-- Exact modifier -->
8  <input @keyup.ctrl.exact="onlyCtrl" /> <!-- Only Ctrl, not Ctrl+Shift -->
9</template>

@keyup.ctrl.enter fires when you release Enter while holding Ctrl, because with keyup the system key has to be pressed at the moment of the event. .exact requires exactly this set of system keys: @keyup.ctrl.exact will not react when you hold Shift together with Ctrl.

Mouse Modifiers

The .left, .right and .middle modifiers restrict the procedure to the main, secondary and auxiliary button. The names assume a right-handed mouse, but they stand for the roles of the buttons, not their physical position.

1<template>
2  <!-- Mouse buttons -->
3  <div @click.left="leftClick">Left click</div>
4  <div @click.right="rightClick">Right click</div>
5  <div @click.middle="middleClick">Middle click</div>
6
7  <!-- Context menu -->
8  <div @contextmenu.prevent="showCustomMenu">
9    Right click = custom menu
10  </div>
11</template>
12
13<script setup>
14function leftClick() { console.log('Left') }
15function rightClick() { console.log('Right') }
16function middleClick() { console.log('Middle') }
17function showCustomMenu(e) {
18  console.log('Custom menu at:', e.clientX, e.clientY)
19}
20</script>

Browsers send click only for the main button, which is why Vue compiles @click.right to the contextmenu event and @click.middle to mouseup. Hence a practical conclusion: .right on its own will not hide the browser menu. You build your own context menu with @contextmenu.prevent, as in the last element of the example, and e.clientX and e.clientY tell you where to open it. In the next lesson you will move on to forms and the v-model directive.

Remember: a modifier is a filter on the signal line - it lets only the right commands through to the procedure and suppresses the ones the browser would carry out on its own.

Code for this lesson: App.vue
1<script setup>
2import { ref } from 'vue'
3
4const commandLog = ref([])
5const secretCode = ref('')
6const mouseAction = ref('')
7
8const addLog = (message) => {
9  commandLog.value.unshift({
10    id: Date.now(),
11    message,
12    time: new Date().toLocaleTimeString()
13  })
14  if (commandLog.value.length > 5) {
15    commandLog.value.pop()
16  }
17}
18
19const handleEnter = () => {
20  addLog('⏎ Enter: Command executed!')
21  if (secretCode.value === 'MARS') {
22    addLog('Access code accepted!')
23  }
24}
25
26const handleEscape = () => {
27  addLog('⎋ Escape: Operation cancelled')
28  secretCode.value = ''
29}
30
31const handleDelete = () => {
32  addLog('⌫ Delete: Data removed')
33}
34
35const handleLeftClick = () => {
36  mouseAction.value = 'Left button: Scanning the left sector'
37  addLog('Left mouse button')
38}
39
40const handleRightClick = (event) => {
41  event.preventDefault()
42  mouseAction.value = 'Right button: Scanning the right sector'
43  addLog('Right mouse button')
44}
45
46const handleMiddleClick = () => {
47  mouseAction.value = 'Middle button: Scanning central sector'
48  addLog('Middle mouse button')
49}
50</script>
51
52<template>
53  <div class="command-center">
54    <div class="center-header">
55      <h2>⌨NOVA LAB Command Center</h2>
56      <p class="subtitle">Key and Mouse Modifiers</p>
57    </div>
58
59    <div class="input-section">
60      <div class="input-group">
61        <label>Access code (type MARS and press Enter):</label>
62        <input
63          v-model="secretCode"
64          @keyup.enter="handleEnter"
65          @keyup.esc="handleEscape"
66          @keyup.delete="handleDelete"
67          type="text"
68          placeholder="Enter code..."
69          class="command-input"
70        />
71      </div>
72
73      <div class="key-hints">
74        <span class="hint">Enter - Execute</span>
75        <span class="hint">Esc - Cancel</span>
76        <span class="hint">Delete - Remove</span>
77      </div>
78    </div>
79
80    <div class="mouse-section">
81      <h3>Mouse Scanner Control</h3>
82      <div class="scanner-area">
83        <div
84          class="scan-zone"
85          @click.left="handleLeftClick"
86          @click.right="handleRightClick"
87          @click.middle="handleMiddleClick"
88        >
89          <p>Click with different mouse buttons</p>
90          <p class="action-display">{{ mouseAction || 'Waiting for command...' }}</p>
91        </div>
92      </div>
93    </div>
94
95    <div class="log-section">
96      <h3>Command Log</h3>
97      <div class="log-container">
98        <div
99          v-for="log in commandLog"
100          :key="log.id"
101          class="log-entry"
102        >
103          <span class="log-time">{{ log.time }}</span>
104          <span class="log-message">{{ log.message }}</span>
105        </div>
106        <div v-if="commandLog.length === 0" class="log-empty">
107          No activity...
108        </div>
109      </div>
110    </div>
111
112    <div class="modifiers-info">
113      <h3>Available modifiers:</h3>
114      <div class="modifier-grid">
115        <div class="modifier-item">
116          <strong>Keys:</strong>
117          <code>.enter</code>
118          <code>.esc</code>
119          <code>.delete</code>
120          <code>.tab</code>
121          <code>.space</code>
122        </div>
123        <div class="modifier-item">
124          <strong>Mouse:</strong>
125          <code>.left</code>
126          <code>.right</code>
127          <code>.middle</code>
128        </div>
129      </div>
130    </div>
131  </div>
132</template>
133
134<style scoped>
135.command-center {
136  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
137  color: #00ff88;
138  padding: 30px;
139  border-radius: 15px;
140  border: 2px solid #00b4d8;
141  font-family: 'Courier New', monospace;
142  max-width: 900px;
143  margin: 0 auto;
144}
145
146.center-header {
147  text-align: center;
148  margin-bottom: 30px;
149  padding-bottom: 20px;
150  border-bottom: 2px solid #00b4d8;
151}
152
153.center-header h2 {
154  color: #00ff88;
155  margin: 0 0 10px 0;
156  font-size: 28px;
157  text-shadow: 0 0 10px #00ff88;
158}
159
160.subtitle {
161  color: #00b4d8;
162  margin: 0;
163  font-size: 14px;
164}
165
166.input-section {
167  margin-bottom: 30px;
168}
169
170.input-group {
171  margin-bottom: 15px;
172}
173
174.input-group label {
175  display: block;
176  color: #00b4d8;
177  margin-bottom: 10px;
178  font-size: 14px;
179}
180
181.command-input {
182  width: 100%;
183  background: rgba(0, 180, 216, 0.1);
184  border: 2px solid #00b4d8;
185  border-radius: 8px;
186  padding: 15px;
187  color: #00ff88;
188  font-size: 16px;
189  font-family: 'Courier New', monospace;
190  box-sizing: border-box;
191}
192
193.command-input:focus {
194  outline: none;
195  border-color: #00ff88;
196  box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
197}
198
199.key-hints {
200  display: flex;
201  gap: 10px;
202  flex-wrap: wrap;
203}
204
205.hint {
206  background: rgba(0, 255, 136, 0.1);
207  border: 1px solid #00ff88;
208  padding: 5px 12px;
209  border-radius: 20px;
210  font-size: 12px;
211  color: #00ff88;
212}
213
214.mouse-section {
215  margin-bottom: 30px;
216}
217
218.mouse-section h3 {
219  color: #00ff88;
220  margin-bottom: 15px;
221}
222
223.scanner-area {
224  background: rgba(0, 180, 216, 0.05);
225  padding: 20px;
226  border-radius: 8px;
227  border: 2px dashed #00b4d8;
228}
229
230.scan-zone {
231  background: linear-gradient(135deg, rgba(0, 180, 216, 0.2) 0%, rgba(0, 255, 136, 0.1) 100%);
232  padding: 40px;
233  border-radius: 8px;
234  text-align: center;
235  cursor: pointer;
236  transition: all 0.3s ease;
237}
238
239.scan-zone:hover {
240  background: linear-gradient(135deg, rgba(0, 180, 216, 0.3) 0%, rgba(0, 255, 136, 0.2) 100%);
241  transform: scale(1.02);
242}
243
244.scan-zone p {
245  margin: 0 0 10px 0;
246  color: #00b4d8;
247}
248
249.action-display {
250  color: #00ff88 !important;
251  font-weight: bold;
252  font-size: 18px;
253}
254
255.log-section {
256  margin-bottom: 30px;
257}
258
259.log-section h3 {
260  color: #00ff88;
261  margin-bottom: 15px;
262}
263
264.log-container {
265  background: rgba(0, 0, 0, 0.3);
266  border: 1px solid #00b4d8;
267  border-radius: 8px;
268  padding: 15px;
269  max-height: 200px;
270  overflow-y: auto;
271}
272
273.log-entry {
274  padding: 8px;
275  margin-bottom: 5px;
276  background: rgba(0, 180, 216, 0.1);
277  border-radius: 4px;
278  display: flex;
279  gap: 15px;
280}
281
282.log-time {
283  color: #00b4d8;
284  font-size: 12px;
285}
286
287.log-message {
288  color: #00ff88;
289  flex: 1;
290}
291
292.log-empty {
293  color: #00b4d8;
294  text-align: center;
295  opacity: 0.5;
296}
297
298.modifiers-info {
299  background: rgba(0, 255, 136, 0.05);
300  padding: 20px;
301  border-radius: 8px;
302  border: 1px solid #00ff88;
303}
304
305.modifiers-info h3 {
306  color: #00ff88;
307  margin-top: 0;
308}
309
310.modifier-grid {
311  display: grid;
312  grid-template-columns: 1fr 1fr;
313  gap: 20px;
314}
315
316.modifier-item {
317  color: #00b4d8;
318}
319
320.modifier-item strong {
321  display: block;
322  color: #00ff88;
323  margin-bottom: 10px;
324}
325
326.modifier-item code {
327  background: rgba(0, 255, 136, 0.2);
328  padding: 4px 8px;
329  border-radius: 4px;
330  color: #00ff88;
331  font-size: 12px;
332  margin-right: 5px;
333  display: inline-block;
334  margin-bottom: 5px;
335}
336</style>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the .prevent modifier do in @submit.prevent?

  2. 2. When do we use the .stop modifier?

These are 2 of 7 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Create a form with @submit.prevent and a button with @click.once.

  • Horizontal ordering

    Arrange the syntax for a form with the .prevent modifier:

  • Vertical ordering

    Arrange the DOM event propagation phases in order:

  • Click in order

    Click in the correct order to build @click.stop.prevent:

  • Code editor

    Create an input that responds to @keyup.enter and @keyup.esc.

  • Vertical ordering

    Arrange the order of event propagation from bottom to top (bubbling):

Useful articles