The Colon Shorthand: When an Attribute Becomes an Expression
A static attribute is text the compiler copies. v-bind:disabled="..." — written with the colon shorthand :disabled in every real codebase — turns the value into an expression that Vue evaluates on every render and keeps in sync with the DOM. Same directive, two very different lifetimes.
<script setup lang="ts">
import { ref } from 'vue'
const isSaving = ref(false)
const email = ref('')
const attempts = ref(0)
</script>
<template>
<button :disabled="isSaving">{{ isSaving ? 'Saving…' : 'Save' }}</button>
<input v-model="email" :aria-invalid="attempts > 3" />
<form :action="submitUrl" method="post">
<a :href="`/orders/${order.id}`">View</a>
</form>
</template>
The rule that governs every binding, and the one worth memorizing before the syntax details: null, undefined, and false remove the attribute; anything else sets it as a string. For most attributes that is intuitive, but for boolean attributes — disabled, checked, required, readonly, multiple — it carries a trap that exists in plain HTML too: presence means true. Write the literal disabled="false" and the button is disabled, because the browser only checks whether the attribute exists. With the binding, :disabled="isSaving" with isSaving === false removes the attribute entirely and enables the button. The two lines below are equivalent in Vue and both wrong if you wanted a working button:
<button disabled="false">x</button> <!-- disabled: presence is truth -->
<button :disabled="'false'">x</button> <!-- also disabled: a non-empty string -->
Binding to a computed URL — your fourth key point in practice — replaces the manual-DOM habit completely:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ product: { id: string; photo: string | null } }>()
const photoUrl = computed(() => props.product.photo ?? '/placeholder.svg')
</script>
<template>
<img :src="photoUrl" :alt="props.product.name" />
</template>
No querySelector, no .src = assignment, no re-running anything when the product changes: the expression reads reactive state, so the reactive system patches the attribute whenever the inputs change. That is the through-line of this entire phase — templates declare relationships; the reactivity system maintains them.
Two syntax extensions appear rarely but earn points in interviews. Dynamic arguments let the attribute name itself come from an expression: :[attrName]="value" where attrName is 'data-test-id' or 'aria-describedby'. The argument must be a single valid JavaScript identifier or a quoted string — spaces and newlines break it, because the compiler treats them as expression boundaries. The object form binds several attributes at once: v-bind="{ id, class: activeClass, 'data-test': testId }" — a plain object of raw (unbound) values, no colon shorthand inside. Its most common appearance is forwarding a parent's extras to a child: v-bind="$attrs", which spreads every attribute the component did not declare as a prop onto a root element — the mechanism behind wrapper components that stay transparent. You will meet $attrs again as the thing defineProps splits away from the rest; here it is enough to recognize the pattern.
One caution to keep templates readable: bindings are re-evaluated per render, so heavy computation belongs in a computed and the template should bind a name. :disabled="requests.filter(r => r.active).length > 0" works — but :disabled="hasActiveRequests" documents itself and lets devtools show you the boolean directly. Next: the two bindings where Vue stops treating values as plain strings — class and style.
Class and Style: Merging You Don't Have to Code
class and style get special treatment because they are additive: the parent's classes, a component's internal classes, and dynamic state all need to coexist. Vue handles the merge for you, which is why you never concatenate class strings by hand.
<script setup lang="ts">
import { ref } from 'vue'
const isActive = ref(true)
const errors = ref<string[]>(['email'])
const theme = ref<'light' | 'dark'>('dark')
</script>
<template>
<!-- static + object + array, all merged -->
<div
class="card"
:class="{ active: isActive, 'has-errors': errors.length > 0 }"
:style="{ fontSize: size + 'px', color: theme === 'dark' ? '#ddd' : '#222' }"
>
Content
</div>
<!-- array syntax combines binding sources -->
<p :class="['badge', isActive && 'badge--on', theme]">Tag</p>
</template>
The merge rules, precisely:
| Input | Result |
|---|---|
class="card" + :class="{ active: true }" |
class="card active" — static classes are kept, dynamic ones appended |
:class="{ a: x, b: y }" with falsy values |
only truthy keys become classes |
:class="['a', cond && 'b', state]" |
array items that are falsy are skipped; strings and reactive refs are joined |
| a class on a child component tag | lands on the child's root element (fallthrough), merging with the child's own classes |
| duplicates | no dedup needed — identical class tokens are harmless |
The fallthrough row matters on teams: <MyButton class="lg" /> renders the child with lg merged onto its root node, so wrapper styling needs no prop plumbing. It is also the same mechanism that puts scoped-style caveats in the previous phase — the parent's scope attribute lands on that root too.
style follows the same object/array shape with value semantics instead of presence. Object keys are camelCase (backgroundColor, flexGrow) and numbers get px appended automatically for dimensional properties — fontSize: 14 renders font-size: 14px; unitless properties (opacity, zIndex, fontWeight) pass through untouched. Mixing strings and objects in the array form works: :style="[{ color }, baseStyle]" with later entries winning on conflict. Custom properties are the exception that proves the camelCase rule — write them as-is: :style="{ '--grid-gap': gap + 'px' }" and read them in CSS with var(--grid-gap).
Two habits separate clean code from brittle code here. First, stop building class strings. 'btn ' + (isOn ? 'btn-on' : 'btn-off') + ' ' + size loses every static class the moment a value is missing and forces you to maintain three parallel taxonomies; the object syntax keeps the mapping from state to class in one visible place, which is what CSS-in-template should look like. Second, treat class bindings as a state API. When a template accumulates :class="{ 'md:grid-cols-3': layout === 'grid' }"-style expressions, that is the moment to compute a named class list in script (const layoutClasses = computed(...)) so the template binds one identifier and devtools shows one boolean/object instead of an inline expression tree.
The behavior to test in your browser devtools once, so it sticks: inspect a node while toggling :class and watch the class attribute update in the same frame — the DOM change is exactly what your hand-rolled classList.toggle code used to do, minus the bugs. Next: Event Handling with v-on — bindings push state to the DOM; listeners bring user actions back.
URLs That Survive the Build: :src, :href, and the BASE_URL Trap
A report arrives: images work on localhost and 404 behind the company's /app/ reverse proxy. The template said <img src="/hero.png">, the file sat in public/, and production moved the app under a subpath. URL bindings are where template habits meet the bundler, and the failure modes are systematic.
Bundled assets must flow through imports. Anything under src/assets/ exists in the build only if imported, and it arrives with a content-hashed name — so the URL itself is state that only the bundler knows:
<script setup lang="ts">
import logoUrl from '@/assets/logo.svg' // hashed URL string
const props = defineProps<{ id: string }>()
const avatarUrl = computed(() => `/avatars/${props.id}.webp`) // runtime-composed
</script>
<template>
<img :src="logoUrl" alt="logo" />
<img :src="avatarUrl" alt="" />
</template>
<img src="@/assets/logo.svg"> as a static attribute does not resolve @ (the template's static-asset rewriting understands relative paths like ../assets/x.png, not aliases), which produces the classic dev-server 404 that still "looks right" in the code review. The import form is explicit, works with TypeScript (the vite/client types give *.svg module declarations), and tree-shakes.
Public files need the base prefix at runtime. A public asset referenced as /favicon.svg works at the domain root and breaks under base: '/app/'. Vite injects the configured base as a constant, so compose public URLs through it:
const manifestUrl = import.meta.env.BASE_URL + 'site.webmanifest'
Hardcoded root-absolute paths are the bug; hashed imported assets are immune because the bundler already stamped base into their URLs. This asymmetry — imports get base for free, public references must add it — is worth stating in any deployment discussion.
Boolean and presence attributes, revisited with real APIs. The removal rule (null/undefined/false deletes the attribute) makes bindings the correct way to express optional HTML:
<input :required="isRequired" :maxlength="maxLen ?? undefined" :disabled="!formReady" />
<img :src="src" :alt="decorative ? undefined : label" /> <!-- alt absent when decorative -->
Note the aria-* exception to keep assistive tech correct: aria-* and role are string-semantic attributes where "false" is meaningful information. :aria-hidden="false" removes the attribute (fine — absence equals false there), but :aria-pressed="isActive" on a toggle button removes aria-pressed when inactive, which screen readers report as "not a toggle button" instead of "toggled off". Bind explicit strings for those:
<button :aria-pressed="String(isActive)">Like</button>
The general review checklist for URL and conditional attributes: static path that a bundler should hash? import it · public file? prefix import.meta.env.BASE_URL · boolean attribute? bind a real boolean, never the string "false" · aria-*/role? bind 'true'/'false' strings deliberately. With state now flowing out to attributes, the next piece is the return channel: Event Handling with v-on.
A final production habit ties URL bindings to testing: give key elements stable hooks. A data-testid attribute bound from the row's id gives end-to-end tests a selector that survives class-name redesigns, and because it is an ordinary attribute it follows the same removal rule — bind undefined when the id is absent rather than leaking the string 'undefined' into the DOM. Teams that adopt this early stop writing CSS-selector queries in Playwright, and the bindings themselves document which elements the test suite depends on. The same discipline applies to image assertions: match the hashed prefix rather than an exact filename, since every content change renames the file. Attribute bindings are the seam where your data model meets both the browser and the tooling that verifies it — treat them as part of the page's public surface, not as incidental markup.
Quiz
1. `isSaving` is `false`. What does `<button :disabled="isSaving">Save</button>` render?
2. With `isOn = true` and `theme = 'dark'`, what classes does `<div class="card" :class="[{ active: isOn }, 'lg', theme]">` render?
3. Why should an avatar image URL be built as a computed value (`:src="avatarUrl"`) instead of assigned via document.querySelector in a watcher?
4. For a toggle button, what is the correct way to bind aria-pressed so assistive technology reports 'pressed, not pressed' instead of 'toggle, not a toggle'?
Flashcards
Question
What is the attribute-removal rule for bound values, and why does it matter for boolean attributes?
Click to reveal answer
Answer
null, undefined, and false remove the attribute; any other value sets it as a string. For presence-true attributes (disabled, checked, required) this is exactly right: :disabled="false" removes it and enables the element — whereas the literal disabled="false" (or :disabled="'false'") keeps it, because presence is truth.
Question
How do class and style bindings merge with static values and with each other?
Click to reveal answer
Answer
Automatically: static class/style survive and dynamic ones append; :class accepts an object (truthy keys become classes) or an array (falsy items skipped) mixable with refs/strings; :style accepts camelCase object(s) or arrays, later entries win, numbers get px for dimensional properties, custom properties stay literal ('--x'). No string concatenation and no dedup needed.
Question
How do URLs behave differently in static attributes vs bindings?
Click to reveal answer
Answer
Static src understands relative paths the plugin can rewrite (../assets/x.png) but not the @ alias; imported assets arrive as hashed URL strings and include base automatically. Public files need import.meta.env.BASE_URL + 'file' at runtime or they 404 under a subpath deploy. Dynamic concatenation the compiler can't see always needs an explicit import or glob.
Question
What is v-bind="$attrs" and when does it matter?
Click to reveal answer
Answer
It spreads every attribute passed to the component that wasn't declared as a prop (including listeners and class/style) onto the chosen element. Wrapper components use it to stay transparent; class and style even fall through to a child's root automatically. It's the object form of v-bind: a raw map of unbound values.
Question
Why do aria-* bindings need explicit 'true'/'false' strings?
Click to reveal answer
Answer
aria-* and role are string-semantic attributes where 'false' conveys meaning (aria-pressed="false" = pressed-off toggle). Vue's boolean-removal rule would delete the attribute when the bound value is false, which assistive tech reads as 'no such state', so bind String(isActive) instead of the boolean.
Revision Notes
Key Takeaways
- 1. v-bind (colon shorthand) makes an attribute an expression re-evaluated per render; false/null/undefined delete it — which is exactly correct for boolean attributes and dangerous for aria-* strings
- 2. class and style bindings merge with static values automatically; object and array syntaxes replace hand-concatenated class strings that lose statics and duplicate taxonomies
- 3. URLs are bundler state: import assets to get hashed names with base included, and prefix public files with import.meta.env.BASE_URL
- 4. Child components receive parent classes via root fallthrough — the same mechanism that makes wrapper styling work and scoped targeting subtle
- 5. The object form v-bind="{...}", including $attrs, spreads raw attributes — the building block of transparent wrapper components
Interview Tips
- • "What does :disabled=\"flag\" render when flag is false?" — attribute removed entirely; contrast with the literal disabled=\"false\" trap and explain presence-true semantics of boolean attributes
- • "How would you implement dynamic theming?" — class object keyed by theme tokens or style object with custom properties (--var) plus CSS var() — show you know both mechanisms and where each belongs
- • "Why does my image 404 only in production?" — static @/ alias src or root-absolute public path under a non-root base; fixes: import for hashed assets, BASE_URL prefix for public files
- • "What is v-bind=\"$attrs\" for?" — forwarding undeclared attributes to a root element so wrapper components stay transparent; mention class/style fallthrough and how defineProps splits attrs from props
Cheat Sheet
Dynamic Attributes: v-bind (:)
Basics:
<button :disabled="isSaving">…</button> <!-- false => attribute removed -->
<a :href="`/orders/${order.id}`">View</a> <!-- dynamic URL expression -->
<div v-bind="{ id, 'data-test': tid }">…</div> <!-- object form -->
<input :[attrName]="value" /> <!-- dynamic argument (identifier only) -->
Removal rule: null | undefined | false → attribute absent; everything else → String(v). Boolean attributes (disabled, checked, required) are presence-true — never bind the string 'false'.
class / style:
| Form | Example | Notes |
|---|---|---|
| object | :class="{ active: isOn }" |
falsy keys skipped |
| array | :class="['a', cond && 'b', theme]" |
falsy items skipped, refs unwrapped |
| style object | :style="{ fontSize: n + 'px', '--gap': g }" |
camelCase keys; px auto for dimensions; custom props literal |
| child root | <Child class="lg" /> |
falls through and merges on the child's root |
URLs: imported asset → hashed URL incl. base (import it; static @/ src does not resolve) · public file → import.meta.env.BASE_URL + 'file' · runtime-composed URLs → computed binding, never string-concatenated static src.
aria-*/role: string semantics — :aria-pressed="String(isActive)" so 'false' stays present.
Readability: bind names (:disabled="hasActiveRequests"), compute heavy class lists in script, keep the object-syntax state→class mapping visible in one place.