Fallthrough Attributes
An attribute you did not declare lands on the component's root element; $attrs and inheritAttrs let you put it where it belongs.
defineOptions({ inheritAttrs: false }) // then v-bind="$attrs"A fallthrough attribute is an attribute or v-on listener passed to a component that is not declared as a prop or an emit. Vue does not drop it. If the component renders a single root element, the attribute is applied to that root.
This is why <my-button class="large" /> works without class being a prop. class and style merge with whatever the root element already has, so class="btn" in the child plus class="large" from the parent gives class="btn large". Other attributes replace.
$attrs
$attrs holds all of them. Names keep their original casing, so data-test-id is read as $attrs['data-test-id']. Listeners appear as functions under on names: @click becomes $attrs.onClick. In script, useAttrs() imported from vue returns the same object.
Taking control
Automatic inheritance is wrong whenever the root element is a wrapper. Turn it off with defineOptions({ inheritAttrs: false }) and bind v-bind="$attrs" on the element that should receive them. A component with more than one root node never inherits automatically; without an explicit v-bind="$attrs" you get a runtime warning.
What trips people up
Forwarding carries listeners too. If you bind $attrs to an element that already has its own @click, both handlers run on one click. Also note that with inheritAttrs: false, a parent's class goes to the inner element, not your wrapper.
Example
<!-- src/components/TextField.vue -->
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const { label, id } = defineProps<{
label: string
id: string
hint?: string
}>()
</script>
<template>
<div>
<label :for="id">{{ label }}</label>
<!-- type, placeholder, required, autocomplete, @blur, data-*:
everything the parent passed and this component did not
declare goes on the input, not on the wrapper. -->
<input :id="id" v-bind="$attrs" />
<small v-if="hint">{{ hint }}</small>
</div>
</template>
<style scoped>
div {
display: grid;
gap: 0.25rem;
}
small {
opacity: 0.7;
}
</style>When to use it
- Let a labelled field wrapper pass type, required and autocomplete through to the real input.
- Keep a data-test-id from the parent landing on the element your end-to-end test clicks.
- Stop a parent's class being applied to a layout wrapper instead of the control inside it.
- Fix the extraneous-attributes warning on a component with two root nodes.
More examples
What the parent writes, and where it lands
The reason to forward rather than declare: a wrapper cannot usefully re-declare every input attribute, and a test id or an autocomplete hint must reach the real input to have any effect. Note that v-model here needs TextField to accept a model, which defineModel provides; the remaining attributes travel through $attrs.
<!-- src/views/ProfileView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TextField from '../components/TextField.vue'
const email = ref('')
const touched = ref(false)
</script>
<template>
<form>
<!-- label, id and hint are props.
type, required, autocomplete, @blur and data-test-id are not,
so they fall through onto the <input> inside TextField. -->
<text-field
id="email"
label="Email"
hint="We only use this to sign you in."
v-model="email"
type="email"
required
autocomplete="email"
data-test-id="email-field"
@blur="touched = true"
/>
<p v-if="touched">Touched</p>
</form>
</template>More than one root node
A heading followed by a bordered section. A multi-root component never applies fallthrough attributes automatically; if the parent passes a class and no root has v-bind="$attrs", the development build logs an extraneous-attributes warning and the class is lost. Picking the target explicitly also documents which element the parent is styling.
<!-- src/components/PanelSection.vue -->
<script setup lang="ts">
defineProps<{ heading: string }>()
</script>
<template>
<h2>{{ heading }}</h2>
<!-- With several root nodes Vue cannot guess which one should
take the parent's attributes, so it warns unless you say.
No inheritAttrs: false is needed here. -->
<section v-bind="$attrs">
<slot />
</section>
</template>
<style scoped>
section {
padding: 1rem;
}
</style>Reading $attrs in script
Renders a button when the parent attached a click handler and a link otherwise, forwarding everything either way. This shows the on-prefixed naming: @click is attrs.onClick, @blur is attrs.onBlur. useAttrs is the script-side equivalent of $attrs and is imported from vue; reach for it when the markup you render depends on what the parent passed.
<!-- src/components/ActionLink.vue -->
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
// useAttrs() returns the same object the template calls $attrs.
const attrs = useAttrs()
// A @click listener arrives as attrs.onClick.
const isAction = computed(() => typeof attrs.onClick === 'function')
</script>
<template>
<button v-if="isAction" type="button" v-bind="attrs">
<slot />
</button>
<a v-else v-bind="attrs">
<slot />
</a>
</template>
<style scoped>
button,
a {
font: inherit;
}
</style>
Discussion