Skip to content

@vibe-labs/design-components — Package Development Guide

How to build a component-level package for the Vibe Design System. Follow this structure exactly — it's what every component package uses.


Package Anatomy

vibe-design-components-{name}/
├── package.json
├── readme.md
├── tsconfig.json
├── scripts/
│   └── generate.ts         # generates component CSS from tokens + types
├── src/
│   └── {name}.css           # token definitions (@layer vibe.tokens)
└── types/
    └── index.ts             # runtime constants + TypeScript types

After build:

dist/
├── index.css                # barrel (tokens + generated styles)
├── {name}.css               # token definitions (copied from src)
├── {name}.g.css             # generated component styles
├── index.js                 # compiled types (runtime constants)
├── index.d.ts               # type declarations
├── index.d.ts.map           # declaration source map
└── index.js.map             # JS source map

package.json

json
{
  "name": "@vibe-labs/design-components-{name}",
  "version": "0.1.0",
  "private": false,
  "type": "module",
  "files": ["dist"],
  "style": "./dist/index.css",
  "exports": {
    ".": {
      "default": "./dist/index.css"
    },
    "./types": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "sideEffects": ["*.css"],
  "scripts": {
    "build": "rimraf dist && ncp src dist && tsc --rootDir types --outDir dist && tsx ./scripts/generate.ts --mode attr"
  },
  "dependencies": {},
  "devDependencies": {
    "@types/node": "^25.2.3",
    "ncp": "^2.0.0",
    "rimraf": "^6.1.2",
    "tsx": "^4.21.0",
    "typescript": "^5.5.0"
  }
}

Key differences from design-level packages

  • Two exports: . for CSS, ./types for TypeScript constants + types
  • Build command includes tsc (compiles types/dist/) and uses tsx (runs the TypeScript generate script)
  • --mode attr flag tells the generator to emit data-attribute selectors (the default selector strategy)

Build Pipeline

Every component package uses the same build command in package.json:

json
"build": "rimraf dist && ncp src dist && tsc --rootDir types --outDir dist && tsx ./scripts/generate.ts --mode attr"
  1. Cleanrimraf dist removes stale output
  2. Copyncp src dist copies token CSS into dist as-is
  3. Compile typestsc --rootDir types --outDir dist emits .js, .d.ts, and source maps
  4. Generate stylestsx ./scripts/generate.ts --mode attr creates {name}.g.css and overwrites index.css

tsconfig.json

Same for every component package:

json
{
  "compilerOptions": {
    "outDir": "dist",
    "target": "ES2020",
    "lib": ["ES2020"],
    "module": "ESNext",
    "moduleResolution": "Node",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "paths": {}
  },
  "include": ["types/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

Source CSS: Token Definitions

Tokens live in src/{name}.css, defined inside @layer vibe.tokens on :root — same as design-level packages:

css
@layer vibe.tokens {
  :root {
    /* ── Sizing ── */
    --badge-height-sm: 1.25rem;
    --badge-height-md: 1.5rem;
    --badge-height-lg: 1.75rem;
    --badge-px-sm: var(--space-1);
    --badge-px-md: var(--space-2);

    /* ── Appearance ── */
    --badge-radius: var(--radius-full);
    --badge-font-weight: var(--font-semibold, 600);
    --badge-bg: var(--surface-elevated);
  }
}

Token naming convention

All component tokens are prefixed with the component name:

--{component}-{property}                    → --badge-radius
--{component}-{property}-{size}             → --badge-height-sm
--{component}-{sub}-{property}              → --dropdown-item-height
--{component}-{sub}-{property}-{size}       → --list-item-height-sm
--{component}-{sub}-{property}-{state}      → --menu-item-hover-bg

Fallback values

Use var(--token, fallback) only when a design-level token might not be loaded. For tokens you own, direct values are fine:

css
--badge-font-weight: var(--font-semibold, 600); /* fallback for safety */
--badge-height-sm: 1.25rem; /* own token, direct value */

TypeScript Types

Types live in types/index.ts. Every component exports:

  1. Runtime const arrays — used by the generate script and consumable by Vue/JS at runtime
  2. Derived literal types — extracted from the const arrays
  3. Style prop interfaces — CSS-level props, framework-agnostic
ts
// Runtime constants
export const BadgeSizes = ["sm", "md", "lg"] as const;
export const BadgeVariants = [
  "accent",
  "success",
  "warning",
  "danger",
  "info",
  "accent-subtle",
  "success-subtle",
  "warning-subtle",
  "danger-subtle",
  "info-subtle",
  "outline",
] as const;

// Derived types
export type BadgeSize = (typeof BadgeSizes)[number];
export type BadgeVariant = (typeof BadgeVariants)[number];

// Style prop interfaces (CSS-level, framework-agnostic)
export interface BadgeStyleProps {
  variant?: BadgeVariant;
  size?: BadgeSize;
  dot?: boolean;
  pill?: boolean;
  square?: boolean;
  interactive?: boolean;
  removable?: boolean;
}

// Sub-component style props as needed
export interface BadgeGroupStyleProps {
  label?: string;
}

Naming conventions

ExportPatternExample
Size array{Component}SizesBadgeSizes
Variant array{Component}VariantsBadgeVariants
Other arrays{Component}{Dimension}sDropdownAlignments, SliderThumbShapes
Size type{Component}SizeBadgeSize
Variant type{Component}VariantBadgeVariant
Style props{Component}StylePropsBadgeStyleProps
Sub-component props{Component}{Sub}StylePropsBadgeGroupStyleProps

Style prop interfaces are CSS-level only — they describe what data-attributes and modifiers the CSS supports, not Vue component props. The Vue layer wraps these with behaviour props (events, slots, accessibility) at the @vibe-labs/ui-* level.


Generate Script

scripts/generate.ts creates the component CSS. It imports types from the types/ directory and selector helpers from the shared .build/ directory.

Structure

ts
import fs from "fs";
import path from "path";
import { base, variant, flag } from "../../../.build/selectors";
import { ComponentSizes, type ComponentVariant } from "../types/index";

const distDir = path.resolve("dist");

// Wrap all generated CSS in the components layer
function layer(txt: string): string {
  return `@layer vibe.components {\n${txt}}\n`;
}

// Generate a single CSS rule
function rule(selector: string, ...declarations: string[]): string {
  return `${selector} {\n${declarations.map((d) => `  ${d};`).join("\n")}\n}\n`;
}

/* ── Base ── */
function generateBase(): string { ... }

/* ── Sizes ── */
function generateSizes(): string { ... }

/* ── Variants ── */
function generateVariants(): string { ... }

/* ── Modifiers ── */
function generateModifiers(): string { ... }

/* ── Write ── */
const all = [
  generateBase(),
  generateSizes(),
  generateVariants(),
  generateModifiers(),
].join("\n");

fs.writeFileSync(path.join(distDir, "{name}.g.css"), layer(all));
fs.writeFileSync(
  path.join(distDir, "index.css"),
  `@import "./{name}.css";\n@import "./{name}.g.css";\n`
);

Shared selector helpers

The ../../../.build/selectors module provides three functions that respect the --mode flag:

HelperUsageOutput (attr mode)
base(name)base("badge").badge
variant(name, dimension, value)variant("badge", "size", "sm").badge[data-size="sm"]
flag(name, modifier)flag("badge", "dot").badge[data-dot]

In flat-class mode these would emit .badge-sm, .badge-dot, etc. — but attr mode is the default.

Generation patterns

Base styles — the foundational selector for the component:

ts
function generateBase(): string {
  return rule(
    base("badge"),
    "display: inline-flex",
    "align-items: center",
    "justify-content: center",
    "gap: var(--space-1)",
    "font-weight: var(--badge-font-weight)",
    "border-radius: var(--badge-radius)",
    "background-color: var(--badge-bg)",
    "color: var(--badge-color)",
  );
}

Size variants — iterate the sizes array from types:

ts
function generateSizes(): string {
  return BadgeSizes.map((s) =>
    rule(
      variant("badge", "size", s),
      `height: var(--badge-height-${s})`,
      `padding-left: var(--badge-px-${s})`,
      `padding-right: var(--badge-px-${s})`,
      `font-size: var(--badge-font-size-${s})`,
    ),
  ).join("");
}

Named variants — map from a typed record to CSS:

ts
const variantStyles: Record<BadgeVariant, { bg: string; color: string; border?: string }> = {
  accent:  { bg: "var(--color-accent)", color: "var(--color-accent-contrast)" },
  outline: { bg: "transparent", color: "var(--text-secondary)", border: "var(--border-default)" },
  // ...
};

function generateVariants(): string {
  return (Object.entries(variantStyles) as [BadgeVariant, ...][])
    .map(([name, vals]) =>
      rule(
        variant("badge", "variant", name),
        `background-color: ${vals.bg}`,
        `color: ${vals.color}`,
        ...(vals.border ? [`border-color: ${vals.border}`] : []),
      ),
    )
    .join("");
}

Boolean flag modifiers — standalone data-attributes:

ts
function generateModifiers(): string {
  return [
    rule(flag("badge", "dot"), "width: 0.5rem", "height: 0.5rem", "padding: 0", "font-size: 0"),
    rule(
      flag("badge", "interactive"),
      "cursor: pointer",
      "transition-property: background-color, border-color, opacity",
      "transition-duration: var(--transition-duration, var(--duration-normal))",
    ),
    // Pseudo-state on a flagged selector
    rule(`${flag("badge", "interactive")}:hover`, "opacity: var(--opacity-80, 0.8)"),
  ].join("");
}

Sub-components — separate base selectors for child elements:

ts
function generateGroup(): string {
  return rule(base("badge-group"), "display: flex", "flex-wrap: wrap", "gap: var(--space-1)", "align-items: center");
}

Pseudo-states and compound selectors

For hover, focus, disabled, and ARIA states, string-concatenate onto the selector helper output:

ts
// Hover on a flagged element
rule(`${flag("badge", "interactive")}:hover`, "opacity: 0.8");

// Disabled via attribute or ARIA
rule(`${base("btn")}:disabled, ${base("btn")}[aria-disabled]`, "opacity: 0.5");

// ARIA selection (used by tabs, menus, dropdowns)
rule(`${base("tab")}[aria-selected="true"]`, "color: var(--tab-active-color)");

// Nested child when parent has state
rule(`${flag("list", "hoverable")} ${base("list-item")}:hover`, "background-color: var(--list-item-hover-bg)");

CSS Layer

All generated component CSS lives in @layer vibe.components:

vibe.reset → vibe.tokens → vibe.utilities → vibe.components → vibe.theme → vibe.accessibility

Component styles sit above utilities (so they can use utility tokens) and below theme (so tenant overrides win).


Variant System

Components use data-attribute selectors by default:

html
<button class="btn" data-variant="primary" data-size="lg">Click</button> <span class="badge" data-variant="accent" data-dot></span>
  • data-variant — visual variant (accent, success, danger, etc.)
  • data-size — size variant (sm, md, lg)
  • Boolean flagsdata-dot, data-interactive, data-loading, etc. (presence = true)
  • Statearia-selected, aria-current, :disabled (prefer native/ARIA over custom attributes)

Flat class mode (.badge-accent, .badge-sm) is also supported via the selector helpers but attr mode is the default.


Readme Structure

Every component package readme follows this template:

markdown
# @vibe-labs/design-components-{name}

One-line description.

## Usage

\```css
@import "@vibe-labs/design-components-{name}";
\```

\```ts
import { ComponentSizes, ComponentVariants } from "@vibe-labs/design-components-{name}/types";
import type { ComponentSize, ComponentVariant, ComponentStyleProps } from "@vibe-labs/design-components-{name}/types";
\```

## Contents

### Tokens (`{name}.css`)

Document every token with default values, ideally in tables grouped by category.

### Generated Styles (`{name}.g.css`)

Document all classes, variants, sizes, modifiers, sub-components, and state selectors.

### TypeScript Types (`types/`)

Show the exported constants, types, and interfaces.

## Dist Structure

| File                      | Description                                |
| ------------------------- | ------------------------------------------ |
| `index.css`               | Barrel — imports tokens + generated styles |
| `{name}.css`              | Token definitions                          |
| `{name}.g.css`            | Generated component styles                 |
| `index.js` / `index.d.ts` | TypeScript types + runtime constants       |

## Dependencies

List required tokens from other packages.

## Build

\```bash
npm run build
\```

Cross-Package Dependencies

Component packages reference design-level tokens via CSS custom properties. These are runtime dependencies — the design umbrella must be loaded first in the cascade.

Don't add design packages to package.json dependencies. The umbrella @vibe-labs/design-components handles import order.

The generate script imports from types/ within the same package. The shared .build/selectors module is accessed via relative path (../../../.build/selectors).


Quick Checklist: New Component Package

  1. Create vibe-design-components-{name}/ directory
  2. Add package.json (copy template, update name)
  3. Add tsconfig.json (copy verbatim)
  4. Create types/index.ts with const arrays, derived types, and style prop interfaces
  5. Create src/{name}.css with component tokens in @layer vibe.tokens
  6. Create scripts/generate.ts importing types + selector helpers
  7. Implement generators: base → sizes → variants → modifiers → sub-components
  8. Write readme.md documenting all tokens, classes, and types
  9. Run npm run build and verify dist output
  10. Add the package to the umbrella @vibe-labs/design-components imports