Vue – The Single-File Component

October 15, 20254 min readUpdated 8/24/2026

A Vue component lives in a single .vue file with up to three blocks: a <template>, a <script> and a <style>. That co-location is the whole idea. Everything about one component — what it looks like, what it does and how it is styled — is in one place, and none of it leaks anywhere else.

This is the smallest real one in the demo application. It renders the "there is nothing here" panel that the reel list, the collections page and the search results all use:

<script setup>
defineProps({
  icon: { type: String, default: "bi-inbox" },
  title: { type: String, required: true },
  message: { type: String, default: "" },
});
</script>

<template>
  <div class="text-center py-5">
    <i class="bi fs-1 text-tertiary d-block mb-2" :class="icon" style="opacity: 0.5"></i>
    <h6 class="mb-1">{{ title }}</h6>
    <p v-if="message" class="text-secondary small mb-3">{{ message }}</p>
    <!-- Callers drop a primary action in here. -->
    <slot />
  </div>
</template>

Three blocks, about twenty lines, and no imports, no registration and no boilerplate class. That is a complete component.

The template block

The template is real HTML. Not a string, not JSX — the browser's own parser could read it. Vue compiles it to a render function at build time, so nothing is parsed at runtime.

Two things in it are not plain HTML: {{ title }} interpolates a value, and attributes beginning with v- or : are directives. Lesson 4 covers all of them.

Vue 2 required exactly one root element. Vue 3 does not — a template may have several, or none at all.

The script block, and what <script setup> does

You will see two forms in the wild. The old one exports an object:

<script>
import { ref } from "vue";

export default {
  props: { title: String },
  setup(props) {
    const open = ref(false);
    function toggle() { open.value = !open.value; }
    // Everything the template needs has to be returned.
    return { open, toggle };
  },
};
</script>

The modern one adds setup as an attribute:

<script setup>
import { ref } from "vue";

defineProps({ title: String });

const open = ref(false);
function toggle() { open.value = !open.value; }
</script>

These do the same thing. The difference is that with <script setup> the body of the block is the setup function, so every top-level binding — imports, variables, functions — is available to the template automatically. There is no return statement to forget, and an imported component is used simply by importing it.

It is also faster. The compiler knows the bindings at build time, so it can reference them directly in the generated render function instead of going through a proxy object.

Use <script setup> for new code. This track does, everywhere.

When you still need a plain <script>

Rarely, and mostly for one thing: options that apply to the component itself rather than to an instance. You can have both blocks in the same file.

<script>
// Runs once, when the module is imported -- not per instance.
export default { inheritAttrs: false };
</script>

<script setup>
const props = defineProps({ label: String });
</script>

A component's name is inferred from its filename, so EmptyState.vue is <EmptyState> with nothing declared. That is why the file naming matters more in Vue than it looks like it should.

The style block

Styles are scoped by adding one attribute:

<style scoped>
.toast-enter-active,
.toast-leave-active {
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
  opacity: 0;
  transform: translateX(18px);
}
</style>

scoped means the compiler adds a unique data attribute to every element the component renders and rewrites each selector to require it. So .toast-enter-active here becomes something like .toast-enter-active[data-v-7f3d], and a .toast-enter-active somewhere else in the application is untouched.

Three things about scoped styles are worth knowing before they surprise you:

It is not the Shadow DOM. Global styles still reach inside — which is exactly what you want when the application is built on Bootstrap, as this one is.

A child component's internals are not yours to style. The child's elements carry its own data attribute, not the parent's. Reaching in deliberately needs :deep(.selector).

Scoped is opt-in. A <style> with no scoped is a plain global stylesheet, which is occasionally what you want and usually an accident.

What the build actually produces

None of this reaches the browser. @vitejs/plugin-vue compiles each .vue file into an ordinary JavaScript module: the template becomes a render function, the script becomes a setup function, and the style block is extracted into CSS that Vite handles like any other stylesheet.

The practical consequence is that a template error is a build error. A mistyped directive or an unclosed tag fails the build rather than throwing in a browser at the moment a user hits that screen.

The order of the blocks

Vue does not care. The demo application puts <script setup> first, template second, style last, and that ordering is worth copying: the script explains what the template is talking about, so reading the file top to bottom explains the component.

Next: Template Syntax and Directives — everything you can write inside that template block.