10.3 · difficulty 3/4 · 18 min read
Project 8: Publishing a Cross-Framework Component Library
Use Vite library mode to build a Button component that supports React, Vue, Svelte, and Solid simultaneously — one core logic package, four framework adapters, each published as an independent npm package.
Vite 8.1Stable
Project architecture
text
examples/library-mode-demo/
├── pnpm-workspace.yaml
├── package.json (root)
└── packages/
├── core/ ← Framework-agnostic core logic
│ ├── src/index.ts
│ ├── vite.config.ts
│ └── package.json
├── react/ ← React adapter
├── vue/ ← Vue 3 adapter
├── svelte/ ← Svelte adapter
└── solid/ ← Solid.js adapterCore package: framework-agnostic logic
ts
export interface ButtonConfig {
variant: "primary" | "secondary" | "ghost"
size: "sm" | "md" | "lg"
disabled: boolean
}
/** Generate CSS class names from config */
export function getButtonClasses(config: Partial<ButtonConfig>): string {
const { variant = "primary", size = "md", disabled = false } = config
const base = "btn"
const variants = { primary: "btn-primary", secondary: "btn-secondary", ghost: "btn-ghost" }
const sizes = { sm: "btn-sm", md: "btn-md", lg: "btn-lg" }
const disabledCls = disabled ? "btn-disabled" : ""
return [base, variants[variant], sizes[size], disabledCls].filter(Boolean).join(" ")
}ts
import { defineConfig } from "vite"
import { resolve } from "node:path"
export default defineConfig({
build: {
lib: {
entry: resolve("src/index.ts"),
formats: ["es", "cjs"],
fileName: (format) => `index.${format === "es" ? "js" : "cjs"}`,
},
},
})React adapter
tsx
import type { ButtonHTMLAttributes } from "react"
import { getButtonClasses, type ButtonConfig } from "@ui-kit/core"
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, Partial<ButtonConfig> {
children: React.ReactNode
}
export function Button({ variant, size, disabled, children, className = "", ...props }: ButtonProps) {
const classes = getButtonClasses({ variant, size, disabled: disabled ?? false })
return (
<button className={`${classes} ${className}`} disabled={disabled} {...props}>
{children}
</button>
)
}
export type { ButtonConfig }ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import { resolve } from "node:path"
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve("src/index.tsx"),
formats: ["es", "cjs"],
fileName: (format) => `index.${format === "es" ? "js" : "cjs"}`,
},
rolldownOptions: {
external: ["react", "react/jsx-runtime", "@ui-kit/core"],
output: {
globals: { react: "React", "@ui-kit/core": "UIKitCore" },
},
},
},
})Vue adapter
vue
<script setup lang="ts">
import { computed } from "vue"
import { getButtonClasses } from "@ui-kit/core"
import type { ButtonConfig } from "@ui-kit/core"
const props = withDefaults(defineProps<Partial<ButtonConfig>>(), {
variant: "primary",
size: "md",
disabled: false,
})
const classes = computed(() => getButtonClasses(props))
</script>
<template>
<button :class="classes" :disabled="disabled">
<slot />
</button>
</template>Build and publish workflow
bash
cd examples/library-mode-demo
pnpm install
# Build core first (other packages depend on it)
pnpm -F @ui-kit/core build
# Then build each framework adapter
pnpm -F @ui-kit/react build
pnpm -F @ui-kit/vue build
# ...Or use the root package.json build script (Turborepo handles dependency order automatically):
bash
pnpm build # Turborepo builds in dependency orderHow consumers use the library
tsx
// React project
import { Button } from "@ui-kit/react"
;<Button variant="primary" size="lg">
Click me
</Button>
// Vue project
import { Button } from "@ui-kit/vue"
;<Button variant="secondary">Click me</Button>
// Svelte project
import { Button } from "@ui-kit/svelte"
;<Button>Click me</Button>Self-check
- Why put the core logic in a separate
corepackage instead of duplicating it in each framework adapter? - Why should
@ui-kit/corealso be listed inrolldownOptions.external? - If a user installs both
@ui-kit/reactand@ui-kit/vue, will there be two copies of thecorepackage's code? - How should CSS be handled in this library? If a user doesn't use Tailwind, how do you make the styles work?
ts
// Add Input component core logic to @ui-kit/core:
// - InputConfig: { type: "text" | "password" | "email", disabled, error }
// - getInputClasses(config): returns a CSS class name string
// - validateInput(value: string, type: InputConfig["type"]): boolean
export interface InputConfig {
// TODO
}
export function getInputClasses(config: Partial<InputConfig>): string {
// TODO
}
export function validateInput(value: string, type: InputConfig["type"]): boolean {
// TODO
}