Customizable Vue components should give product teams room to adapt the interface without letting every screen fork the component. The most reliable pattern is a small, stable core plus deliberate extension points: typed props for finite options, slots for markup, events and v-model for behavior, CSS tokens for visual variation, and native attributes for last-mile integration.
In this guide, we will build that contract around a reusable button, then apply the same approach to inputs, cards, dialogs, and composite application UI. By the end, we will have a component that a consuming screen can configure, compose, style, and verify without reaching into its internal DOM.
Before we start, prepare a Vue 3 Single-File Component project. TypeScript is recommended, but not required. We will use Vue’s <script setup> syntax, a browser preview, and a test runner or interactive playground.
Table of contents
-
4. Use slots when consumers need structure, not another boolean prop
-
6. Forward native attributes deliberately and preserve accessibility
1. Define the customization contract before writing styles
Start with the component’s public API, not its template. Every option you expose becomes an agreement with the teams using it, so expose choices that are meaningful and finite.
For an AppButton, our contract can be:
-
Props:
tone,size,loading, anddisabled. -
Slots: default label content,
icon-start, andicon-end. -
Events:
clickwhen the button is available for interaction. -
Native attributes:
id,aria-describedby,data-*,class, and other attributes that belong on the actual button.
This separates configuration from composition. A consumer chooses a semantic tone with a prop, supplies rich content through slots, and can still add an accessible label or a screen-specific class through normal attributes.
Expected result: You can describe every supported variation in a short list. If a proposed prop only helps one page, keep it at the page level or use a slot instead.
Verification check: Write three intended usages before implementation: a primary save action, a destructive delete action, and a loading action. If the same API handles all three cleanly, the contract is on the right track.
Troubleshooting: Avoid an options object that accepts arbitrary keys on day one. It makes the API difficult to document, type, test, and retire. Prefer explicit props until a real pattern proves the abstraction is necessary.

2. Build a typed base component with safe defaults
Vue components should explicitly declare their props. That makes the public contract visible and keeps undeclared values available as fallthrough attributes instead of silently treating them as component configuration.
Create AppButton.vue:
<script setup lang="ts">
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
type Tone = 'primary' | 'secondary' | 'danger'
type Size = 'sm' | 'md' | 'lg'
const props = withDefaults(
defineProps<{
tone?: Tone
size?: Size
loading?: boolean
disabled?: boolean
}>(),
{
tone: 'primary',
size: 'md',
loading: false,
disabled: false,
}
)
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const buttonClass = computed(() => [
'app-button',
`app-button--${props.tone}`,
`app-button--${props.size}`,
])
function handleClick(event: MouseEvent) {
if (props.disabled || props.loading) {
event.preventDefault()
return
}
emit('click', event)
}
</script>
<template>
<button
v-bind="$attrs"
:class="buttonClass"
:disabled="disabled || loading"
:aria-busy="loading || undefined"
@click="handleClick"
>
<span v-if="$slots['icon-start']" class="app-button__icon">
<slot name="icon-start" />
</span>
<span class="app-button__label">
<slot>Continue</slot>
</span>
<span v-if="$slots['icon-end']" class="app-button__icon">
<slot name="icon-end" />
</span>
</button>
</template>
We disable automatic attribute inheritance because this component owns the decision about where attributes land. Binding $attrs to the native <button> keeps id, accessibility attributes, and consumer classes on the interactive element instead of an accidental wrapper.
Expected result: This component renders a native button with a predictable default appearance and a focused API.
Verification check: Render <AppButton id="save-profile" aria-describedby="save-help">Save</AppButton> and inspect the browser DOM. Both attributes should be on the button element.
Troubleshooting: If an attribute seems to disappear after adding a wrapper around the button, confirm that v-bind="$attrs" is still attached to the intended interactive element.
3. Make visual variations token-driven, not page-driven
A component is customizable when its design choices are intentional, not when every consumer can overwrite its internal selectors. Use semantic props to select token values, then allow class and style as limited escape hatches.
.app-button {
--button-bg: var(--color-accent);
--button-fg: white;
--button-padding-x: 1rem;
--button-padding-y: 0.625rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 0;
border-radius: var(--radius-control);
background: var(--button-bg);
color: var(--button-fg);
padding: var(--button-padding-y) var(--button-padding-x);
}
.app-button--secondary {
--button-bg: var(--color-surface-raised);
--button-fg: var(--color-text);
}
.app-button--danger {
--button-bg: var(--color-danger);
}
.app-button--sm {
--button-padding-x: 0.75rem;
--button-padding-y: 0.375rem;
}
.app-button--lg {
--button-padding-x: 1.25rem;
--button-padding-y: 0.75rem;
}
Keep the prop names semantic. tone="danger" describes intent better than background="red", and a design token lets a theme change the final color without changing every component call site.

Expected result: A theme can change component appearance by updating tokens, while consumers select supported semantic variants.
Verification check: Switch tone between primary, secondary, and danger. Then set a different --color-accent at the application theme boundary. The primary button should update without any component API changes.
Troubleshooting: Do not add a new prop for a one-off spacing adjustment. Let the layout own spacing with a wrapper or consumer class. Component props should control the component itself.
4. Use slots when consumers need structure, not another boolean prop
Props are best for data and constrained choices. Slots are best when the caller needs to provide markup. Named slots make the available insertion points clear and let the component preserve its layout, focus behavior, and styles.
<AppButton tone="primary" @click="saveProfile">
<template #icon-start>
<SaveIcon aria-hidden="true" />
</template>
Save profile
<template #icon-end>
<kbd>⌘S</kbd>
</template>
</AppButton>
Use a slot for an icon, badge, shortcut, or custom label markup. Do not create hasIcon, iconPosition, showShortcut, and labelHtml props when consumers need different structures. Those props quickly become a second templating language.
Expected result: A consumer can change the content inside the button without copying the button’s behavior or styling.
Verification check: Render a button with no named slots, one with only icon-start, and one with both named slots. Confirm the spacing and focus treatment remain consistent.
Troubleshooting: If a slot needs data from the child, use a scoped slot and pass only the state the caller truly needs. Keep behavior such as keyboard handling inside the component whenever possible.
For a concise walkthrough of modern Vue component patterns, watch the embedded video below.
5. Use v-model for controlled component state
When a component owns an interactive value, use Vue’s component v-model contract rather than inventing a custom pairing such as :checked plus @changed. In current Vue, defineModel() provides a compact way to declare that two-way interface.
Here is a small toggle component:
<script setup lang="ts">
const checked = defineModel<boolean>({ required: true })
defineProps<{
label: string
disabled?: boolean
}>()
</script>
<template>
<label class="app-toggle">
<input
v-model="checked"
type="checkbox"
:disabled="disabled"
>
<span>{{ label }}</span>
</label>
</template>
A parent can now use it naturally:
<AppToggle v-model="notificationsEnabled" label="Email notifications" />
The same pattern works for a dialog’s open state, a combobox selection, a date range, or an expandable navigation group. For components with multiple controlled values, use named models such as v-model:open or v-model:query.
Expected result: Parent state remains the source of truth, and the child updates that state through a standard Vue interface.
Verification check: Change notificationsEnabled from a parent control and confirm the toggle updates. Then click the toggle and confirm the parent state changes.
Troubleshooting: Do not give a model prop a child-only default unless you have considered the initial parent value. An unbound parent value and a child default can start out of sync.
When you need a larger starting point, DOM Studio includes form controls alongside its application primitives, so the same API discipline can extend from a button to schema-aware forms.
6. Forward native attributes deliberately and preserve accessibility
A customizable component must be adaptable without losing its semantic HTML. Fallthrough attributes are especially useful for class, style, id, aria-*, data-*, and native listeners. With a single root element, Vue can apply these automatically. In a wrapper component, take control using inheritAttrs: false and bind $attrs where the attributes should live, as we did in AppButton.
Use this decision guide:
-
Add a prop when the value changes supported component behavior or a semantic visual variant.
-
Add a slot when the caller needs to supply markup within a controlled region.
-
Forward an attribute when it belongs to the native element or is specific to the consuming screen.
-
Add an event when the component reports an interaction or state transition.
This is also where we protect the component’s accessibility contract. A loading button should not emit its action. A disabled control should use the appropriate native behavior. An icon-only control needs an accessible name from the consumer. Customization should add context, not make keyboard and screen-reader support optional.
Expected result: Consumers can add context and styling without targeting hidden internal selectors.
Verification check: Test the component with a keyboard, inspect the accessible name, and confirm aria-busy, disabled, and any consumer-provided aria-describedby values are present where expected.
Troubleshooting: Multi-root components do not have automatic attribute fallthrough. Bind $attrs explicitly to the root node that should receive the attributes, or Vue will warn because it cannot choose for you.
7. Document and inspect every supported variation
A configurable component is only reusable if developers can discover its options and see them work. Write a short usage recipe for every prop, slot, event, and model. Pair it with a live preview that exercises default, edge, disabled, loading, long-label, and keyboard states.
At DOM Studio, the live Playground lets developers select a component, edit properties, and see the stage update immediately. It can also infer an editing schema from component props, which is useful when turning a component library into a system other teams can inspect rather than merely import.

For custom local components, keep documentation close to source. DOM Studio’s component specification guide describes metadata for documentation, navigation, slots, events, and Studio controls, plus prop-level editor hints for richer inspector inputs. That gives the API a shared representation for docs, visual editing, and future automation.
Use this release checklist before you ship:
-
Every prop has a type, default, and a documented reason to exist.
-
Every slot has a defined region and an example.
-
Every event has a stable payload and test.
-
Every
v-modelworks in both directions. -
Keyboard, focus, disabled, loading, and accessible-name states are tested.
-
The component has a live example with its most important combinations.
Expected result: A teammate can discover and safely customize the component without reading its implementation.
Troubleshooting: If documentation becomes lengthy because a component has too many conditional props, split it into a stable base primitive and a higher-level composite. A component with a clear boundary is easier to customize than a universal component with dozens of modes.
Your completed outcome: a component API that scales
We now have a repeatable approach for customizable Vue components: define the contract, implement typed props and safe defaults, use slots for composition, use v-model for controlled state, expose token-based styling, forward attributes intentionally, and verify the result in an interactive example.
This approach works whether we build our own design system or extend a library such as Vuetify or PrimeVue. The goal is not unlimited flexibility. The goal is a small set of predictable extension points that let teams move quickly while the component still protects behavior, accessibility, and visual consistency.
If we want an editable Vue and Web Component foundation with inspectable primitives, wrappers, application blocks, form tools, and source-aware documentation, explore the DOM Studio component library. Start with one component that appears in several product surfaces, document its supported variations, and use that contract as the standard for the rest of the system.
