Architecture & system design

Component architecture and design systems

Layering components from tokens to pages, headless primitives plus styled wrappers, designing props and slots APIs that scale across a team, the accessibility contract, documentation, versioning and deprecation, distribution as a package or a Nuxt layer, theming for brands, governance, and build versus adopt.

"How would you structure a component library for the team" is the design question closest to daily work for a senior Vue engineer. The answer is architectural: layers of responsibility, a headless base, APIs designed for misuse resistance, and a product mindset about versions, documentation and adoption. The layers section covers how to ship one in Nuxt; this page covers how to design it.

Know

  • Layers of responsibility: tokens (colour, spacing, type, radius, motion as custom properties) → primitives (Button, Input, Checkbox, Link: one element, one responsibility, fully accessible) → composites (FormField, Card, Dialog, Combobox: primitives plus behaviour) → patterns (DataTable, Wizard, CommandPalette: opinionated assemblies) → feature components (ProductCard, CheckoutSummary: domain-aware) → pages. Lower layers know nothing about upper ones; feature components never leak into the library.
  • Headless plus styled: behaviour, keyboard handling and ARIA come from a headless library (Reka UI, which Nuxt UI 4 wraps), styling from tokens through Tailwind variants or scoped CSS. Building a combobox's keyboard model yourself is a month of edge cases; theming one is a day.
  • API design rules: variants over booleans (variant="primary" not primary, secondary), slots for anything the consumer might render differently, defineModel for two-way state with a local fallback, typed emits, class passthrough and a ui slots-override prop for internals, inheritAttrs handled deliberately, sensible defaults so the empty tag looks right, and misuse resistance: if a consumer can produce an inaccessible or inconsistent result without a type error, the API is not done.
  • The accessibility contract: every component documents its roles, keyboard interactions, focus behaviour and required labelling props (aria-label when no visible label), tested with role-based locators and axe; the library is where WCAG compliance is solved once.
  • Documentation as part of the component: live examples with editable props, do-and-don't guidance, accessibility notes, tokens used, changelog entries; Storybook or Histoire for isolated development and visual tests, or a Nuxt-Content-driven docs site that renders the real components (the Nuxt UI approach).
  • Versioning and change: semantic versioning with changesets, deprecation windows with runtime warnings (useLogger in dev), codemods for mechanical migrations, a support policy per major, and a canary or fixture app that consumes the library on every change. Breaking a hundred consumers silently is the failure mode; the senior craft section covers rollout.
  • Distribution: an npm package (build with @nuxt/module-builder or a plain Vite library build, exports map, types) when consumers are diverse; a Nuxt layer when consumers are Nuxt apps and you want to ship config, composables, app.config tokens and pages together; often both: a framework-agnostic core package and a Nuxt layer around it.
  • Theming and brands: semantic tokens map to brand values per tenant (--ui-primary), dark mode through color-scheme/class variants, density and radius as tokens; components never hard-code colours. Nuxt UI exposes this through app.config and --ui-* variables.
  • Governance: an owner or a small core team, a contribution model (proposal → RFC for new components, PR for fixes), acceptance criteria (a11y, tests, docs, tokens only), adoption metrics (which apps use which version), and a roadmap driven by consumer pain rather than novelty.
  • Build versus adopt: adopt a mature base (Nuxt UI with Reka) and build a thin brand layer on top; build from scratch only when the design language cannot be expressed by theming, and even then on headless primitives. The cost of a bespoke library is mostly maintenance and accessibility, not the initial build.

How it works

A composite that respects the layers: headless Dialog from Reka, tokens from Tailwind, a misuse-resistant API:

layers/ui/app/components/Team/Dialog.vue
<script setup lang="ts">
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
import { tv } from 'tailwind-variants'

const dialog = tv({
  slots: {
    overlay: 'fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out motion-reduce:animate-none',
    content: 'fixed inset-0 m-auto h-fit w-[min(90vw,var(--dialog-width))] rounded-(--ui-radius) bg-(--ui-bg) p-6 shadow-xl outline-none',
    title: 'text-lg font-semibold text-(--ui-text)',
  },
  variants: { size: { sm: { content: '[--dialog-width:24rem]' }, md: { content: '[--dialog-width:32rem]' }, lg: { content: '[--dialog-width:48rem]' } } },
  defaultVariants: { size: 'md' },
})

// The title is required: a dialog without an accessible name cannot be built with this component.
const { title, size, ui } = defineProps<{ title: string, description?: string, size?: 'sm' | 'md' | 'lg', ui?: Partial<Record<'overlay' | 'content' | 'title', string>> }>()
const open = defineModel<boolean>('open', { default: false })
defineSlots<{ trigger(): any, default(props: { close: () => void }): any, footer(props: { close: () => void }): any }>()
const classes = computed(() => dialog({ size }))
</script>

<template>
  <DialogRoot v-model:open="open">
    <DialogTrigger v-if="$slots.trigger" as-child><slot name="trigger" /></DialogTrigger>
    <DialogPortal>
      <DialogOverlay :class="[classes.overlay(), ui?.overlay]" />
      <DialogContent :class="[classes.content(), ui?.content]">
        <DialogTitle :class="[classes.title(), ui?.title]">{{ title }}</DialogTitle>
        <DialogDescription v-if="description" class="text-sm text-(--ui-text-muted)">{{ description }}</DialogDescription>
        <slot :close="() => (open = false)" />
        <footer v-if="$slots.footer" class="mt-6 flex justify-end gap-2">
          <slot name="footer" :close="() => (open = false)" />
        </footer>
        <DialogClose class="sr-only">Close</DialogClose>
      </DialogContent>
    </DialogPortal>
  </DialogRoot>
</template>

Tokens per brand, resolved once, consumed everywhere:

layers/ui/app/assets/css/tokens.css
@layer tokens {
  :root { --ui-radius: 0.5rem; --ui-bg: light-dark(oklch(99% 0 0), oklch(18% 0.02 260)); --ui-text: light-dark(oklch(20% 0.02 260), oklch(95% 0 0)); --ui-text-muted: color-mix(in oklch, var(--ui-text) 65%, transparent); --ui-primary: oklch(62% 0.19 260); }
  [data-brand="north"] { --ui-primary: oklch(55% 0.15 150); --ui-radius: 0.25rem; }
}

The deprecation path a library owes its consumers:

layers/ui/app/components/Team/Modal.vue (script)
// Deprecated in 2.3, removed in 3.0. Warn once per session in development, map old props to the new component.
if (import.meta.dev) console.warn('[team-ui] <TeamModal> is deprecated; use <TeamDialog> (codemod: npx @team/ui-codemods modal-to-dialog)')
CHANGELOG.md (excerpt)
## 2.3.0
- Added `<TeamDialog>` (Reka-based, focus management, sizes). Replaces `<TeamModal>`.
- Deprecated `<TeamModal>`: runtime warning in dev; removal planned for 3.0 (not before 2026-12). Codemod: `modal-to-dialog`.
Gotcha· Feature components in the design system

<ProductCard> in the shared library couples every app to one product's domain and makes the library change with the product. The library stops at patterns; domain components live in the app or a domain layer.

Gotcha· A component API with twelve booleans

compact, dense, bordered, flat, outlined, elevated… allow contradictory combinations and make every consumer read the source. Variants (density, appearance) with a small enum each are exhaustive, documented by the type, and cheap to test.

Verify before the interview:

Nuxt UI 4's current theming surface (app.config keys, ui prop, --ui-* variables) and Reka UI's component list; also the maintained Storybook or Histoire integration for Vue 3 and Nuxt.

docs ↗

Exercise

Exercise
  • Classify every component in one of your projects into the six layers; find the feature components that leaked into the shared library.
  • Rewrite a boolean-heavy component API as variants with tv(); count the combinations before and after.
  • Write the accessibility contract (roles, keyboard, focus, labelling) for a Combobox and compare it with Reka UI's implementation.

Be able to say

Be able to say· How would you structure a component library for a team of Vue developers?

"In layers with strict direction: design tokens as custom properties, accessible primitives built on a headless library like Reka UI so keyboard and ARIA behaviour is solved once, composites and patterns styled with Tailwind variants, and domain components kept out of the library in the apps or a domain layer. Component APIs use variants instead of booleans, slots for anything rendered differently, defineModel for two-way state, and a ui override prop, with the rule that a consumer cannot produce an inaccessible result without a type error. It ships as a package or a Nuxt layer with semantic versioning, changesets, dev-time deprecation warnings and codemods, a docs site rendering the real components, and a canary app in CI. And I would adopt Nuxt UI as the base and build a thin brand layer rather than start from scratch, because the cost of a bespoke library is accessibility and maintenance, not the first build."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.