Building a Modern Component Library: How shadcn + Bun Revolutionize Front-End Development

🇨🇳 中文版

A traditional component library is "install an npm package, use the API its author gave you." shadcn/ui flips that — the component code is copied straight into your project; you own the full source and can modify it however you like. When you want to share that set of components across multiple apps, you need a "registry" to distribute them. This article uses Bun for dependency installation, building, and serving the registry, and shares a copyable, evolvable component-library engineering workflow.

Example project: a custom shadcn registry — Next.js + Storybook docs, deployed to GitHub Pages. The code snippets below are taken from the project's real source.

1. Why shadcn + a registry

The core of shadcn/ui isn't a "library" — it's "source-code ownership":

  • Once a component lands in your components/ui, it is your own code, no longer bound by upstream versions;
  • But when multiple projects within a team share the same components, you need a single source of truth to "copy" from — that's the registry;
  • The shadcn CLI packages components into standard JSON via registry.json, and a single command pulls them into a local project.

Bun handles three jobs here: package manager (packageManager: bun@1.3.5), running the Next.js build, and running shadcn build to generate registry artifacts.

2. Initialization: components.json

The entry point of the registry is components.json, which tells the CLI which style to use, whether RSC is on, where the CSS variables live, and how path aliases map:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "iconLibrary": "lucide"
}

Two things worth noting: tailwind.config is an empty string, meaning Tailwind v4's CSS-first config (theme variables live directly in app/globals.css, no JS config file needed); cssVariables: true means theme colors are CSS variables, which gives dark mode for free.

3. The registry manifest: registry.json

registry.json describes "what components this registry provides." Each item has name, type, title, description, and files[] (pointing at the real source paths). Here are a few real entries from the project:

{
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "erishen",
  "homepage": "https://erishen.github.io/shadcn-registry/",
  "items": [
    {
      "name": "hello-world",
      "type": "registry:component",
      "title": "Hello World",
      "description": "A simple hello world component",
      "registryDependencies": ["button"],
      "files": [
        {
          "path": "registry/new-york/blocks/hello-world/hello-world.tsx",
          "type": "registry:component"
        }
      ]
    },
    {
      "name": "example-form",
      "type": "registry:component",
      "title": "Example Form",
      "description": "A contact form with Zod validation.",
      "dependencies": ["zod"],
      "registryDependencies": ["button", "input", "label", "textarea", "card"],
      "files": [
        {
          "path": "registry/new-york/blocks/example-form/example-form.tsx",
          "type": "registry:component"
        }
      ]
    },
    {
      "name": "complex-component",
      "type": "registry:component",
      "title": "Complex Component",
      "description": "A complex component showing hooks, libs and components.",
      "registryDependencies": ["card"],
      "files": [
        { "path": "registry/new-york/blocks/complex-component/page.tsx", "type": "registry:page", "target": "app/pokemon/page.tsx" },
        { "path": "registry/new-york/blocks/complex-component/components/pokemon-card.tsx", "type": "registry:component" },
        { "path": "registry/new-york/blocks/complex-component/components/pokemon-image.tsx", "type": "registry:component" },
        { "path": "registry/new-york/blocks/complex-component/lib/pokemon.ts", "type": "registry:lib" },
        { "path": "registry/new-york/blocks/complex-component/hooks/use-pokemon.ts", "type": "registry:hook" }
      ]
    }
  ]
}

One item can contain a single file (like hello-world), or — as complex-component does — pull in an entire multi-file feature block: it brings a page, two components, a lib, and a hook in one go, and uses target to specify where the page lands (app/pokemon/page.tsx). registryDependencies will automatically pull in the base UI it depends on.

4. Real UI primitives: Button and Card

The components themselves are standard shadcn source. Taking registry/new-york/ui/button.tsx as an example, it expresses variants with class-variance-authority (cva):

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
        destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
        outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
)

function Button({
  className, variant, size, asChild = false, ...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot : "button"
  return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
}

export { Button, buttonVariants }

Card is a composable structure (registry/new-york/ui/card.tsx), split into CardHeader / CardTitle / CardDescription / CardContent / CardFooter / CardAction sub-components, each carrying a data-slot for outer styling hooks:

import * as React from "react"
import { cn } from "@/lib/utils"

function Card({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card"
      className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
      {...props}
    />
  )
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn("grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)}
      {...props}
    />
  )
}

export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

Note the semantic colors like bg-primary and text-card-foreground throughout these components — they aren't hard-coded colors, but the CSS variables defined in globals.css below.

5. What a multi-file block looks like

The complex-component page is a RSC (React Server Component) that uses React's cache() for request de-duplication:

import { cache } from "react"
import { PokemonCard } from "@/registry/new-york/blocks/complex-component/components/pokemon-card"
import { getPokemonList } from "@/registry/new-york/blocks/complex-component/lib/pokemon"
const getCachedPokemonList = cache(getPokemonList)

export default async function Page() {
  const pokemons = await getCachedPokemonList({ limit: 12 })
  if (!pokemons) return null
  return (
    <div className="mx-auto w-full max-w-2xl px-4">
      <div className="grid grid-cols-2 gap-4 py-10 sm:grid-cols-3 md:grid-cols-4">
        {pokemons.results.map((p) => (
          <PokemonCard key={p.name} name={p.name} />
        ))}
      </div>
    </div>
  )
}

This item fits "install a whole feature at once" precisely because registry.json lists page / component / lib / hook file types, and the shadcn CLI drops them into the right places by target and type.

6. Theming: Tailwind v4 CSS variables

Since tailwind.config is empty in components.json, all theme colors are declared in app/globals.css, using Tailwind v4's @theme inline + the oklch() color space:

@import "tailwindcss";
@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --radius-lg: var(--radius);
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
}

oklch() gives perceptually uniform color transitions, and dark mode only needs to redefine the same variable set under .dark — the bg-primary written in the button automatically re-skins with no conditional class names.

7. Building the registry artifacts

The registry JSON is generated locally by the official CLI (shadcn build reads registry.json and inlines each item's files into public/r/<name>.json); the artifacts are committed to the repo and then served via GitHub Pages:

# Generate public/r/*.json locally (standard shadcn build output, schema: registry-item.json)
pnpm dlx shadcn@latest build
# or
npx shadcn@latest build

Every script in package.json runs on Bun, and Bun is declared as the package manager:

{
  "packageManager": "bun@1.3.5",
  "scripts": {
    "dev": "bun next dev",
    "prod": "bun next build",
    "start": "bun next start",
    "lint": "bun next lint",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build -o docs",
    "deploy": "bun next build && touch out/.nojekyll"
  }
}

The CI (.github/workflows/deploy.yml) deploy steps are straightforward:

- run: bun install
- run: bun run prod            # = bun next build (static export to out/)
- run: bun run build-storybook # generate component docs to docs/
- run: mkdir -p out/storybook && cp -r docs/* out/storybook/
- run: touch out/.nojekyll

8. Consuming components: pull with one command

Users no longer npm install — instead they use the shadcn CLI to "copy" the component from the registry into their own project:

# Pull example-form (with the Zod dependency, auto-includes button/input/label/textarea/card)
pnpm dlx shadcn@latest add https://erishen.github.io/shadcn-registry/r/example-form.json

# Pull hello-world
pnpm dlx shadcn@latest add https://erishen.github.io/shadcn-registry/r/hello-world.json

The app/page.tsx homepage shows exactly these install commands, grouped by category (Tailwind / SCSS / styled-components / advanced), each with a live preview (ComponentPreview) and a Storybook link.

9. Docs and multi-style variants

The project uses Storybook for component docs (.storybook/ + stories/, bun run storybook starts it on port 6006, bun run build-storybook outputs docs/). Meanwhile, registry/ maintains four styling routes side by side, all registered in the single registry.json:

  • registry/new-york/: the primary style source (Tailwind + new-york style, ui/ primitives + blocks/ feature blocks);
  • registry/demo/: plain-CSS demo components originating from Storybook (demo-with-button/header/page);
  • registry/styled-components/: the CSS-in-JS route (depends on styled-components);
  • registry/scss-components/: the SCSS Module route (.module.scss).

All four routes are distributed from one manifest, and users pick based on their project's tech stack.

10. Wrapping up

The shadcn + Bun combo redefines a "component library" as a distributable source of copyable code:

  1. components.json defines style and aliases, registry.json defines the distribution manifest — both are pure declarations;
  2. UI primitives (Button/Card) express variants with cva, theming uses Tailwind v4 CSS variables, and dark mode is free;
  3. Multi-file feature blocks (page + component + lib + hook) install in one go, dropped into place via files/target;
  4. shadcn build generates public/r/*.json, Bun handles dependencies and build, and GitHub Pages handles distribution;
  5. Users run one shadcn add command to copy the source "into their own project," and from then on fully own and freely modify it.

If you maintain UI shared across multiple apps, try collecting your components into a shadcn registry — the control that "copy means ownership" brings often outlasts the convenience of "install and use" as your projects evolve.

11. Source code navigation

The full project lives at github.com/erishen/shadcn-registry. Key files:

File What it does
components.json shadcn CLI config: style (new-york), RSC, tailwind/alias, and registry URL
registry.json Registry distribution manifest: hello-world / example-form / complex-component entries
registry/new-york/ui/button.tsx Button primitive, variants via class-variance-authority
registry/new-york/ui/card.tsx Card primitive, composable sub-components
registry/demo/Button/Button.tsx Demo component (sourced from Storybook)
registry/new-york/blocks/complex-component/page.tsx Multi-file feature block example (page + component + lib + hook)
app/globals.css Tailwind v4 theme: oklch variables + dark mode
app/page.tsx Demo site home, install commands grouped by category with live preview

12. FAQ

What is the fundamental difference between a shadcn registry and a normal npm component library?

A normal library ships compiled artifacts to npm — you install a “black box” and must fork or override styles to change it. A shadcn registry distributes source code: shadcn add copies the .tsx files straight into your project, so you fully own and can freely modify them, with no version-upgrade lock-in.

Why use Bun instead of npm / pnpm?

This registry project itself uses Bun for dependency management, Next.js dev, and shadcn build: faster installs and starts, plus a single-file binary that avoids Node version management. But it only governs how the registry runs — consumers still use their own package manager when adding components, so they are unaffected.

How does one shadcn add drop multiple files into my project?

A registry entry declares its distributed files in the files field, each with a target path (e.g. components/ui/button.tsx). shadcn add fetches the corresponding public/r/*.json and writes each file to its target location, so a multi-file block (page + component + lib + hook) installs in one go.

How are theming and dark mode implemented?

Theming uses Tailwind v4’s CSS-first config: colors are oklch variables defined under :root and .dark in app/globals.css (e.g. --background, --primary). Components only reference semantic variables, so switching to dark mode is just adding the dark class to the root — zero extra code.

How do I choose among the four style tracks (new-york / demo / styled-components / scss)?

They are all registered in the same registry.json and used on demand: new-york is the primary style (Tailwind + new-york); demo is a pure-CSS demo sourced from Storybook; styled-components uses CSS-in-JS; scss-components uses SCSS Modules. Pick the one matching your project’s existing stack — they don’t interfere with each other.

首页 简历 商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号