From 8e2d2d1439f54260aa0c51747261c220334ec641 Mon Sep 17 00:00:00 2001 From: shadcn Date: Sun, 31 May 2026 16:11:01 +0400 Subject: [PATCH] feat: add shadcn eject (#10834) --- .changeset/good-suits-know.md | 5 + apps/v4/content/docs/(root)/cli.mdx | 86 ++++++ .../docs/changelog/2026-05-shadcn-eject.mdx | 61 ++++ packages/shadcn/CHANGELOG.md | 2 +- packages/shadcn/src/commands/eject.test.ts | 262 ++++++++++++++++++ packages/shadcn/src/commands/eject.ts | 248 +++++++++++++++++ packages/shadcn/src/index.ts | 2 + 7 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 .changeset/good-suits-know.md create mode 100644 apps/v4/content/docs/changelog/2026-05-shadcn-eject.mdx create mode 100644 packages/shadcn/src/commands/eject.test.ts create mode 100644 packages/shadcn/src/commands/eject.ts diff --git a/.changeset/good-suits-know.md b/.changeset/good-suits-know.md new file mode 100644 index 0000000000..7107ebd9ab --- /dev/null +++ b/.changeset/good-suits-know.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add npx shadcn eject diff --git a/apps/v4/content/docs/(root)/cli.mdx b/apps/v4/content/docs/(root)/cli.mdx index dbf7019ad4..aa81bac9e1 100644 --- a/apps/v4/content/docs/(root)/cli.mdx +++ b/apps/v4/content/docs/(root)/cli.mdx @@ -3,6 +3,8 @@ title: shadcn description: Use the shadcn CLI to add components to your project. --- +import { TriangleAlertIcon } from "lucide-react" + ## init Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`. @@ -503,3 +505,87 @@ npx shadcn@latest migrate radix "src/components/ui/**" If no path is provided, the migration will transform all files in your `ui` directory (from `components.json`). Once complete, you can remove any unused `@radix-ui/react-*` packages from your `package.json`. + +--- + +## eject + +When you run `init`, shadcn adds `@import "shadcn/tailwind.css"` to your global CSS file. This import provides shared Tailwind v4 utilities such as custom variants (`data-open:`, `data-closed:`, etc.) and accordion animations. + +Use the `eject` command to inline `shadcn/tailwind.css` into your global CSS file and remove the `shadcn` dependency from your project. + +}> + **Note: This action is irreversible.** After ejecting, future shadcn CLI + updates to `shadcn/tailwind.css` will not apply automatically. + + +```bash +npx shadcn@latest eject +``` + +**Before** + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +``` + +**After** + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +/* ejected from shadcn@4.8.3 */ +@theme inline { + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var( + --radix-accordion-content-height, + var(--accordion-panel-height, auto) + ); + } + } +} + +@custom-variant data-open { + &:where([data-state="open"]), + &:where([data-open]:not([data-open="false"])) { + @slot; + } +} + +@utility no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} +``` + +**Monorepo** + +In a monorepo, run the command from the workspace that contains your `components.json` and global CSS file: + +```bash +npx shadcn@latest eject -c packages/ui +``` + +**Options** + +```bash +Usage: shadcn eject [options] + +inline shadcn/tailwind.css and remove the shadcn dependency + +Options: + -c, --cwd the working directory. defaults to the current directory. + -y, --yes skip confirmation prompt. (default: false) + -s, --silent mute output. (default: false) + -h, --help display help for command +``` diff --git a/apps/v4/content/docs/changelog/2026-05-shadcn-eject.mdx b/apps/v4/content/docs/changelog/2026-05-shadcn-eject.mdx new file mode 100644 index 0000000000..4fa8e8fe78 --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-05-shadcn-eject.mdx @@ -0,0 +1,61 @@ +--- +title: May 2026 - shadcn eject +description: Inline shadcn/tailwind.css and remove the shadcn dependency. +date: 2026-05-31 +--- + +When we added support for both Radix and Base UI, we needed a place for shared Tailwind utilities that both libraries depend on, e.g. custom variants like `data-open:` and `data-closed:` and utilities like `no-scrollbar`. + +We also ran into a few bugs while working on RTL support that were easier to fix in one shared place rather than duplicating across every component. + +So we created `shadcn/tailwind.css`. When you run `init`, it adds `@import "shadcn/tailwind.css"` to your global CSS file. It works just like other CSS imports such as `tw-animate-css`: a small dependency that is tree-shaken in production and resolved at build time. + +If you prefer not to depend on the `shadcn` package for that CSS, we've added the `shadcn eject` command. It inlines `shadcn/tailwind.css` into your global CSS file and removes the `shadcn` dependency from your project. + +```bash +npx shadcn@latest eject +``` + +**Before** + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +``` + +**After** + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +/* ejected from shadcn@4.8.3 */ +@theme inline { + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var( + --radix-accordion-content-height, + var(--accordion-panel-height, auto) + ); + } + } +} + +@custom-variant data-open { + &:where([data-state="open"]), + &:where([data-open]:not([data-open="false"])) { + @slot; + } +} +``` + +In a monorepo, run the command from the workspace that contains your `components.json` and global CSS file: + +```bash +npx shadcn@latest eject -c packages/ui +``` + +See the [CLI documentation](/docs/cli#eject) for more details. diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index fcc41fd96a..6ed38ace85 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,4 +1,4 @@ -# @shadcn/ui +# shadcn ## 4.8.3 diff --git a/packages/shadcn/src/commands/eject.test.ts b/packages/shadcn/src/commands/eject.test.ts new file mode 100644 index 0000000000..76406c8337 --- /dev/null +++ b/packages/shadcn/src/commands/eject.test.ts @@ -0,0 +1,262 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises" +import os from "os" +import path from "path" +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "@/src/utils/get-monorepo-info" +import { getPackageManager } from "@/src/utils/get-package-manager" +import { execa } from "execa" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { runEject, SHADCN_TAILWIND_IMPORT } from "./eject" + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("@/src/utils/get-package-manager", () => ({ + getPackageManager: vi.fn().mockResolvedValue("pnpm"), +})) + +vi.mock("@/src/utils/get-monorepo-info", async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + formatMonorepoMessage: vi.fn(), + getMonorepoTargets: vi.fn().mockResolvedValue([]), + isMonorepoRoot: vi.fn().mockResolvedValue(false), + } +}) + +vi.mock("@/src/utils/spinner", () => ({ + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn(), + })), +})) + +vi.mock("@/src/utils/logger", () => ({ + logger: { + break: vi.fn(), + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + }, +})) + +const fixtureConfig = { + $schema: "https://ui.shadcn.com/schema.json", + style: "base-nova", + rsc: true, + tsx: true, + tailwind: { + config: "", + css: "app/globals.css", + baseColor: "neutral", + cssVariables: true, + }, + aliases: { + components: "@/components", + utils: "@/lib/utils", + ui: "@/components/ui", + lib: "@/lib", + hooks: "@/hooks", + }, +} + +const fixtureTsconfig = { + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./*"], + }, + }, +} + +const baseCss = `@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@layer base { + body { + @apply bg-background text-foreground; + } +} +` + +describe("runEject", () => { + let tempDir: string + + beforeEach(async () => { + vi.clearAllMocks() + vi.mocked(getPackageManager).mockResolvedValue("pnpm") + tempDir = await mkdtemp(path.join(os.tmpdir(), "shadcn-eject-")) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + async function setupProject( + cssContent: string, + packageJson: Record = { + name: "test-app", + dependencies: { + shadcn: "^4.8.3", + }, + } + ) { + await writeFile( + path.join(tempDir, "components.json"), + JSON.stringify(fixtureConfig, null, 2) + ) + await writeFile( + path.join(tempDir, "tsconfig.json"), + JSON.stringify(fixtureTsconfig, null, 2) + ) + await mkdir(path.join(tempDir, "app"), { recursive: true }) + await writeFile(path.join(tempDir, "app", "globals.css"), cssContent) + await writeFile( + path.join(tempDir, "package.json"), + JSON.stringify(packageJson, null, 2) + ) + } + + it("inlines shadcn/tailwind.css and removes the dependency", async () => { + await setupProject(baseCss) + + await runEject({ + cwd: tempDir, + yes: true, + silent: true, + }) + + const output = await readFile( + path.join(tempDir, "app", "globals.css"), + "utf8" + ) + const shadcnCss = await readFile( + path.join(process.cwd(), "src/tailwind.css"), + "utf8" + ) + + expect(output).not.toMatch(SHADCN_TAILWIND_IMPORT) + expect(output).toContain('@import "tailwindcss";') + expect(output).toContain('@import "tw-animate-css";') + expect(output).toContain("/* ejected from shadcn@4.8.3 */") + expect(output).toContain(shadcnCss.trim()) + expect(output).toContain("@layer base") + expect(output).toContain("@apply bg-background text-foreground;") + expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], { + cwd: tempDir, + }) + }) + + it("removes shadcn from devDependencies", async () => { + await setupProject(baseCss, { + name: "test-app", + devDependencies: { + shadcn: "^4.8.3", + }, + }) + + await runEject({ + cwd: tempDir, + yes: true, + silent: true, + }) + + const output = await readFile( + path.join(tempDir, "app", "globals.css"), + "utf8" + ) + + expect(output).toContain("/* ejected from shadcn@4.8.3 */") + expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], { + cwd: tempDir, + }) + }) + + it("skips dependency removal when shadcn is not in package.json", async () => { + await setupProject(baseCss, { + name: "test-app", + dependencies: {}, + }) + + await runEject({ + cwd: tempDir, + yes: true, + silent: true, + }) + + expect(execa).not.toHaveBeenCalled() + }) + + it("removes shadcn from package.json for deno", async () => { + vi.mocked(getPackageManager).mockResolvedValue("deno") + await setupProject(baseCss) + + await runEject({ + cwd: tempDir, + yes: true, + silent: true, + }) + + const packageJson = JSON.parse( + await readFile(path.join(tempDir, "package.json"), "utf8") + ) + + expect(packageJson.dependencies?.shadcn).toBeUndefined() + expect(execa).not.toHaveBeenCalled() + }) + + it("exits when shadcn/tailwind.css is not imported", async () => { + await setupProject(`@import "tailwindcss"; +@import "tw-animate-css"; +`) + + const exit = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit") + }) as never) + + await expect( + runEject({ + cwd: tempDir, + yes: true, + silent: true, + }) + ).rejects.toThrow("process.exit") + + exit.mockRestore() + }) + + it("shows monorepo suggestions when run from a monorepo root", async () => { + vi.mocked(isMonorepoRoot).mockResolvedValue(true) + vi.mocked(getMonorepoTargets).mockResolvedValue([ + { name: "packages/ui", hasConfig: true }, + { name: "apps/web", hasConfig: true }, + ]) + + const exit = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit") + }) as never) + + await expect( + runEject({ + cwd: tempDir, + yes: true, + silent: true, + }) + ).rejects.toThrow("process.exit") + + expect(formatMonorepoMessage).toHaveBeenCalledWith("eject", [ + { name: "packages/ui", hasConfig: true }, + { name: "apps/web", hasConfig: true }, + ]) + + exit.mockRestore() + }) +}) diff --git a/packages/shadcn/src/commands/eject.ts b/packages/shadcn/src/commands/eject.ts new file mode 100644 index 0000000000..8c17495559 --- /dev/null +++ b/packages/shadcn/src/commands/eject.ts @@ -0,0 +1,248 @@ +import { promises as fs } from "fs" +import path from "path" +import { SHADCN_URL } from "@/src/registry/constants" +import { getConfig } from "@/src/utils/get-config" +import { + formatMonorepoMessage, + getMonorepoTargets, + isMonorepoRoot, +} from "@/src/utils/get-monorepo-info" +import { getPackageInfo } from "@/src/utils/get-package-info" +import { getPackageManager } from "@/src/utils/get-package-manager" +import { handleError } from "@/src/utils/handle-error" +import { highlighter } from "@/src/utils/highlighter" +import { logger } from "@/src/utils/logger" +import { spinner } from "@/src/utils/spinner" +import { Command } from "commander" +import { execa } from "execa" +import fsExtra from "fs-extra" +import prompts from "prompts" +import { z } from "zod" + +export const SHADCN_TAILWIND_IMPORT = + /@import\s+["']shadcn\/tailwind\.css["'];?\s*\n?/ + +export const ejectOptionsSchema = z.object({ + cwd: z.string(), + yes: z.boolean(), + silent: z.boolean(), +}) + +export const eject = new Command() + .name("eject") + .description("inline shadcn/tailwind.css and remove the shadcn dependency") + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd() + ) + .option("-y, --yes", "skip confirmation prompt.", false) + .option("-s, --silent", "mute output.", false) + .action(async (opts) => { + try { + const options = ejectOptionsSchema.parse({ + cwd: path.resolve(opts.cwd), + yes: opts.yes, + silent: opts.silent, + }) + + await runEject(options) + } catch (error) { + handleError(error) + } + }) + +export async function runEject(options: z.infer) { + if (!fsExtra.existsSync(path.resolve(options.cwd, "components.json"))) { + if (await isMonorepoRoot(options.cwd)) { + const targets = await getMonorepoTargets(options.cwd) + if (targets.length > 0) { + formatMonorepoMessage("eject", targets) + process.exit(1) + } + } + + logger.break() + logger.error( + `No ${highlighter.info("components.json")} found. Run ${highlighter.info("shadcn init")} first.` + ) + logger.error( + `Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.` + ) + logger.break() + process.exit(1) + } + + const config = await getConfig(options.cwd) + if (!config?.resolvedPaths.tailwindCss) { + logger.break() + logger.error( + "Could not resolve the Tailwind CSS file from components.json." + ) + logger.break() + process.exit(1) + } + + const cssFilepath = config.resolvedPaths.tailwindCss + const cssFilepathRelative = path.relative(options.cwd, cssFilepath) + let cssContent = await fs.readFile(cssFilepath, "utf8") + + if (!SHADCN_TAILWIND_IMPORT.test(cssContent)) { + logger.break() + logger.error( + `Could not find ${highlighter.info('@import "shadcn/tailwind.css"')} in ${highlighter.info(cssFilepathRelative)}.` + ) + logger.error("Nothing to eject.") + logger.break() + process.exit(1) + } + + const packageInfo = getPackageInfo(options.cwd, false) + const shadcnVersion = getShadcnVersion(packageInfo) + const shadcnCssPath = resolveShadcnTailwindCss(options.cwd) + const shadcnCssContent = await fs.readFile(shadcnCssPath, "utf8") + + if (!options.silent) { + logger.break() + logger.warn( + "This action is not reversible. Future shadcn CLI updates to tailwind.css will not apply automatically." + ) + logger.break() + } + + if (!options.yes) { + logger.log("This will:") + logger.log( + ` - Inline ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}` + ) + logger.log(` - Remove the ${highlighter.info("shadcn")} dependency`) + logger.break() + + const { proceed } = await prompts({ + type: "confirm", + name: "proceed", + message: "Proceed?", + initial: false, + }) + + if (!proceed) { + process.exit(0) + } + } + + const ejectSpinner = spinner( + `Inlining ${highlighter.info("shadcn/tailwind.css")}.`, + { + silent: options.silent, + } + )?.start() + + cssContent = cssContent.replace( + SHADCN_TAILWIND_IMPORT, + () => + `/* ejected from shadcn@${shadcnVersion} */\n${shadcnCssContent.trim()}\n\n` + ) + + await fs.writeFile(cssFilepath, cssContent, "utf8") + ejectSpinner?.succeed() + + if (hasShadcnDependency(packageInfo)) { + const removeSpinner = spinner(`Removing ${highlighter.info("shadcn")}.`, { + silent: options.silent, + })?.start() + + await removeShadcnDependency(options.cwd) + removeSpinner?.succeed() + } else if (!options.silent) { + logger.warn( + `The ${highlighter.info("shadcn")} package was not found in package.json. Skipped removal.` + ) + } + + logger.break() + logger.log( + `Ejected ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}.` + ) + logger.break() +} + +function getShadcnVersion(packageInfo: ReturnType) { + if (!packageInfo) { + return "unknown" + } + + return ( + packageInfo.dependencies?.shadcn ?? + packageInfo.devDependencies?.shadcn ?? + "unknown" + ) + .replace(/^[\^~]/, "") + .trim() +} + +function hasShadcnDependency(packageInfo: ReturnType) { + if (!packageInfo) { + return false + } + + return Boolean( + packageInfo.dependencies?.shadcn || packageInfo.devDependencies?.shadcn + ) +} + +function resolveShadcnTailwindCss(cwd: string) { + const projectCss = path.join(cwd, "node_modules/shadcn/dist/tailwind.css") + if (fsExtra.existsSync(projectCss)) { + return projectCss + } + + const cliRoot = process.argv[1] + ? path.dirname(path.resolve(process.argv[1])) + : cwd + + for (const candidate of [ + path.join(cliRoot, "tailwind.css"), + path.join(cliRoot, "dist", "tailwind.css"), + path.join(cliRoot, "src", "tailwind.css"), + path.join(process.cwd(), "src/tailwind.css"), + path.join(process.cwd(), "dist/tailwind.css"), + ]) { + if (fsExtra.existsSync(candidate)) { + return candidate + } + } + + throw new Error("Could not resolve shadcn/tailwind.css.") +} + +async function removeShadcnDependency(cwd: string) { + const packageManager = await getPackageManager(cwd) + + switch (packageManager) { + case "npm": + await execa("npm", ["uninstall", "shadcn"], { cwd }) + break + case "pnpm": + await execa("pnpm", ["remove", "shadcn"], { cwd }) + break + case "yarn": + await execa("yarn", ["remove", "shadcn"], { cwd }) + break + case "bun": + await execa("bun", ["remove", "shadcn"], { cwd }) + break + case "deno": { + const packageJsonPath = path.join(cwd, "package.json") + const packageJson = await fsExtra.readJson(packageJsonPath) + + for (const field of ["dependencies", "devDependencies"] as const) { + if (packageJson[field]?.shadcn) { + delete packageJson[field].shadcn + } + } + + await fsExtra.writeJson(packageJsonPath, packageJson, { spaces: 2 }) + break + } + } +} diff --git a/packages/shadcn/src/index.ts b/packages/shadcn/src/index.ts index c0e9b6ca4a..39a3d68be4 100644 --- a/packages/shadcn/src/index.ts +++ b/packages/shadcn/src/index.ts @@ -4,6 +4,7 @@ import { apply } from "@/src/commands/apply" import { build } from "@/src/commands/build" import { diff } from "@/src/commands/diff" import { docs } from "@/src/commands/docs" +import { eject } from "@/src/commands/eject" import { info } from "@/src/commands/info" import { init } from "@/src/commands/init" import { mcp } from "@/src/commands/mcp" @@ -38,6 +39,7 @@ async function main() { .addCommand(view) .addCommand(search) .addCommand(migrate) + .addCommand(eject) .addCommand(info) .addCommand(build) .addCommand(mcp)