Props & Slots in Vue

Choosing the Right API for Professional Component Design

A hands-on comparison of Vue.js props and slots with practical decision guidance and real-world patterns

2026/01/29

Background

Component-based frameworks are now the de facto industry standard in software development. Components structure the user interface into clearly separated areas and significantly increase code reusability. At the same time, they make frontend architectures more maintainable in the long run.

When layout requirements change, adjustments usually only need to be made in a single place: the component itself. Components encapsulate presentation and related logic, enabling a clean separation of concerns even on the frontend.

The concept of components can therefore be found in almost all modern frontend and fullstack frameworks. Well-known examples include Vue.js, React, Angular, Svelte, Laravel with Blade, and Freemarker. However, not all solutions offer the same APIs or the same level of flexibility.

This article focuses on component design in Vue.js.

Mastering Customizability

The more frequently components are used, the more important their interfaces become. This is especially true when implementing design systems or building UI libraries shared across multiple projects. Poor API decisions often can only be fixed later through extensive (and expensive) refactoring.

Defining the right interfaces from the start is therefore a key factor in professional frontend architecture.

The central question when designing components is: How does data flow between components?

For optimal reusability, the same components must be able to satisfy very different requirements. A button component needs changing labels, a link component needs different URLs, and a card component almost never displays the exact same content twice.

Data flow is usually bidirectional: from parent to child components (for example via props) and from child to parent components (for example via events). The top-down data flow, however, exists almost universally. In Vue.js, two APIs are available for this purpose: props and slots.

Props

props are the most common way to pass data to a Vue component. Syntactically, they resemble HTML attributes. Using data binding (e.g. :title or v-bind:title), reactive variables can also be passed. Reactive means that changes to these variables automatically trigger follow-up actions, such as UI updates or recalculation of computed properties, without any explicit intervention. Within a component, props are available both in the template and in the logic (<script>).

props can be typed with TypeScript, marked as optional (using ?), and provided with default values (for example via withDefaults).

The following example shows a simple card component using props:

        <!-- Card.vue -->

<template>
  <div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
    <h3 v-if="title">{{ title }}</h3>
    <p>
      {{ content }}
    </p>
  </div>
</template>

<script setup lang="ts">
  defineProps<{
    title?: string;
    content: string;
  }>()
</script>

      

The component defines two props (title and content), which are rendered in the UI via template bindings.

Props can be passed either statically or reactively:

        <!-- App.vue -->

<!-- not reactive -->
<Card title="Hello world!" content="Lorem Ipsum" />

<!-- reactive -->
<Card :title="cardTitle" :content="cardContent" />

<script setup lang="ts">
  import { ref } from 'vue';

  const cardTitle = ref('John Doe');
  const cardContent = ref('Turn visitors into customers with a clear message here.');
</script>

      

In the reactive scenario, the UI updates automatically whenever cardTitle or cardContent changes.

Since props are also available in the <script>, they can be further processed there:

        <!-- Card.vue -->

<script setup lang="ts">
  import { computed } from 'vue';

  const props = defineProps<{
    title?: string;
    content: string;
  }>()

  const titleWithEmoji = computed(() => `${ props.title } 🚀`);
</script>

      

In this example, titleWithEmoji is recalculated whenever props.title changes.

Limitations of Props

Props are excellent for configuring components or passing simple data. However, they are inherently value-based.

Our <Card /> component is dynamic in terms of its data, but it strictly defines how that data is presented. In other words, it is intentionally opinionated.

This becomes problematic especially for props like content. Since it is just a string, rendering more complex content—such as buttons, icons, or links—is only possible via workarounds (for example using VNodes).

        <!-- App.vue -->

<!-- undesirable / broke -->
<Card title="Hello world!" content="<b>Lorem</b> Ipsum" />

      

Conceptually <Card /> fulfills a pure layout function. Therefore the component does not care what content is projected inside it.

Rule of thumb: props are better suited for configuration than for free-form visualization.

This is where slots come into play.

Slots

While props are comparable to HTML attributes, slots are closer to the child elements of an HTML tag.

The content of a slot is not as easily accessible in the <script> as props are. On the other hand, no explicit declaration is required. It is sufficient to define where the slot should be rendered in the component’s template. The component itself is agnostic about the slot’s actual content—in other words, it is unopinionated with regard to visualization.

        <template>
  <div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
    <h3 v-if="title">{{ title }}</h3>
    <p>
     <slot />
    </p>
  </div>
</template>

<script setup lang="ts">
  defineProps<{
    title?: string;
  }>()
</script>

      

The content prop can be removed entirely. From this point on, <Card /> does not care what the content of the <p> element is. It can consist of variables, HTML elements, or even other Vue components, such as <Hyperlink />:

        <!-- Hyperlink.vue -->
<template>
  <a :href="href" class="text-blue-500 hover:text-blue-700">
    <slot />
  </a>
</template>

<script setup lang="ts">
  defineProps<{
    href: string;
  }>()
</script>

      
        <!-- App.vue -->

<Card :title="cardTitle">
  {{ cardContent }}
  <Hyperlink href="https//nils-siemsen.de">
    My Homepage
  </Hyperlink>
</Card>

      

The reactivity of cardContent remains intact. Everything between the opening and closing <Card> tags is rendered exactly where <slot /> is placed inside <Card />. This allows us to include a hyperlink without adding additional configuration props to <Card />.

slots can be placed multiple times and can also be named, enabling the use of multiple slots within the same component:

        <!-- Card.vue -->

<template>
  <div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
    <h3 v-if="$slots.title">
      <slot name="title" />
    </h3>
    <p>
      <slot />
    </p>
  </div>
</template>

      

Just like with props, you can check whether slots are assigned ($slots) and implement conditional rendering accordingly.

Limitations of Slots

slots are not the ultimate solution. The example of the <Hyperlink /> component emphasizes why making the richt API choice individually is crucial.

Imaging <Hyperlink /> would replace all props with slots.

        <!-- Hyperlink.vue-->
<template>
  <a :href="$slots.default" class="text-blue-500 hover:text-blue-700">
    <slot />
  </a>
</template>

      

Trying to bind the slot's content via : to the href of the <a> leads into the following result:

        <a href="[object Object]" class="text-blue-500 hover:text-blue-700"> My Homepage </a>

      

Also the Vue Compiler will notify you with the warning below:

        Type 'VNode<RendererNode, RendererElement, { [key: string]: any; }>[] | undefined' is not assignable to type 'string | undefined'.

      

The warning also explains exactly why :href="$slots.default" results in href="[object Object]": a slot always resolves to an array of VNodes.

These nodes are Vue.js’ internal abstractions of rendered content. While it is technically possible to access the textual content of a VNode, doing so is significantly more complex than simply using props.

Rule of thumb: slots are better suited for visualization than for configuration.

Making the Decision

The goal of good components is maximum reusability—ideally across multiple products. Every good API decision pays off over time, while every poor one increases the cost of future refactorings for all consumers of the component.

Components can only be reused flexibly if they expose well-defined customization points via props or slots. These APIs do not compete with each other; they complement each other:

  • props control behavior and configuration
  • slots enable flexible presentation

As the number of consumers of a component grows, so does the variety of use cases. For that reason, I generally recommend favoring slots whenever the content is not required by the component’s internal logic.

For example, there is little conceptual reason to do this:

        <Card variant="large" title="Hello world!" content="Lorem Ipsum" />

      

Note the variant prop, which might define a design variation, while leaving visualization of the remaining content to slots.

There are, however, virtually unlimited reasons to do this instead:

        <Card variant="large">
  <template #title>Hello World!</template>
  Lorem Ipsum
</Card>

      

Each consumer can render exactly what their specific requirements demand.

My recommendation can therefore be summarized as follows:

Use slots for visual content that varies widely. Use props for data that controls behavior or structure.

Beware of Too Much Flexibility

There are scenarios in which props may be preferable to slots event if the content is purely visual. When multiple independent teams work with the same components, it can make sense to deliberately limit visual flexibility.

Imagine a <Card /> component whose design system requires a fixed typography for its text content:

        <!-- Card.vue -->

<template>
  <div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
    <h3 v-if="$slots.title">
      <slot name="title" />
    </h3>
    <p class="text-base text-gray-900 font-regular">
      <slot />
    </p>
  </div>
</template>

      

Using slots allows consumers to override styles easily:

        <!-- App.vue -->

<template>
  <Card>
    <span class="text-2xl font-bold text-green-200">I solemnly swear that I am up to no good.</span>
  </Card>
</template>

      

Because CSS rules on child elements override equally specific rules on parent elements, deviations from the intended UX are trivial.

This problem can be solved by replacing slots with props:

        <!-- App.vue -->

<template>
  <Card :contenr="Crap. You got me" />
</template>

      

Now the content can no longer be visually altered.

In this case, customizability is deliberately restricted to ensure global consistency.

This decision guideline can therefore be extended:

Use props for visual content when a strict design system must be enforced, especially across third-party consumers.

This becomes relevant mainly when components are shared across teams or organizations and is therefore primarily a concern in enterprise environments. In general, the advantages of slots for visual content still outweigh the downsides.

Keep in mind, however, that for third parties, slots can be the Death Star when it comes to disrupting design systems. Trust is of the essence.

Bonus: Headless Components

To conclude, let`s look at a design pattern that shows how slots can interact with a component’s internal logic.

Headless components are called headless because the “head” — the presentation — is missing or can be completely replaced. These components encapsulate logic only and may provide a default rendering suggestion that can be fully overridden via slots.

This works because slots can access internal state via scoped slots.

Imagine a semantic blog component called <Posts />. Its responsibility is to render a list of blog posts.

It renders posts using v-for:

          <Card v-for="post in posts">
      <template #title>
          {{ post.title }} [{{ post.id }}]
      </template>
      <div class="flex flex-col">
          <i class="mb-3">by user {{ post.userId }}</i>
          <p>
              {{ post.body }}
          </p>
      </div>
  </Card>

      

After fetching them from an API:

          onMounted(async () => {
    posts.value = await fetch(props.url)
        .then(res => res.json())
        .catch(console.error);
  })

      

Now expose posts via a scoped slot:

        <!-- Posts.vue -->

<slot :posts="posts">

      

The parent component can now freely define how these reactive data are rendered:

        <!-- App.vue -->

  <Posts>
    <template #default="{ posts }">
      <nav class="grid grid-cols-1">
        <Hyperlink v-for="post in posts" :href="`https://jsonplaceholder.typicode.com/posts/${ post.id }`">
          {{ post.title }}
        </Hyperlink>
      </nav>
    </template>
  </Posts>

      

The full reactivity of posts is preserved. This pattern allows filtering, searching, and pagination logic to be implemented once and reused across many different visual representations.

Feel free to explore the accompanying Github-Repository to see more real-world use cases for props and slots.

Hi, I'm Nils!

👋 Hey, I'm Nils — a full-stack developer, AI enthusiast, and creative problem solver from Ulm with a passion for intelligent solutions, clean code, and great ideas. I build complete products across frontend, backend, and architecture — from idea to deployment 🚀.
sketch of Nils Siemsen in casual style