About vue-component-anatomy
A library-neutral skill for developers structuring Vue 3.5+ Single File Components. It specifies conventions for a single `.vue` file, covering block order, macros, script, template, and styles.
Key features
- 01SFC block and macro ordering
- 02Script declarations and function layout
- 03Typed props, emits, and models
- 04Template and styling conventions
- 05Whitespace and formatting rules
Use cases
- 01Create a Vue Single File Component
- 02Reorganize component scripts and macros
- 03Choose patterns for props, events, and two-way binding
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ basmilius/ skills --skill vue-component-anatomyAfter installing, type/vue-component-anatomyin Claude Code to confirm it loaded.
Details
From the project READMEVue component anatomy
How one Vue 3 Single File Component is built on the inside. This is about the
shape of a single .vue file: the order of its blocks, its macros, its script,
and its styles. It is deliberately library-neutral. For how a feature is split
across several components use vue-build-feature; for choosing UI components (if
the project uses Flux) use flux-ui; project-specific rules live in the repo's
CLAUDE.md / AGENTS.md.
Assume <script setup lang="ts"> and Vue 3.5+ unless the repo says otherwise.
1. SFC skeleton and template root
- Block order:
<template>first, then<script setup lang="ts">, then<style>, then an optional<i18n>block last. Keep it consistent across the codebase. <i18n>blocks (projects with vue-i18n SFC messages) always come after<style>. Match the format of existing components: check whether the codebase uses<i18n lang="yaml">or JSON before writing one, and use the samelangattribute everywhere.- Prefer a single root element in the template: it gives predictable
attribute fallthrough (
class,@click,v-bind="$attrs"land on one node) and is required by many<Transition>setups. Vue 3 allows fragments (multiple roots), but reach for them deliberately, not by accident: with more than one root,$attrsis not auto-inherited and you must bind it explicitly. - Render teleported/overlay children (dialogs, tooltips) inside the root, not as a sibling of it, so transitions and fallthrough stay intact.
2. Macro order (top of <script setup>)
Declare the compiler macros first, in this order, so the component's contract is readable at a glance:
defineOptions (rare) -> defineEmits -> defineModel -> defineProps ->
defineSlots.
defineExpose is the exception: it goes at the very bottom of the script (after
the function declarations, see section 3), and only if a parent needs a ref
handle. It describes the outward surface, so it reads last once everything it
exposes is defined.
- Type each one; do not pass runtime option objects when a type argument says it better.
3. <script setup> ordering
Order the body top to bottom so state is declared before it is used:
- imports
- macros (emits / models / props / slots) - see section 2
- module constants (
ALL_CAPS: config values, option lists, lookup maps) - stores (
use*Store()- shared state) - reactive state (
ref,reactive,shallowRef, and template refs) - composables (
useRouter,useI18n, otheruse*behavior helpers) - computed
- watch / watchEffect
- lifecycle hooks (
onMounted,onUnmounted, ...) - function declarations (event handlers and helpers)
defineExposelast of all, only if a parent needs a ref handle
- Module constants go below the macros, not above them. The macros are the
component's public contract and read first; constants are implementation
detail. A constant that needs
t()or other reactive input is not a constant: make it acomputedin step 7. - Stores sit above the reactive state, composables below it. A store is
shared state and reads together with the component's own state; a composable
provides behavior or derived data and may build on that state. Rule of thumb:
a
use*Store()from the store layer is a store, every otheruse*call is a composable. - Template refs use
useTemplateRef('name')(Vue 3.5+) and sit with the reactive state; the string argument matches theref="name"in the template. - Import style (alias vs relative) follows
vue-build-feature: the path alias (@/...) across layers, relative paths only for same-feature siblings. - Keep side effects (kick-off fetches, title setters) in the lifecycle step, not scattered between declarations.
4. function vs arrow
- Named handlers and helpers are
functiondeclarations (function onSubmit(): void { ... }): they hoist, read cleanly in the template, and stack-trace with a name. - Arrow functions only for inline callbacks and closures (watchers,
array.map,.filter, event options). - Lifecycle bodies stay inline:
onMounted(async () => { ... })keeps the side effect where it runs. Only lift it into a named function when a second caller needs it (a watcher, a retry button, the template) or when the body grows past a handful of statements - not just to give it a name.
5. Props and their types
- Type props with the generic form:
defineProps<{ readonly id: number }>(). Mark fieldsreadonly- props are immutable. - Defaults: use reactive props destructuring with defaults:
const { size = 'md' } = defineProps<{ size?: Size }>(). The destructured bindings stay reactive; only destructure the props that need a default. typeoverinterfacefor prop and data shapes. Useinterfaceonly where declaration merging is required (module augmentation,declare global).- Inline the props type in
defineProps<{ ... }>()when it is used only there. Promote it to a named, exportedtype Propsonly when it is reused elsewhere (for example passed to another generic). - Do not destructure props that are only read in the template in
<script>; destructure in script only when you need a default or a local binding.
6. Emits and models
- Type emits:
const emit = defineEmits<{ submit: [id: number]; cancel: [] }>(). - Two-way binding is
defineModel, not a hand-rolledmodelValueprop plus anupdate:modelValueemit:const value = defineModel<string>(). Named models:defineModel<number>('page')pairs withv-model:pageon the parent. - Model defaults use a runtime option object (the exception to section 2):
defineModel<string>({ default: '' }). Give an array or object model a factory default so its value is neverundefined:defineModel<string[]>({ default: () => [] }). - Keep the child dumb: emit an event or write the model; let the parent own the decision.
7. Template conventions
v-foralways has a stable:key(an id, not the array index when the list reorders).- No heavy logic in the template. Move anything past a simple expression into
a
computedor a named function. - Bind named models with
v-model:x; stop propagation on nested interactive elements (@click.stop) when the parent also handles the event.
8. Styling
- Default to
<style module>($style.someClass) with camelCase class names. Name classes after the component: a base class matching the component ($style.tagInput), child parts asbase + suffix($style.tagInputChip,$style.tagInputField), and state as a separateis*modifier on the same element (.tagInput.isDisabled). This prefix already isolates the classes, so scoping is largely redundant; use<style scoped>only if the project does. - Toggle modifiers with an array/object binding:
:class="[$style.tagInput, { [$style.isDisabled]: disabled }]". - Custom / global / deep-selector CSS is a last resort. Keep it minimal and leave a one-line comment explaining why the module route did not work.
- Overriding a component library's internals (when props, slots and design
tokens truly cannot reach it) is done with
:local(.pane)nested under your own base class. Functionally it is just a local class reference; writing:local()explicitly marks the class as foreign, deliberately reached into. This only resolves to the library's class when app and library share the same name-based CSS-modules mangling (for example both built with@basmilius/vite-preset); the one-line-comment rule above applies. - Theme through CSS custom properties (design tokens), not by overriding
internal selectors of other components. When the project has no token set, a
standalone component can expose its own
var(--x, fallback)properties so consumers can override them.
9. Whitespace
- One blank line between the top-level groups in
<script setup>(imports, each macro block, constants, stores, state, composables, computed, watch, lifecycle) and between everyfunctiondeclaration. - No blank line between adjacent single-line declarations in the same group (a run of refs or composables stays together).
- Inside a function, one blank line separates logical steps: after a guard/early-return block, and between setup and the action that follows.
- In the template, one blank line between logically separate element groups.
- One blank line between the
<template>,<script>,<style>, and<i18n>blocks.
Related skills
vue-build-feature- splitting a feature across a view plus multiple components, composables, data access and a route.flux-ui- picking and composing components when the project uses Flux.- The repo's
CLAUDE.md/AGENTS.md- project-specific style, i18n and tooling rules that override the neutral defaults here.