Skip to content
beginner Phase 1 · Project Setup and Your First App

Single-File Components

Structure template, script, and style blocks inside one .vue file.

40m
0 problems
Topic Progress 0%

Anatomy of a .vue File: One Module, Three Blocks

A single-file component (SFC) is a text file with the extension .vue that contains up to three kinds of block: a <template> for markup, a <script> for logic, and a <style> for CSS. The browser never parses this file. When Vite sees an import of it, @vitejs/plugin-vue compiles the blocks into a plain ES module — a JavaScript file the browser loads like any other — and registers it as an HMR boundary so edits hot-swap just that component.

A complete, realistic component:

<!-- src/components/PriceTag.vue -->
<script setup lang="ts">
const props = defineProps<{ amount: number; currency: 'USD' | 'EUR' }>()

const formatted = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: props.currency
}).format(props.amount)
</script>

<template>
  <span class="price">{{ formatted }}</span>
</template>

<style scoped>
.price {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}
</style>

Each block has options that show up constantly in real codebases:

Block Attributes Meaning
<template> (none common) Exactly one per file; compiles to a render function
<script> setup, lang="ts", src="..." setup enables the composition style; lang picks the transpile language
<style> scoped, module, lang="scss", src Multiple <style> blocks are allowed — mix one scoped and one global

lang="ts" on the script block is what makes TypeScript inside SFCs work at all: Vite's esbuild transforms the block, and — in a project scaffolded with the TS preset — vue-tsc type-checks it during npm run build. The same lang attribute extends to styles (lang="scss") provided the corresponding Vite plugin is installed, which is why adding Sass means npm i -D sass and nothing else.

Two properties of SFCs are worth stating plainly because interviewers probe them. First, an SFC is a module. It has imports and (with script setup) top-level declarations, it participates in tree-shaking like any other module, and it exports a component options object under the hood. Second, the compiler does real work at build time: template expressions are compiled to render-function calls (_ctx.amount becomes a property read on the render context), static subtrees are hoisted so patching skips them, and the scoped attribute rewrites CSS as you will see below. Vue 3's runtime is the runtime-only build in a create-vue project — it contains no template compiler, so any template string left for the browser to compile would fail. SFC templates are precompiled, which is precisely why the runtime can stay small.

The <script> block comes in two forms, and knowing both pays off when reading older code. script setup is the default in new projects: declarations at the top level are automatically exposed to the template — no return, no components: {} list. The classic form (<script> without setup) exports options (props, data, methods) and requires an explicit setup() or export default {}. They can even coexist in one file: a plain block for things that must be computed before setup runs, the setup block for everything else. You will dig into script setup's compilation rules in The script setup Syntax; here it is enough to read it fluently.

Practical conventions that keep teams sane: one component per file, filename equals component name (UserCard.vue → UserCard), PascalCase filenames so imports never collide with HTML tags, and <template> root allowed to have multiple elements since Vue 3 — fragment roots no longer break CSS or ref wiring, though a stable single root still makes slots and attribute fallthrough easier to reason about. Continue to Project Structure and Conventions for how these files are laid out in a team-scale app.

Scoped Styles: The data-v Attribute Trick Behind <style scoped>

Global CSS and components fight each other. A .title { margin-top: 2rem } rule written for one view silently restyles every h2 in the app, and the debugging session that follows has no local culprit — the rule lives in a different component's file. <style scoped> solves this, and the mechanism behind it is one of the most commonly asked compilation questions in Vue interviews.

With scoped, the build step does two rewrites. In the template, every element the component renders gets a generated attribute — data-v-f2f6a1b, hashed from the file path. In the style, every selector is compiled with that attribute appended:

/* you wrote */
.price { color: teal; }
.card .price { color: crimson; }

/* Vue compiles to */
.price[data-v-f2f6a1b] { color: teal; }
.card .price[data-v-f2f6a1b] { color: crimson; }

The result is an attribute selector, not shadow DOM: the rules still live in one global stylesheet, but they can only match elements carrying this component's marker. Specificity changes accordingly (one extra attribute selector), which occasionally surfaces as a scoped rule losing to a third-party global rule it would previously have beaten.

The rules that catch people out:

  • Scoped styles do not reach a child component's internals. A child's elements carry the child's hash. If Card.vue tries .child-title { color: red } scoped, nothing matches the title inside <ChildTitle /> — because ChildTitle's <h3> has data-v-child, not data-v-card.
  • The child's root element is the exception. Vue stamps the parent's scope id onto the child's root node as well, so a scoped .child-title does style the child's outermost element. The result is a confusing rule: parent scoped CSS works on a child's root and fails one level deeper.
  • The escape hatch is :deep(). It compiles to "my scope id, then anything below":
<style scoped>
.card :deep(.child-title) {
  color: crimson;   /* matches .child-title inside the child subtree */
}
.card :global(.legacy-widget) {
  border: 0;        /* opt out entirely for one selector */
}
</style>

:deep(.x) becomes [data-v-card] .x — my elements, then yours. That is also the idiomatic way to style a third-party widget you render (.datepicker :deep(.day-cell)), because you do not own its classes and cannot stamp it with your hash.

A deliberate decision you will meet in code review: do not add scoped to every style reflexively. Layout primitives, CSS custom properties, resets, and design tokens are meant to be shared — put those in a global stylesheet imported once in main.ts (or in assets/base.css in a create-vue project). The scoped attribute is for a component's private geometry: its padding, its internal grid, its hover state. A component's public skin — the classes a parent must be able to tweak — is better expressed through passed class attributes than through leaking CSS.

For component libraries, <style module> offers a third path: class names become locally scoped runtime properties on a styles object, so styles.price in setup references a hashed class in the template. It solves the same leakage problem with CSS Modules semantics — useful in mixed codebases, uncommon in Vue-first teams.

The diagnostic loop when a style "should" apply and doesn't: inspect the element, check which data-v-* attributes it actually carries, then check which scope id your rule compiled to. Nine times out of ten the mismatch is a child-component boundary that needs :deep() or belongs in a global stylesheet.

Making a Component Usable: Import, Registration, and Tag Names

A .vue file is inert until another component imports it. There is no global registry step, no name-indexed lookup — registration in a create-vue project is just ESM plus a template tag that the compiler resolves.

<!-- src/components/CheckoutSummary.vue -->
<script setup lang="ts">
import PriceTag from '@/components/PriceTag.vue'
import CartLine from './CartLine.vue'

const lines = [
  { id: 1, name: 'Keyboard', price: 79.9 },
  { id: 2, name: 'Mouse', price: 24.5 }
]
</script>

<template>
  <ul>
    <CartLine v-for="line in lines" :key="line.id" :line="line" />
  </ul>
  <PriceTag :amount="104.4" currency="USD" />
</template>

Under script setup, any top-level import used in the template is compiled into a component registration. The compiler rewrites the template to look up PriceTag in the render context, so an import you forget to use in the template is simply dead code that tree-shaking removes, and an import you use only inside <script> (say, a utility function) is never mistaken for a component. This automatic exposure is the single biggest source of boilerplate removal compared to the Options API's components: { PriceTag } map.

Tag naming is decided by HTML, not by Vue. Templates are parsed as HTML, where tag names are case-insensitive, so the canonical form in templates is kebab-case: <price-tag>. Vue resolves both <PriceTag> and <price-tag> to the same registered component — SFC templates let PascalCase through — but the ecosystem standard is PascalCase in script (imports, defineAsyncComponent) and kebab-case in templates. That split keeps custom elements and native elements (<textarea>, <svg>) unambiguous and matches what vue/ESLint rules enforce.

Three gotchas that burn people once each:

  1. A component is registered per-file, not per-app. Importing PriceTag in CheckoutSummary.vue makes it available only in that template. Sibling components need their own imports. When a dozen files genuinely share one component, either import it in each (the cost is one line) or promote it with app.component('Icon', Icon) in main.ts — the global path trades a hidden dependency for less noise.
  2. The filename is the component's identity for devtools and HMR. Vue Devtools shows the component by that name; the file path drives the HMR boundary. Renaming PriceTag.vue to Money.vue without updating imports fails the build immediately (good), but renaming only the exported identifier inside a classic <script> block silently changes the rendered name while the import still resolves — another reason to prefer script setup, where the filename wins.
  3. Props and events cross the boundary, nothing else does. <PriceTag :amount="104.4" /> passes data down; the child declares it with defineProps. Styling the child's internals from the parent requires either class fallthrough (<PriceTag class="big" /> lands on the child's root automatically) or the :deep() mechanism from the previous chapter. There is no ::v-parent back door into a child's private markup — that wall is the design.

Async registration exists for heavy children — const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue')) — which produces a separate chunk loaded on demand; combine it with <Suspense> when the child's setup itself is async. For a first project, plain imports are enough; reach for async when a route's chunk measurably bloats the initial bundle.

You now have the full loop: import a .vue file, render its tag, style it privately, and wire props between them. Project Structure and Conventions turns that into a folder layout a team can navigate.

Quiz

1. What does `<style scoped>` in a .vue file compile to, at a mechanical level?

Question 1 options

2. Card.vue renders `<Child />` and wants to restyle `.child-title`, an element deep inside Child's own template. What works?

Question 2 options

3. In a script setup component, how does `<PriceTag>` in the template become a registered component?

Question 3 options

4. In devtools and in Vue's component tree, what is the name of a single-file component in a script setup project?

Question 4 options

Flashcards

Question

What blocks can a .vue file contain, and what does the build actually do with them?

Answer

One <template> (compiles to a render function), one or more <script> blocks (optionally script setup, lang="ts", src), and one or more <style> blocks (optionally scoped, module, lang="scss", src). @vitejs/plugin-vue compiles them into a plain ES module; the browser never parses .vue and the runtime-only build never compiles templates itself.

Question

How is <style scoped> implemented?

Answer

The compiler appends a generated attribute selector ([data-v-hash], hashed from the file path) to every rule and stamps data-v-hash on every element in that component's template. It is attribute scoping inside one global stylesheet — not Shadow DOM — so specificity changes and child component internals (which carry the child's hash) are unreachable without :deep().

Question

When does a parent's scoped style apply to a child component, and when does it stop?

Answer

The child's ROOT node receives the parent's scope id too, so a scoped selector from the parent styles the child's outermost element. One level deeper — inside the child's own template — elements carry only the child's id, so the parent needs :deep(.selector) (which compiles to '[parent id] .selector') or an unscoped global rule.

Question

How does a component get registered for use in a template under script setup?

Answer

A top-level import that the template references is compiled into a component registration — no components map, no global registry. Naming convention: PascalCase for the import in script, kebab-case for the tag in the template (HTML case-insensitivity makes kebab the canonical form; Vue resolves both).

Question

What does `lang="ts"` on the script block do, and what must exist for it to type-check?

Answer

It tells the Vite pipeline to run the script block through TypeScript (esbuild) before serving or bundling. Type-checking is a separate concern: only vue-tsc in the build/type-check script verifies the block's types — esbuild alone strips them. For styles, lang="scss" likewise needs the sass package installed.

Revision Notes

Key Takeaways

  • 1. A .vue file is a module the build compiles: template → render function, script → module body, styles → (possibly rewritten) CSS; the browser never sees the .vue source
  • 2. scoped CSS is attribute selectors plus a data-v stamp, so it stops at child boundaries — :deep() crosses them and the child's root node is the documented exception
  • 3. script setup turns top-level imports used in the template into registrations automatically, deleting the components-map boilerplate
  • 4. One component per file, filename equals component name, PascalCase in script and kebab-case in templates — the conventions ESLint and devtools both assume
  • 5. Shared tokens belong in global styles; the scoped block is for a component's private geometry, and public skin should arrive via class fallthrough

Interview Tips

  • • "How does Vue know a style is scoped?" — walk the compile step: attribute stamped on elements, selector rewritten with [data-v-hash]; then immediately name the child-boundary consequence to show you've used it, not memorized it
  • • "Why can't my parent's scoped style restyle the child?" — child elements carry the child's hash; only the child's root inherits the parent's id; the fix is :deep() or global CSS — and explain why that wall exists (style isolation)
  • • "What's the difference between the two script forms?" — classic exports options and needs explicit return; script setup compiles top-level bindings into setup internals and infers the name from the filename
  • • "What happens when you import a .vue file?" — plugin-vue returns an ES module for the component with render function, scope id, and HMR boundary — connect it to the runtime-only build having no template compiler

Cheat Sheet

Single-File Components

Skeleton:

<script setup lang="ts">
import Child from './Child.vue'          // template-used import = registration
const props = defineProps<{ n: number }>()
</script>

<template>
  <Child :n="props.n" />                 <!-- kebab-case tags -->
</template>

<style scoped>
.n { font-variant-numeric: tabular-nums; }
.card :deep(.third-party-x) { color: red; }
</style>

Blocks: <template> one per file → render function · <script setup> top-level bindings auto-exposed, no return · <style scoped> per-component CSS · multiple style/script blocks allowed · lang="ts"|"scss" opt into transpilers · src pulls in external files.

Scoping mechanics:

What Effect
scoped selectors become .x[data-v-hash]; template elements get data-v-hash
child internals carry the CHILD's hash → parent scoped rules don't match
child root node also carries parent's hash → parent scoped rules DO match it
:deep(.x) compiles to [parent-hash] .x — reach through the boundary
:global(.x) opt one selector out of scoping

Rules of thumb: PascalCase filenames and imports · kebab-case in templates · keep tokens/resets global, private geometry scoped · filename is the component's devtools name and HMR identity · heavy children via defineAsyncComponent(() => import(...)) for chunking.

Continue Learning