Skip to content

Build Guide — Vue Component Packages

How to create a new @vibe-labs/design-vue-{name} package. Vue 3 components that consume CSS from design-components-* and add behaviour, accessibility, slots, events, and composables. These packages contain zero CSS.


Directory Structure

vibe-design-vue-{name}/
├── package.json
├── readme.md
├── tsconfig.json
├── vite.config.ts
└── src/
    ├── index.ts                # barrel — exports components, types, composables
    ├── types.ts                # Vue-level props (extends component-level style props)
    ├── components/
    │   ├── Vibe{Name}.vue      # primary component
    │   └── Vibe{Sub}.vue       # sub-components
    └── composables/            # optional — headless logic
        └── use{Feature}.ts

package.json

json
{
  "name": "@vibe-labs/design-vue-{name}",
  "version": "0.9.0",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "type": "module",
  "exports": {
    ".": "./dist/index.js"
  },
  "scripts": {
    "build": "vite build"
  },
  "peerDependencies": {
    "vue": "^3.5.18"
  },
  "dependencies": {
    "@vibe-labs/core": "*",
    "@vibe-labs/design-components-{name}": "*"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.1",
    "typescript": "^5.9.2",
    "vite": "^7.1.2",
    "vite-plugin-dts": "^4.5.4",
    "vue": "^3.5.18"
  }
}

Key differences from component-level packages:

  • Single JS export (no separate CSS export)
  • Vite build (not rimraf + ncp + tsc + tsx)
  • Vue as peerDependency — never bundled
  • All @vibe-labs/* externalized via rollup

vite.config.ts

ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import dts from "vite-plugin-dts";
import path from "path";

export default defineConfig({
  build: {
    sourcemap: true,
    lib: {
      entry: path.resolve(__dirname, "src/index.ts"),
      name: "VibeDesignVue{Name}",
      formats: ["es"],
      fileName: "index",
    },
    rollupOptions: {
      external: ["vue", /^@vibe\//],
      output: { globals: { vue: "Vue" } },
    },
  },
  plugins: [vue(), dts({ insertTypesEntry: true, copyDtsFiles: true })],
});

Critical: external: ["vue", /^@vibe\//] — Vue and all sibling packages are never bundled.


tsconfig.json

json
{
  "compilerOptions": {
    "outDir": "dist",
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Node",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "paths": {
      "@vibe-labs/core": ["../../../vibe-core/src"],
      "@vibe-labs/design-components-{name}/types": ["../../components/vibe-design-components-{name}/types/index"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.vue"],
  "exclude": ["dist", "vite.config.ts"]
}

lib must include DOM and DOM.Iterable. paths are for IDE resolution during development only.


Type Bridge (src/types.ts)

The type layering pattern:

design-components-{name}/types     →  style props (CSS-level, framework-agnostic)
  ↓ extends
design-vue-{name}/types            →  Vue props (adds behaviour, events, slots)
  ↓ used by
Vibe{Name}.vue                   →  defineProps<Vibe{Name}Props>()
ts
// Re-export everything from the component-level types
export type { BadgeVariant, BadgeSize, BadgeStyleProps } from "@vibe-labs/design-components-badges/types";
export { BadgeVariants, BadgeSizes } from "@vibe-labs/design-components-badges/types";

// Vue-level props — extend the CSS-level props with behaviour
export interface VibeBadgeProps extends Omit<BadgeStyleProps, "dot"> {
  label?: string;
  bgColor?: string;
  fgColor?: string;
  autoColor?: boolean;
  dismissible?: boolean;
}

Vue props extend component-level style props, adding: behavioural props (dismissible, autoColor, loading), override props (bgColor, fgColor, color), content props (label, message, title), and Omit<> for CSS-only concerns.


Barrel (src/index.ts)

ts
/* ── Components ── */
export { default as VibeBadge } from "./components/VibeBadge.vue";
export { default as VibeBadgeCount } from "./components/VibeBadgeCount.vue";

/* ── Types ── */
export type { VibeBadgeProps, VibeBadgeCountProps } from "./types";
export type { BadgeVariant, BadgeSize, BadgeStyleProps } from "./types";
export { BadgeVariants, BadgeSizes } from "./types";

/* ── Composables ── */
// export { useXxx } from "./composables/useXxx";

SFC Conventions

vue
<script setup lang="ts">
import { computed } from "vue";
import type { VibeBadgeProps } from "../types";

const props = withDefaults(defineProps<VibeBadgeProps>(), {
  variant: "accent-subtle",
  size: "md",
  pill: true,
  interactive: false,
  dismissible: false,
});

const emit = defineEmits<{
  dismiss: [];
}>();

defineOptions({ inheritAttrs: false });
</script>

<template>
  <span
    class="badge"
    :data-variant="variant"
    :data-size="size"
    :data-pill="pill || undefined"
    :data-interactive="interactive || undefined"
    :data-removable="dismissible || undefined"
    :role="interactive ? 'button' : undefined"
    :tabindex="interactive ? 0 : undefined"
    v-bind="$attrs"
  >
    <slot name="left" />
    <span class="badge-label"><slot>{{ label }}</slot></span>
    <slot name="right" />
  </span>
</template>

SFC Rules

  1. Root element class = component-level base class (badge, btn, card). All visual styling comes from design-components-*.

  2. No <style> blocks — ever. Only inline styles for dynamic overrides (e.g. colorOverride).

  3. Data attributes for variants/flags:

    vue
    <!-- Enum variant — always bound -->
    :data-variant="variant"  :data-size="size"
    
    <!-- Boolean flag — undefined removes the attribute entirely -->
    :data-pill="pill || undefined"  :data-loading="loading || undefined"
    
    <!-- ARIA state — prefer native/ARIA over custom data attrs -->
    :aria-selected="selected"  :aria-expanded="expanded"  :disabled="disabled || undefined"

    Use || undefined for boolean flags — data-dot="false" would still match [data-dot] selectors.

  4. defineOptions({ inheritAttrs: false }) + v-bind="$attrs" — explicit control over where attrs land.

  5. withDefaults for every optional prop. Defaults must match the documented component defaults.

  6. Events via defineEmits with typed tuple signatures.


Custom Color Override Pattern

For components supporting custom colours, locally override --color-accent:

ts
const customStyle = computed(() => {
  if (!props.color) return undefined;
  return {
    "--color-accent": props.color,
    "--color-accent-hover": `color-mix(in srgb, ${props.color} 85%, black)`,
    "--color-accent-active": `color-mix(in srgb, ${props.color} 70%, black)`,
  } as Record<string, string>;
});

This cascades through all accent-derived variant tokens without per-variant overrides.


Composable Conventions

ts
// Return reactive refs and computed properties, not raw values
// Accept MaybeRefOrGetter<T> for flexibility
// Clean up side effects in onUnmounted / onScopeDispose
// Always prefix with "use"

Common patterns: state management (useModal, useToast), DOM interaction (useClickOutside, useFocusTrap), data processing (useTableSort, useUploadQueue), input helpers (useInputField, useAutoResize), responsive (useContainerBreakpoint).


Provide/Inject for Compound Components

ts
// Parent provides context
const context = {
  activeTab: ref("first"),
  registerTab: (name: string) => { ... },
  selectTab: (name: string) => { ... },
};
provide(TABS_INJECTION_KEY, context);

// Child injects — fail gracefully if used outside parent
const ctx = inject(TABS_INJECTION_KEY);

Responsive Vue Components

The @vibe-labs/design-vue-responsive package wraps the container-query CSS components. One composable provides JS-level breakpoint observation:

vue
<VibeResponsiveContainer type="inline" name="main">
  <slot />
</VibeResponsiveContainer>

<VibeResponsiveGrid :cols="1" :cols-md="2" :cols-lg="3">
  <slot />
</VibeResponsiveGrid>

<VibeResponsiveStack breakpoint="md">
  <slot />
</VibeResponsiveStack>

useContainerBreakpoint(target) uses ResizeObserver to track the inline size of a container. Returns { current, above, below, width } — for conditional rendering that CSS container queries can't handle.


Accessibility Baseline

  • Semantic HTML (<button>, <input>, <nav>) not generic <div> with roles
  • ARIA attributes (aria-label, aria-selected, aria-expanded, aria-disabled, role)
  • Keyboard navigation for all interactive components
  • Focus management (modals trap focus, dropdowns restore focus)
  • Screen reader announcements (role="status" for live updates)
  • Use data-* for visual-only modifiers, ARIA for state that assistive technology needs

Cross-Package Dependencies

TypeWhere declaredHow it works
Token referencesreadme onlye.g. surfaces uses --color-neutral-* from colors
Component-level CSSpackage.json deps in umbrella@vibe-labs/design-components handles import order
Vue ↔ component typespackage.json deps@vibe-labs/design-components-{name}
Vue ↔ core utilspackage.json deps@vibe-labs/core
Vue ↔ Vuerollup externalsnever bundled, resolved at runtime

Checklist

  1. Create vibe-design-vue-{name}/ directory
  2. Add package.json (copy template, update name + dependencies)
  3. Add tsconfig.json (copy template, update paths)
  4. Add vite.config.ts (copy template, update entry/name/fileName)
  5. Create src/types.ts — re-export component-level types + define Vue-level props
  6. Create src/components/Vibe{Name}.vue — SFC with <script setup>, typed props, no <style>
  7. Create src/index.ts barrel
  8. Add composables in src/composables/ if needed
  9. Write readme.md, developer.md, usage.md, contents.md
  10. Run npm run build and verify dist
  11. Add to umbrella @vibe-labs/design-vue imports

Vibe