Skip to main

Slots and Scoped Slots in Vue.js

5 min read

Slots allows us to reuse and control presentation of a UI component in a simple way. Most common use case of slots is delegating “what” to render to the parent component (or the component using the slot) and adding to that, scoped-slots allow us to pass back data to the parent component to help it render the “what”.

Why would I need slots?

Let’s say we have a Custom Submit button used in our form and it has two states based on the form. An initial state where Button is active with ‘Submit’ text and a Loading state where Button is disabled and showing ‘Loading…” text. Typically, change of button state based on the form is managed in the component that holds the form state. So it makes sense to just pass props to our Custom Submit button whether it should show active or disabled states. This is how our CustomSubmitButton would look like:

/* CustomSubmitButton.vue */
<script setup lang="ts">
withDefaults(defineProps<{ loading: boolean }>(), {
  loading: false,
});
/* Other CustomSubmitButton related Logic, Analytic events etc here.. */
</script>

<template>
  <div class="wrapper">
    <button class="submit-btn">{{ loading ? "Loading..." : "Submit" }}</button>
  </div>
</template>
/* MyForm.vue */
<template>
  <form>
    <!--  -->
    <CustomButton />
  </form>
</template>
Form with Custom Button

It’s all well and good until you have another form where you’d like to use this CustomSubmitButton but the marketing team said they want to try out a different text for that button instead of ‘Submit’. So you’re probably thinking, that’s fine I can just pass whatever text the button should show as props and it’ll be fine.

/* CustomSubmitButton.vue */
<script setup lang="ts">
withDefaults(defineProps<{ loading: boolean; text: string }>(), {
  loading: false,
  text: "Submit",
});
</script>

<template>
  <div class="wrapper">
    <button>{{ loading ? "Loading..." : text }}</button>
  </div>
</template>
/* MyForm.vue */
<template>
  <form>
    <!-- Other form fields  -->
    <CustomButton text="Register" />
  </form>
</template>

After a while, marketing team comes back to you and say we want to change the “loading” text too. You can probably add another prop called loadingText to the custom button and get away again. But what happens when you have yet another form using this CustomSubmitButton where you’d like to show a Spinner Component instead of “Loading…” text?

We can solve this problem using slots by delegating “what” to render to the View/Parent using the component. You can think of slots as the literal meaning, “slot”. We make a space or a slot in the component that gets reused and let the parent fill in that slot. Vue.js comes with a special tag for declaring slots:

<slot></slot>

In our CustomButton component we can replace the dynamic part (“Loading…” and “Submit” texts) into a <slot></slot>.

/* CustomSubmitButton.vue */
<template>
  <div class="wrapper">
    <button class="submit-btn">
      <slot></slot>
      <!-- 👈 this part gets replaced by whatever was passed by the parent -->
    </button>
  </div>
</template>
/* MyForm.vue */
<template>
  <form>
    <!-- Other form fields  -->
    <CustomButton> Register </CustomButton>
  </form>
</template>
CustomButton with Slot content

Now since the form state is managed by the Parent Form component, we can choose what to show in the CustomButton slot based on the state within the Parent Form component. So let’s add the Loading Spinner into the slot as well.

/* MyForm.vue */
<template>
  <form>
    <!-- Other form fields  -->
    <CustomButton>
      <!-- Can be replaced by a loading component -->
      <template v-if="loading">Loading...</template>
      <template v-else>Register</template>
    </CustomButton>
  </form>
</template>

Scoped Slots

Slots also provide a very powerful feature of passing value(s) back up to the parent component. Let’s say we want to access the focus or hover state (or any internal state) of the CustomButton component in the form, we can do so by binding the property onto the <slot> component.

/* CustomSubmitButton.vue */
<script setup lang="ts">
import { ref } from "vue";
/* --- */
const isFocused = ref<boolean>(false);
</script>
<template>
  <div class="wrapper">
    <button
      @focusout="isFocused = false"
      @focusin="isFocused = true"
      class="submit-btn"
    >
      <slot v-bind:isFocused="isFocused"></slot>
    </button>
  </div>
</template>
/* MyForm.vue */
<template>
  <form>
    <!-- Other form fields  -->
    <CustomButton v-slot="{ isFocused }">
      <!-- Do whatever you want with `isFocused` value in the parent template -->
      {{ isFocused }}
      <!-- Can be replaced by a loading component -->
      <template v-if="loading">Loading...</template>
      <template v-else>Register</template>
    </CustomButton>
  </form>
</template>

We can also wrap all the form related logic into a its own renderless component which handles submitting form, error states, form field states and expose them through scoped slots.

/* MyForm.vue */
<template>
  <MyCustomFormWrapper v-slot="{ errors, submit, fields }">
    <!-- Other form fields  -->
    <span v-if="errors.email" class="error">{{ errors.email }}</span>

    <CustomButton @click="submit" v-slot="{ isFocused }">
      <!-- Do whatever you want with `isFocused` state in the parent template -->
      {{ isFocused }}
      <!-- Can be replaced by a loading component -->
      <template v-if="loading">Loading...</template>
      <template v-else>Register</template>
    </CustomButton>
  </MyCustomFormWrapper>
</template>

This pattern is pretty common in libraries like VueUse, vee-validate, Headless UI and many more, since it allows us to build highly reusable renderless components.

Further Reading / References: