Skip to content

Build Guide — CSS Component Packages

How to create a new @vibe-labs/design-components-{name} package. These packages generate framework-agnostic CSS for components (in @layer vibe.components) and export TypeScript types. They are consumed by Vue components but contain no framework code.


Directory Structure

vibe-design-components-{name}/
├── package.json
├── readme.md
├── tsconfig.json
├── types/
│   └── index.ts            # const arrays, derived types, style prop interfaces
├── scripts/
│   └── generate.ts         # generates component CSS from types + selector helpers
└── src/
    ├── index.css            # barrel
    └── {name}.css           # component-specific tokens (@layer vibe.tokens)

After build → dist/ contains: index.css (barrel), {name}.css (tokens), {name}.g.css (generated component CSS), index.js + index.d.ts (TypeScript).


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"
  },
  "devDependencies": {
    "@types/node": "^25.2.3",
    "ncp": "^2.0.0",
    "rimraf": "^6.1.2",
    "tsx": "^4.21.0",
    "typescript": "^5.5.0"
  }
}

Build Pipeline

rimraf dist → ncp src dist → tsc --rootDir types --outDir dist → tsx ./scripts/generate.ts --mode attr
  1. Clean — remove stale dist
  2. Copy — copy token CSS into dist
  3. Compile typestsc emits .js, .d.ts, source maps from types/
  4. Generate styles — creates {name}.g.css and overwrites index.css

tsconfig.json

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"]
}

TypeScript Types (types/index.ts)

Every component exports three things:

ts
// 1. Runtime const arrays (used by generate script AND consumable at runtime)
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;

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

// 3. 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
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 the CSS supports, not Vue component props.


Token Definitions (src/{name}.css)

Component-specific tokens in @layer vibe.tokens, prefixed with component name:

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 pattern:

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

Use var(--token, fallback) for design-level tokens that might not be loaded. Direct values for own tokens.


Shared Selector Helpers

Located at ../../../.build/selectors.ts. Three functions that respect the --mode flag:

ts
import { base, variant, flag } from "../../../.build/selectors";

base("badge");               // → ".badge"
variant("badge", "size", "sm"); // → '.badge[data-size="sm"]'
flag("badge", "dot");        // → ".badge[data-dot]"

In flat-class mode these emit .badge-sm, .badge-dot, etc. Attr mode is the default.

ScenarioFunctionExample
Element class namebase().table-row, .tab-panel
Enum prop with named valuesvariant()data-size="lg", data-variant="ghost"
Boolean on/off flagflag()data-striped, data-full
ARIA stateraw string[aria-selected="true"]

flag() takes 2 args (base, name). variant() takes 3 args (base, axis, value).


Generate Script (scripts/generate.ts)

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

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

function layer(txt: string): string {
  return `@layer vibe.components {\n${txt}}\n`;
}

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

/* ── Base ── */
function generateBase(): string {
  return rule(
    base("badge"),
    "display: inline-flex",
    "align-items: 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)",
  );
}

/* ── Sizes ── */
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("");
}

/* ── Variants ── */
function generateVariants(): string {
  const styles: 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)" },
    // ...
  };
  return (Object.entries(styles) as [BadgeVariant, (typeof styles)[BadgeVariant]][])
    .map(([name, vals]) =>
      rule(
        variant("badge", "variant", name),
        `background-color: ${vals.bg}`,
        `color: ${vals.color}`,
        ...(vals.border ? [`border-color: ${vals.border}`] : []),
      ),
    )
    .join("");
}

/* ── Boolean flags ── */
function generateModifiers(): string {
  return [
    rule(flag("badge", "dot"), "width: 0.5rem", "height: 0.5rem", "padding: 0"),
    rule(flag("badge", "interactive"), "cursor: pointer"),
    rule(`${flag("badge", "interactive")}:hover`, "opacity: 0.8"),
  ].join("");
}

/* ── Sub-components ── */
function generateGroup(): string {
  return rule(base("badge-group"), "display: flex", "flex-wrap: wrap", "gap: var(--space-1)");
}

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

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

Pseudo-State and Compound Selectors

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
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)");

// Focus visible ring
rule(
  `${base("btn")}:focus-visible`,
  "box-shadow: var(--ring-offset-color) 0 0 0 var(--ring-offset-width), var(--ring-color) 0 0 0 calc(2px + var(--ring-offset-width))",
);

Container Query Generation

Some packages (e.g. @vibe-labs/design-components-responsive) generate @container rules referencing breakpoint tokens:

ts
const breakpoints: Record<string, string> = {
  xs: "480px", sm: "640px", md: "768px",
  lg: "1024px", xl: "1280px", "2xl": "1536px",
};

function generateResponsiveGrid(): string {
  let output = "";
  output += rule(base("responsive-grid"),
    "display: grid",
    "gap: var(--responsive-grid-gap)",
    "grid-template-columns: repeat(var(--responsive-grid-cols, 1), 1fr)",
  );
  for (let i = 1; i <= 12; i++) {
    output += rule(variant("responsive-grid", "cols", String(i)), `--responsive-grid-cols: ${i}`);
  }
  for (const [bp, width] of Object.entries(breakpoints)) {
    for (let i = 1; i <= 12; i++) {
      output += `@container (min-width: ${width}) {\n`;
      output += rule(variant("responsive-grid", `cols-${bp}`, String(i)), `--responsive-grid-cols: ${i}`);
      output += `}\n`;
    }
  }
  return output;
}

Use CSS custom properties as intermediaries so @container rules only change the variable, not the layout declaration.


Reference Implementations

  • @vibe-labs/design-components-timeline — complex compound component with multiple sub-components, CSS custom property intermediaries for variant cascading, canvas-based rendering, and five associated composables.
  • @vibe-labs/design-vue-hotspots — Vue-only package with no design-components-* counterpart; purely behavioural registration/discovery system.

Checklist

  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 src/index.css barrel importing your token file
  7. Create scripts/generate.ts importing types + selector helpers
  8. Implement generators: base → sizes → variants → modifiers → sub-components
  9. Write readme.md, developer.md, usage.md, contents.md
  10. Run npm run build and verify dist
  11. Add the package to umbrella @vibe-labs/design-components imports

Vibe