15 KiB
Component Structure
File Location
Components live in src/runtime/components/ with PascalCase naming (e.g., Button.vue, InputMenu.vue).
Standard Component Template
<script lang="ts">
// 1. Type imports first (always separate)
import type { VNode } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import type { ComponentConfig } from '../types/tv'
// 2. Theme import
import theme from '#build/ui/component-name'
// 3. Type definition
type ComponentName = ComponentConfig<typeof theme, AppConfig, 'componentName'>
// 4. Props interface with JSDoc defaults
export interface ComponentNameProps {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
/**
* @defaultValue 'primary'
*/
color?: ComponentName['variants']['color']
/**
* @defaultValue 'md'
*/
size?: ComponentName['variants']['size']
class?: any
ui?: ComponentName['slots']
}
// 5. Slots interface - always pass ui for customization
// Return type is VNode[], slots are optional with `?`
export interface ComponentNameSlots {
default?(props: { ui: ComponentName['ui'] }): VNode[]
}
</script>
<script setup lang="ts">
// 6. Regular imports (separate from type imports)
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { useAppConfig } from '#imports'
import { useComponentProps } from '../composables/useComponentProps'
import { tv } from '../utils/tv'
// 7. Raw props (use withDefaults only when you actually need a runtime default)
const _props = defineProps<ComponentNameProps>()
const slots = defineSlots<ComponentNameSlots>()
// 8. Theme-aware proxy: resolves explicit > <UTheme :props> > withDefaults
// > app.config.ui.<name>.defaultVariants. The `ui` prop is deep-merged
// automatically, so reach for `props.ui?.<slot>` in the template.
// `theme.defaultVariants` is NOT in this chain — it only feeds `tv()`
// class resolution.
const props = useComponentProps('componentName', _props)
// 9. App config
const appConfig = useAppConfig() as ComponentName['AppConfig']
// 10. Computed UI - always computed for reactivity
const ui = computed(() => tv({
extend: theme,
...(appConfig.ui?.componentName || {})
})({
color: props.color,
size: props.size
}))
</script>
<template>
<!-- 11. data-slot on every element, always read props as `props.x` -->
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :ui="ui" />
</Primitive>
</template>
Reka UI Components
For components wrapping Reka UI primitives (example: Collapsible.vue):
<script lang="ts">
import type { CollapsibleRootProps, CollapsibleRootEmits } from 'reka-ui'
import type { VNode } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/collapsible'
import type { ComponentConfig } from '../types/tv'
type Collapsible = ComponentConfig<typeof theme, AppConfig, 'collapsible'>
export interface CollapsibleProps extends Pick<CollapsibleRootProps, 'defaultOpen' | 'open' | 'disabled' | 'unmountOnHide'> {
as?: any
class?: any
ui?: Collapsible['slots']
}
export interface CollapsibleEmits extends CollapsibleRootEmits {}
export interface CollapsibleSlots {
default?(props: { open: boolean }): VNode[]
content?(props?: {}): VNode[]
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useComponentProps } from '../composables/useComponentProps'
import { useForwardProps } from '../composables/useForwardProps'
import { tv } from '../utils/tv'
const _props = withDefaults(defineProps<CollapsibleProps>(), {
unmountOnHide: true
})
const emits = defineEmits<CollapsibleEmits>()
const slots = defineSlots<CollapsibleSlots>()
// Theme-aware proxy. `props` deep-merges `ui` and resolves <UTheme :props> defaults.
const props = useComponentProps('collapsible', _props)
const appConfig = useAppConfig() as Collapsible['AppConfig']
// Pick from `props` (the proxy) so theme-supplied values flow through.
// Use the local `useForwardProps` — reka-ui's `useForwardProps` /
// `useForwardPropsEmits` filter root props by `vm.vnode.props ∪ withDefaults`
// and would strip <UTheme :props> values.
const rootProps = useForwardProps(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled', 'unmountOnHide'), emits)
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.collapsible || {}) })())
</script>
<template>
<CollapsibleRoot v-slot="{ open }" v-bind="rootProps" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<CollapsibleTrigger v-if="!!slots.default" as-child>
<slot :open="open" />
</CollapsibleTrigger>
<CollapsibleContent data-slot="content" :class="ui.content({ class: props.ui?.content })">
<slot name="content" />
</CollapsibleContent>
</CollapsibleRoot>
</template>
Generic Components
For components with typed items (Accordion, Select, Table):
<script lang="ts">
export interface AccordionItem {
label?: string
icon?: string
content?: string
value?: string
disabled?: boolean
[key: string]: any
}
export interface AccordionProps<T extends AccordionItem = AccordionItem> {
items?: T[]
// ...
}
</script>
<script setup lang="ts" generic="T extends AccordionItem">
const props = withDefaults(defineProps<AccordionProps<T>>(), {
type: 'single',
collapsible: true
})
</script>
Form Components
For inputs that integrate with UForm:
<script setup lang="ts">
import { useFormField } from '../composables/useFormField'
import { useFieldGroup } from '../composables/useFieldGroup'
defineOptions({ inheritAttrs: false })
// Pass raw `_props` (not the proxy) so the wrapping `<UFormField>` /
// `<UFieldGroup>` keep precedence over `<UTheme :props>` / `withDefaults` /
// `app.config` defaults. Their internal fallback is `props?.x ?? injected.x`,
// so handing them the proxy would leak theme defaults into "explicit prop"
// and silently override the wrapper.
const {
id, name, size: formFieldSize, color, highlight, disabled,
ariaAttrs, emitFormBlur, emitFormInput, emitFormChange
} = useFormField<InputProps>(_props, { deferInputValidation: true })
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_props)
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
// In `tv()` calls, fall back to `props.X` (the proxy) so `<UTheme :props>`
// applies when there is no wrapping FormField/FieldGroup. Without `?? props.X`,
// theme size/color/highlight is silently dropped on bare inputs.
//
// Final precedence: explicit > closer-context (form/group) > <UTheme :props>
// > withDefaults > app.config > tv defaults
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.input || {}) })({
color: color.value ?? props.color,
size: inputSize.value ?? props.size,
highlight: highlight.value ?? props.highlight,
variant: props.variant
}))
</script>
<template>
<input
:id="id"
:name="name"
:disabled="disabled"
v-bind="{ ...$attrs, ...ariaAttrs }"
@blur="emitFormBlur"
@input="emitFormInput"
@change="emitFormChange"
>
</template>
The same ?? props.X pattern applies to useAvatarGroup (size) and any other context composable whose contract is props?.x ?? injected.x. The composable itself stays untouched — the fallback lives at the tv() call site so the wrapper-vs-theme precedence is explicit and reviewable.
data-slot on the root
Parents label a child by passing data-slot (e.g. <UIcon data-slot="leadingIcon" />, <UAvatar data-slot="leadingAvatar" />). The rule is: a caller-supplied data-slot always wins on the component's root element, with the component's own value (root/base) as the fallback. Inner elements keep their own data-slot.
How you achieve it depends on how the root receives attributes:
-
Single root, default
inheritAttrs(Badge, Card, …): nothing to do. Vue's attribute fallthrough already lets the caller'sdata-slotoverride the static one on the root. This only holds when the template root renders an element:Button's root is a renderlessULink customthat hands$attrsback as slot props, so itsULinkBaseneeds the default placed before the spread (<ULinkBase data-slot="base" v-bind="slotProps">), same as the$attrscase below. -
inheritAttrs: false,$attrsspread on the root: fallthrough is off, so a staticdata-slot="root"placed afterv-bindwould win over the caller. Put the attribute before thev-bindinstead, so a caller value in$attrsoverrides it:<Primitive :as="props.as" data-slot="root" v-bind="$attrs" :class="ui.root({ class: [props.ui?.root, props.class] })" /> <Separator data-slot="root" v-bind="{ ...rootProps, ...$attrs }" :class="ui.root({ class: [props.ui?.root, props.class] })" />Keep
:id,refandv-slotbeforedata-slot:vue/attributes-orderranks them first, and its autofix movesdata-slotpast thev-bind(reverting the override) instead of moving them up.test/components/DataSlot.spec.tscatches this, but better not to trip it. -
inheritAttrs: false,$attrsforwarded to an inner element (Avatar, Input, Checkbox, Switch, …): the root never receives$attrs, so read the caller's value on the root explicitly, and keep each inner element's owndata-slotafter its$attrsspread so the caller's value does not leak onto it:<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })"> <input v-bind="{ ...$attrs, ...ariaAttrs }" data-slot="base" :class="ui.base({ class: props.ui?.base })"> </Primitive>For
<Slot>forwards and inner elements that have nodata-slotof their own, strip it from what you forward so it cannot leak:v-bind="{ ...$attrs, 'data-slot': undefined }". The same applies when attributes are forwarded from the script, likeEditorspreadinguseAttrs()into tiptap'seditorProps.attributes: useomit(attrs, ['data-slot']).
The rule is enforced by test/components/DataSlot.spec.ts, which mounts every component with a caller data-slot and asserts it lands exactly once, on the outermost rendered element.
Components with Icons
<script setup lang="ts">
import { useComponentIcons } from '../composables/useComponentIcons'
import UIcon from './Icon.vue'
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
</script>
<template>
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" data-slot="leadingIcon" :class="ui.leadingIcon()" />
</template>
Exposing Refs
<script setup lang="ts">
const inputRef = useTemplateRef('inputRef')
defineExpose({
inputRef
})
</script>
Theme Defaults
useComponentProps is the primary integration with <UTheme>. The proxy resolves the priority chain explicit prop > nearest <UTheme :props> > withDefaults > app.config.ui.<name>.defaultVariants for every prop — including ones driving template logic that tv().defaultVariants can't reach (<component :is>, v-if, computed conditionals). theme.defaultVariants is intentionally NOT in the proxy chain — it only feeds tv() class resolution. If a prop value is consumed in template logic, it must come from one of the proxy-resolved sources (typically withDefaults):
<template>
<component :is="props.variant === 'list' ? 'div' : Label" />
</template>
Notes:
- The proxy passes through to
_propsfor explicitly set props, sowithDefaultsfallbacks stay lower priority than<UTheme>overrides. - The
uiprop is deep-merged (slot classes layered on top of theme overrides). All other props are explicit-wins. - Always read props as
props.xin templates and<script setup>. Bare prop names ({{ label }},v-if="arrow") resolve to_propsand bypass the proxy, so<UTheme :props>defaults won't apply. Thenuxt-ui/no-bare-prop-refsESLint rule autofixes this. - Pass the raw
_props(not the proxy) to context composables —useFormField,useFieldGroup,useAvatarGroup. Their internal fallback isprops?.x ?? injected.x, so the wrapping<UFormField>/<UFieldGroup>/<UAvatarGroup>should beat<UTheme :props>/withDefaults/app.configdefaults (closer context wins). Then always fall back to the proxy intv()calls —size: formSize.value ?? props.size,color: color.value ?? props.color,highlight: highlight.value ?? props.highlight. Without?? props.X,<UTheme :props>is silently dropped when no closer context wraps the component. Final chain:explicit > closer-context > UTheme > withDefaults > app.config > tv defaults.useComponentIconshas no injection chain, so pass the proxypropsdirectly. - Reka primitives'
useForwardProps/useForwardPropsEmitsfilter root props byvm.vnode.props ∪ withDefaultsand would strip theme-supplied values. ImportuseForwardPropsfromcomposables/useForwardProps.tsinstead — same(source, emits?)signature, proxy-aware.
Key Patterns
| Pattern | Usage |
|---|---|
useComponentProps(name, _props) |
Theme-aware proxy — default for new components |
useForwardProps(source, emits?) (local) |
Forward Reka UI props/emits without filtering theme defaults |
withDefaults |
Runtime default values |
defineOptions({ inheritAttrs: false }) |
When spreading $attrs to inner element |
Caller data-slot wins on root |
Place the default data-slot before the root v-bind, or read $attrs['data-slot'] on the root — see data-slot on the root |
reactivePick |
Pick keys off props (the proxy) before forwarding |
createReusableTemplate |
Complex template reuse (Table, Modal) |
useTemplateRef |
Template refs (Vue 3.5+) |
toRef(() => props.x) |
Reactive prop access |
Export Types
Add to src/runtime/types/index.ts:
export * from '../components/ComponentName.vue'
Register in ThemeDefaults
The ThemeDefaults interface in src/runtime/composables/useComponentProps.ts powers autocomplete inside <UTheme :props="{ componentName: { … } }">. The CLI scaffolder (nuxt-ui make component) auto-inserts the entry; only do this manually if you skipped the CLI:
export interface ThemeDefaults {
// ... existing entries
componentName?: Partial<ComponentTypes.ComponentNameProps>
}
The key is the component name in camelCase (matches the #build/ui registry). The value is Partial<XProps>. This is a flat literal interface (not a mapped type) because Volar only surfaces inner-prop autocomplete for interface members, not mapped-type members, in template inline objects.