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.

SyntaxdefineOptions({ 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

Example · vue
<!-- 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.

Example · vue
<!-- 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.

Example · vue
<!-- 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.

Example · vue
<!-- 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

  • Be the first to comment on this lesson.