"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.
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.aria-label when no visible label), tested with role-based locators and axe; the library is where WCAG compliance is solved once.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.@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.--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.A composite that respects the layers: headless Dialog from Reka, tokens from Tailwind, a misuse-resistant API:
<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:
@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:
// 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)')
## 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`.
<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.
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.
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.
tv(); count the combinations before and after."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."
Nothing yet. Notes filed here show up on this page and the section overview.
The frontend system design method
A repeatable structure for a 45–60 minute design round — clarifying requirements and non-functionals, scoping, drawing the architecture, defining data and API, placing state, going deep on one hard part, covering cross-cutting concerns and naming trade-offs — with time budgets, notation and the mistakes that lose points.
Application structure and boundaries
Organising a large Nuxt codebase — by type versus by feature and how Nuxt's scanning constrains that, enforced dependency direction with lint rules, the shared kernel, server services versus handlers, monorepos with pnpm workspaces and Turborepo, Nuxt layers as the unit of reuse, and when micro-frontends are justified.