Appearance
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- Clean — remove stale dist
- Copy — copy token CSS into dist
- Compile types —
tscemits.js,.d.ts, source maps fromtypes/ - Generate styles — creates
{name}.g.cssand overwritesindex.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:
| Export | Pattern | Example |
|---|---|---|
| Size array | {Component}Sizes | BadgeSizes |
| Variant array | {Component}Variants | BadgeVariants |
| Other arrays | {Component}{Dimension}s | DropdownAlignments |
| Size type | {Component}Size | BadgeSize |
| Variant type | {Component}Variant | BadgeVariant |
| Style props | {Component}StyleProps | BadgeStyleProps |
| Sub-component props | {Component}{Sub}StyleProps | BadgeGroupStyleProps |
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-bgUse 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.
| Scenario | Function | Example |
|---|---|---|
| Element class name | base() | .table-row, .tab-panel |
| Enum prop with named values | variant() | data-size="lg", data-variant="ghost" |
| Boolean on/off flag | flag() | data-striped, data-full |
| ARIA state | raw 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 nodesign-components-*counterpart; purely behavioural registration/discovery system.
Checklist
- Create
vibe-design-components-{name}/directory - Add
package.json(copy template, update name) - Add
tsconfig.json(copy verbatim) - Create
types/index.tswith const arrays, derived types, and style prop interfaces - Create
src/{name}.csswith component tokens in@layer vibe.tokens - Create
src/index.cssbarrel importing your token file - Create
scripts/generate.tsimporting types + selector helpers - Implement generators: base → sizes → variants → modifiers → sub-components
- Write
readme.md,developer.md,usage.md,contents.md - Run
npm run buildand verify dist - Add the package to umbrella
@vibe-labs/design-componentsimports