Merge branch 'main' into feat/added-shadcn-dashboard

This commit is contained in:
Mihir Koshti
2026-04-08 18:15:55 +05:30
37 changed files with 2023 additions and 279 deletions

View File

@@ -10,6 +10,7 @@ import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
React.useEffect(() => {
if (hasCopied) {
@@ -32,12 +33,13 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
<Button
variant="outline"
onClick={handleCopy}
title={label}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
>
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
<span className="block min-w-0 truncate">{label}</span>
</Button>
)
}

View File

@@ -23,12 +23,12 @@ import { FontPicker } from "@/app/(app)/create/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/create/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
import { RandomButton } from "@/app/(app)/create/components/random-button"
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
import { StylePicker } from "@/app/(app)/create/components/style-picker"
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { V0Button } from "@/app/(app)/create/components/v0-button"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
@@ -102,8 +102,12 @@ export function Customizer({
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
<OpenPreset
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
label={isMobile ? "Open" : "Open Preset"}
/>
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>

View File

@@ -17,6 +17,7 @@ import {
} from "@/app/(app)/create/components/picker"
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
import { useHistory } from "@/app/(app)/create/hooks/use-history"
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { useReset } from "@/app/(app)/create/hooks/use-reset"
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
@@ -27,6 +28,7 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
const [isMac, setIsMac] = React.useState(false)
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
const { openActionMenu } = useActionMenuTrigger()
const { openPreset } = useOpenPresetTrigger()
const { randomize } = useRandom()
const { toggleTheme } = useThemeToggle()
const { setShowResetDialog } = useReset()
@@ -55,6 +57,9 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
Navigate...
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={openPreset}>
Open Preset... <PickerShortcut>O</PickerShortcut>
</PickerItem>
<PickerItem onClick={randomize}>
Shuffle <PickerShortcut>R</PickerShortcut>
</PickerItem>

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
import { Input } from "@/styles/base-nova/ui/input"
import {
OPEN_PRESET_FORWARD_TYPE,
useOpenPreset,
} from "@/app/(app)/create/hooks/use-open-preset"
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PRESET_EXAMPLE = "b2D0wqNxT"
const PRESET_TITLE = "Open Preset"
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
export function OpenPreset({
className,
label = "Open Preset",
}: React.ComponentProps<typeof Button> & {
label?: string
}) {
const [input, setInput] = React.useState("")
const [, setParams] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const { open, setOpen } = useOpenPreset()
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
const isInvalid = input.trim().length > 0 && nextPreset === null
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setInput("")
}
},
[setOpen]
)
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!nextPreset) {
return
}
setParams({ preset: nextPreset })
handleOpenChange(false)
},
[handleOpenChange, nextPreset, setParams]
)
const triggerClassName = cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)
const desktopTrigger = (
<Button variant="outline" className={triggerClassName} />
)
const fields = (
<Field data-invalid={isInvalid || undefined}>
<FieldLabel htmlFor="preset-code" className="sr-only">
Preset code
</FieldLabel>
<FieldContent>
<Input
id="preset-code"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={isInvalid}
className="h-10 md:h-8"
/>
</FieldContent>
</Field>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>
<Button variant="outline" className={triggerClassName}>
{label}
</Button>
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit}>
<div className="px-4 py-2">{fields}</div>
<DrawerFooter>
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose asChild>
<Button variant="outline" type="button" className="h-10">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
<DialogContent className="dark">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{PRESET_TITLE}</DialogTitle>
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
</DialogHeader>
<div className="py-4">{fields}</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
Cancel
</DialogClose>
<Button type="submit" disabled={!nextPreset}>
Open
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function OpenPresetScript() {
return (
<Script
id="open-preset-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward O key.
document.addEventListener('keydown', function(e) {
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${OPEN_PRESET_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -11,6 +11,7 @@ import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switc
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
@@ -20,78 +21,6 @@ import {
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
function handleMessage(event: MessageEvent) {
if (
typeof window === "undefined" ||
event.origin !== window.location.origin
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
@@ -117,6 +46,89 @@ export function Preview() {
}, [params])
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (
!iframeWindow ||
event.origin !== window.location.origin ||
event.source !== iframeWindow ||
!event.data ||
typeof event.data !== "object"
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "o",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)

View File

@@ -28,7 +28,7 @@ export function RandomButton({
)}
{...props}
>
<span className="w-full text-center font-medium">Shuffle</span>
<span className="w-full truncate text-center font-medium">Shuffle</span>
</Button>
)
}

View File

@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import useSWR from "swr"
const OPEN_PRESET_KEY = "create:open-preset-open"
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
function isEditableTarget(target: EventTarget | null) {
return (
(target instanceof HTMLElement && target.isContentEditable) ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
)
}
export function useOpenPreset() {
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
OPEN_PRESET_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
e.key === "o" &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (isEditableTarget(e.target)) {
return
}
e.preventDefault()
void setOpenData(true, { revalidate: false })
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
open,
setOpen: handleOpenChange,
}
}
export function useOpenPresetTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openPreset = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openPreset,
}
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { parsePresetInput } from "./parse-preset-input"
describe("parsePresetInput", () => {
it("accepts a raw preset code", () => {
expect(parsePresetInput("b0")).toBe("b0")
})
it("accepts a --preset flag", () => {
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
})
it("rejects invalid preset input", () => {
expect(parsePresetInput("open sesame")).toBeNull()
})
})

View File

@@ -0,0 +1,15 @@
import { isPresetCode } from "shadcn/preset"
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
export function parsePresetInput(value: string) {
const input = value.trim()
if (!input) {
return null
}
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
return isPresetCode(preset) ? preset : null
}

View File

@@ -10,6 +10,7 @@ import { ActionMenuScript } from "@/app/(app)/create/components/action-menu"
import { DesignSystemProvider } from "@/app/(app)/create/components/design-system-provider"
import { HistoryScript } from "@/app/(app)/create/components/history-buttons"
import { DarkModeScript } from "@/app/(app)/create/components/mode-switcher"
import { OpenPresetScript } from "@/app/(app)/create/components/open-preset"
import { PreviewStyle } from "@/app/(app)/create/components/preview-style"
import { RandomizeScript } from "@/app/(app)/create/components/random-button"
import {
@@ -139,6 +140,7 @@ export default async function BlockPage({
<PreventScrollOnFocusScript />
<PreviewStyle />
<ActionMenuScript />
<OpenPresetScript />
<RandomizeScript />
<HistoryScript />
<DarkModeScript />

View File

@@ -6,7 +6,7 @@ import { IconCheck, IconCopy, IconPlus } from "@tabler/icons-react"
import { useConfig } from "@/hooks/use-config"
import { useIsMobile } from "@/hooks/use-mobile"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
@@ -15,8 +15,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
@@ -25,42 +24,74 @@ import {
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
} from "@/styles/base-nova/ui/drawer"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
} from "@/styles/base-nova/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
} from "@/styles/base-nova/ui/tooltip"
const DirectoryAddContext = React.createContext<{
open: (registry: { name: string }) => void
}>({
open: () => {},
})
export function useDirectoryAdd() {
return React.useContext(DirectoryAddContext)
}
export function DirectoryAddButton({
registry,
}: {
registry: { name: string }
}) {
const { open } = useDirectoryAdd()
return (
<Button
size="sm"
variant="outline"
className="relative z-10"
onClick={() => open(registry)}
>
Add <IconPlus />
</Button>
)
}
export function DirectoryAddProvider({
children,
}: {
children: React.ReactNode
}) {
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [open, setOpen] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [selectedRegistry, setSelectedRegistry] = React.useState<{
name: string
} | null>(null)
const isMobile = useIsMobile()
const packageManager = config.packageManager || "pnpm"
const commands = React.useMemo(() => {
if (!selectedRegistry) return null
return {
pnpm: `pnpm dlx shadcn@latest registry add ${registry.name}`,
npm: `npx shadcn@latest registry add ${registry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${registry.name}`,
bun: `bunx --bun shadcn@latest registry add ${registry.name}`,
pnpm: `pnpm dlx shadcn@latest registry add ${selectedRegistry.name}`,
npm: `npx shadcn@latest registry add ${selectedRegistry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${selectedRegistry.name}`,
bun: `bunx --bun shadcn@latest registry add ${selectedRegistry.name}`,
}
}, [registry.name])
}, [selectedRegistry])
const command = commands[packageManager]
const command = commands?.[packageManager] ?? ""
React.useEffect(() => {
if (hasCopied) {
@@ -74,19 +105,23 @@ export function DirectoryAddButton({
name: "copy_registry_add_command",
properties: {
command,
registry: registry.name,
registry: selectedRegistry?.name ?? "",
},
})
setHasCopied(true)
}, [command, registry.name])
}, [command, selectedRegistry?.name])
const Trigger = (
<Button size="sm" variant="outline" className="relative z-10">
Add <IconPlus />
</Button>
const contextValue = React.useMemo(
() => ({
open: (registry: { name: string }) => {
setSelectedRegistry(registry)
setIsOpen(true)
},
}),
[]
)
const Content = (
const Content = commands ? (
<Tabs
value={packageManager}
onValueChange={(value) => {
@@ -95,30 +130,28 @@ export function DirectoryAddButton({
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
className="gap-0 overflow-hidden rounded-lg border"
className="gap-0 overflow-hidden rounded-xl border"
>
<div className="flex items-center gap-2 border-b p-2">
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
<div className="flex items-center gap-2 border-b p-1.5">
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
onClick={handleCopy}
>
{hasCopied ? (
<IconCheck className="size-4" />
) : (
<IconCopy className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
<TooltipTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
/>
}
>
{hasCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy command</span>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
@@ -135,47 +168,48 @@ export function DirectoryAddButton({
</TabsContent>
))}
</Tabs>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {registry.name} to your project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
) : null
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{Trigger}</DialogTrigger>
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {registry.name} to your project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose asChild>
<Button size="sm">Done</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<DirectoryAddContext value={contextValue}>
{children}
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="animate-none! sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose render={<Button size="sm" variant="outline" />}>
Done
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DirectoryAddContext>
)
}

View File

@@ -1,12 +1,20 @@
"use client"
import * as React from "react"
import { IconArrowUpRight } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
IconArrowUpRight,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { DirectoryAddButton } from "@/components/directory-add-button"
import globalRegistries from "@/registry/directory.json"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DirectoryAddButton,
DirectoryAddProvider,
} from "@/components/directory-add-button"
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
import {
Item,
ItemActions,
@@ -17,9 +25,16 @@ import {
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/registry/new-york-v4/ui/item"
} from "@/styles/base-nova/ui/item"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/styles/base-nova/ui/pagination"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { SearchDirectory } from "./search-directory"
import { SearchDirectory } from "./directory-search"
function getHomepageUrl(homepage: string) {
const url = new URL(homepage)
@@ -29,55 +44,308 @@ function getHomepageUrl(homepage: string) {
return url.toString()
}
function getPageHref(pathname: string, query: string, page: number) {
const searchParams = new URLSearchParams()
if (query) {
searchParams.set("q", query)
}
if (page > 1) {
searchParams.set("page", page.toString())
}
const search = searchParams.toString()
return search ? `${pathname}?${search}` : pathname
}
function getPageNumbers(current: number, total: number) {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as (
| number
| "ellipsis"
)[]
}
const pages: (number | "ellipsis")[] = [1]
// Show ellipsis or page 2 directly if only one number would be hidden.
if (current > 4) {
pages.push("ellipsis")
} else if (current >= 4) {
pages.push(2)
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Show ellipsis or second-to-last page directly if only one number would be hidden.
if (current < total - 3) {
pages.push("ellipsis")
} else if (current <= total - 3) {
pages.push(total - 1)
}
pages.push(total)
return pages
}
type DirectoryPaginationLinkProps = React.ComponentProps<"a"> & {
isActive?: boolean
size?: React.ComponentProps<typeof Button>["size"]
}
function DirectoryPaginationLink({
className,
isActive,
size = "icon",
...props
}: DirectoryPaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function DirectoryPaginationPrevious({
className,
text = "Previous",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<IconChevronLeft className="cn-rtl-flip size-4" />
<span className="hidden sm:block">{text}</span>
</DirectoryPaginationLink>
)
}
function DirectoryPaginationNext({
className,
text = "Next",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<IconChevronRight className="cn-rtl-flip size-4" />
</DirectoryPaginationLink>
)
}
export function DirectoryList() {
const { registries } = useSearchRegistry()
const pathname = usePathname()
const {
isLoading,
paginatedRegistries,
page,
query,
registries,
totalPages,
setPage,
setQuery,
} = useSearchRegistry()
const previousHref =
page > 1 ? getPageHref(pathname, query, page - 1) : undefined
const nextHref =
page < totalPages ? getPageHref(pathname, query, page + 1) : undefined
const handlePageChange = React.useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
targetPage: number,
disabled = false
) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
if (disabled || targetPage === page) {
event.preventDefault()
return
}
event.preventDefault()
void setPage(targetPage)
},
[page, setPage]
)
return (
<div className="mt-6">
<SearchDirectory />
<DirectoryAddProvider>
<div className="mt-6">
{isLoading ? (
<DirectoryListSkeleton />
) : (
<>
<SearchDirectory
query={query}
registriesCount={registries.length}
setQuery={setQuery}
/>
<ItemGroup className="my-8">
{paginatedRegistries.map((registry, index) => (
<React.Fragment key={registry.name}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<ItemContent>
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
</ItemFooter>
</Item>
{index < paginatedRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
</React.Fragment>
))}
</ItemGroup>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<DirectoryPaginationPrevious
href={previousHref}
aria-disabled={page <= 1 || undefined}
tabIndex={page <= 1 ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page - 1, page <= 1)
}
className={cn(
page <= 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
{getPageNumbers(page, totalPages).map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<DirectoryPaginationLink
href={getPageHref(pathname, query, p)}
isActive={p === page}
onClick={(event) => handlePageChange(event, p)}
className="cursor-pointer"
>
{p}
</DirectoryPaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<DirectoryPaginationNext
href={nextHref}
aria-disabled={page >= totalPages || undefined}
tabIndex={page >= totalPages ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page + 1, page >= totalPages)
}
className={cn(
page >= totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
</div>
</DirectoryAddProvider>
)
}
function DirectoryListSkeleton() {
return (
<>
<Skeleton className="h-8 w-full rounded-lg" />
<ItemGroup className="my-8">
{registries.map((registry, index) => (
{Array.from({ length: 5 }, (_, index) => (
<React.Fragment key={index}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<Item className="relative items-start gap-6 px-0">
<Skeleton className="size-8 rounded-lg" />
<ItemContent>
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
<Skeleton className="h-4 w-32 sm:w-40" />
<Skeleton className="mt-1.5 h-4 w-full max-w-md" />
<Skeleton className="mt-1 h-4 w-3/4 max-w-sm" />
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
<ItemActions className="hidden self-start sm:flex">
<Skeleton className="h-7 w-16 rounded-lg" />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
<ItemFooter className="justify-start gap-2 pl-16 sm:hidden">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
</ItemFooter>
</Item>
{index < globalRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
{index < 4 && <ItemSeparator className="my-1" />}
</React.Fragment>
))}
</ItemGroup>
</div>
</>
)
}

View File

@@ -1,23 +1,24 @@
import * as React from "react"
"use client"
import { Search, X } from "lucide-react"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { Field } from "@/registry/new-york-v4/ui/field"
import { Field } from "@/styles/base-nova/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/registry/new-york-v4/ui/input-group"
export const SearchDirectory = () => {
const { query, registries, setQuery } = useSearchRegistry()
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
}
} from "@/styles/base-nova/ui/input-group"
export function SearchDirectory({
query,
registriesCount,
setQuery,
}: {
query: string
registriesCount: number
setQuery: (value: string | null) => void
}) {
return (
<Field>
<InputGroup>
@@ -25,14 +26,15 @@ export const SearchDirectory = () => {
<Search />
</InputGroupAddon>
<InputGroupInput
className="h-full"
placeholder="Search"
value={query}
onChange={onQueryChange}
onChange={(e) => setQuery(e.target.value)}
/>
<InputGroupAddon align="inline-end">
<span className="text-muted-foreground tabular-nums sm:text-xs">
{registries.length}{" "}
{registries.length === 1 ? "registry" : "registries"}
{registriesCount}{" "}
{registriesCount === 1 ? "registry" : "registries"}
</span>
</InputGroupAddon>
<InputGroupAddon

View File

@@ -5,7 +5,7 @@ description: Use the shadcn CLI to add components to your project.
## init
Use the `init` command to initialize configuration and dependencies for a new project.
Use the `init` command to initialize configuration and dependencies for an existing project, or create a new project with `--name`.
The `init` command installs dependencies, adds the `cn` util and configures CSS variables for the project.
@@ -21,26 +21,26 @@ Usage: shadcn init [options] [components...]
initialize your project and install dependencies
Arguments:
components name, url or local path to component
components names, url or local path to component
Options:
-t, --template <template> the template to use. (next, vite, start, react-router, laravel, astro)
-b, --base <base> the component library to use. (radix, base)
-p, --preset [name] use a preset configuration. (name, URL, or preset code)
-n, --name <name> the name for the new project.
-d, --defaults use default configuration. (default: false)
-p, --preset [name] use a preset configuration
-y, --yes skip confirmation prompt. (default: true)
-d, --defaults use default configuration: --template=next --preset=nova (default: false)
-f, --force force overwrite of existing configuration. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
-n, --name <name> the name for the new project.
-s, --silent mute output. (default: false)
--monorepo scaffold a monorepo project.
--no-monorepo skip the monorepo prompt.
--reinstall re-install existing UI components.
--no-reinstall do not re-install existing UI components.
--rtl enable RTL support.
--no-rtl disable RTL support.
--css-variables use css variables for theming. (default: true)
--no-css-variables do not use css variables for theming.
--monorepo scaffold a monorepo project.
--no-monorepo skip the monorepo prompt.
--rtl enable RTL support.
--no-rtl disable RTL support.
--reinstall re-install existing UI components.
--no-reinstall do not re-install existing UI components.
-h, --help display help for command
```
@@ -85,6 +85,34 @@ Options:
---
## apply
Use the `apply` command to apply a preset to an existing project.
```bash
npx shadcn@latest apply --preset a2r6bw
```
**Options**
```bash
Usage: shadcn apply [options] [preset]
apply a preset to an existing project
Arguments:
preset the preset to apply
Options:
--preset <preset> preset configuration to apply
-y, --yes skip confirmation prompt. (default: false)
-c, --cwd <cwd> the working directory. defaults to the current directory.
-s, --silent mute output. (default: false)
-h, --help display help for command
```
---
## view
Use the `view` command to view items from the registry before installing them.

View File

@@ -3,14 +3,9 @@ title: Registry Directory
description: Discover community registries for shadcn/ui components and blocks.
---
import { TriangleAlertIcon } from "lucide-react"
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
<Callout
type="warning"
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
>
<Callout className="bg-muted font-semibold">
Community registries are maintained by third-party developers. Always review
code on installation to ensure it meets your security and quality standards.
</Callout>

View File

@@ -0,0 +1,21 @@
---
title: April 2026 - shadcn apply
description: Switch presets in existing projects without starting over.
date: 2026-04-08
---
We added `shadcn apply` so you can switch presets in an existing project without starting over.
When you run `npx shadcn@latest apply` in an existing project, we apply a new preset, reinstall your existing components, and update your theme, colors, CSS variables, fonts, and icons.
```bash
npx shadcn@latest apply --preset b2D0vQ7G4
```
The CLI keeps the current base and RTL settings from your existing project, even when the preset URL was generated with different values.
<Button asChild size="sm">
<Link href="/create" className="mt-6 no-underline!">
Try a Preset
</Link>
</Button>

View File

@@ -1,7 +1,10 @@
import { debounce, useQueryState } from "nuqs"
import { debounce, parseAsInteger, useQueryState } from "nuqs"
import { useMounted } from "@/hooks/use-mounted"
import globalRegistries from "@/registry/directory.json"
const PAGE_SIZE = 10
const normalizeQuery = (query: string) =>
query.toLowerCase().replaceAll(" ", "").replaceAll("@", "")
@@ -25,15 +28,44 @@ const searchDirectory = (query: string | null) => {
return globalRegistries.filter((registry) => finderFn(registry, query))
}
export const useSearchRegistry = () => {
export function useSearchRegistry() {
const mounted = useMounted()
const [query, setQuery] = useQueryState("q", {
defaultValue: "",
limitUrlUpdates: debounce(250),
})
const [page, setPage] = useQueryState("page", {
...parseAsInteger,
defaultValue: 1,
history: "push",
})
const currentQuery = mounted ? query : ""
const currentPageValue = mounted ? page : 1
const registries = searchDirectory(currentQuery)
const totalPages = Math.ceil(registries.length / PAGE_SIZE)
// Clamp page to valid range.
const currentPage = Math.max(1, Math.min(currentPageValue, totalPages))
const paginatedRegistries = registries.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
)
return {
query,
registries: searchDirectory(query),
setQuery,
isLoading: !mounted,
query: currentQuery,
setQuery: (value: string | null) => {
setQuery(value)
setPage(null)
},
registries,
paginatedRegistries,
page: currentPage,
totalPages,
setPage,
}
}

View File

@@ -76,7 +76,7 @@
"rehype-pretty-code": "^0.14.1",
"rimraf": "^6.0.1",
"server-only": "^0.0.1",
"shadcn": "4.1.2",
"shadcn": "4.2.0",
"shiki": "^1.10.1",
"sonner": "^2.0.0",
"swr": "^2.3.6",

View File

@@ -1030,5 +1030,11 @@
"homepage": "https://shadcndashboard.dev",
"url": "https://shadcndashboard.dev/r/{name}.json",
"description": "ShadcnDashboard is a collection of modern, production-ready dashboard layouts, components, and UI patterns built on top of shadcn/ui and Tailwind CSS. Its designed to help developers build clean, scalable, and data-driven dashboards faster—without compromising on performance, accessibility, or customization."
},
{
"name": "@termcn",
"homepage": "https://termcn.vercel.app",
"url": "https://termcn.vercel.app/r/{name}.json",
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React."
}
]

View File

@@ -1203,5 +1203,12 @@
"url": "https://shadcndashboard.dev/r/{name}.json",
"description": "ShadcnDashboard is a collection of modern, production-ready dashboard layouts, components, and UI patterns built on top of shadcn/ui and Tailwind CSS. Its designed to help developers build clean, scalable, and data-driven dashboards faster—without compromising on performance, accessibility, or customization.",
"logo": "<svg width='40' height='40' viewBox='0 0 40 40' fill='none' xmlns='http://www.w3.org/2000/svg'><rect width='40' height='40' rx='20' fill='#053B25'/><path d='M32.1327 19.501C31.5178 19.6123 30.8844 19.6705 30.2375 19.6705C24.3886 19.6705 19.6471 14.9189 19.6471 9.05765C19.6471 8.89469 19.6507 8.73258 19.658 8.57141C13.8411 9.10533 9.28516 14.0074 9.28516 19.9759C9.28516 26.301 14.4019 31.4286 20.7137 31.4286C27.0256 31.4286 32.1423 26.301 32.1423 19.9759C32.1423 19.8168 32.1391 19.6585 32.1327 19.501Z' fill='#C2FD75'/></svg>"
},
{
"name": "@termcn",
"homepage": "https://termcn.vercel.app",
"url": "https://termcn.vercel.app/r/{name}.json",
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React.",
"logo": "<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256' viewBox='0 0 256 256'><script/><rect x='16' y='16' width='224' height='224' rx='28' stroke='#000' stroke-width='7'/><path d='m42 56 32 24-32 24' stroke='#fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round' fill='none'/><path d='m208.8 156.8-48 48M199.2 104 108 195.2' stroke='#fff' stroke-width='19.2' stroke-linecap='round' fill='none'/></svg>"
}
]

View File

@@ -1,5 +1,11 @@
# @shadcn/ui
## 4.2.0
### Minor Changes
- [#10313](https://github.com/shadcn-ui/ui/pull/10313) [`c1e29824cd7a6809448e45b6b7fe8f7be71ecb0f`](https://github.com/shadcn-ui/ui/commit/c1e29824cd7a6809448e45b6b7fe8f7be71ecb0f) Thanks [@shadcn](https://github.com/shadcn)! - add shadcn apply command
## 4.1.2
### Patch Changes

View File

@@ -20,6 +20,16 @@ The `init` command installs dependencies, adds the `cn` util, configures Tailwin
npx shadcn init
```
## apply
Use the `apply` command to apply a preset to an existing project.
The `apply` command overwrites the current preset configuration, reinstalls detected UI components, and updates fonts and CSS variables to match the new preset.
```bash
npx shadcn apply --preset a2r6bw
```
## add
Use the `add` command to add components to your project.

View File

@@ -1,6 +1,6 @@
{
"name": "shadcn",
"version": "4.1.2",
"version": "4.2.0",
"description": "Add components to your apps.",
"publishConfig": {
"access": "public"

View File

@@ -0,0 +1,49 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import { resolveApplyInitUrl } from "./apply"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
describe("resolveApplyInitUrl", () => {
it("should include the inferred template for preset codes", () => {
const initUrl = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("preset")).toBe("a0")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include the inferred template for named presets", () => {
const initUrl = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should keep the current base for raw preset URLs without injecting a template", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("template")).toBeNull()
expect(parsed.searchParams.get("track")).toBe("1")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
})

View File

@@ -0,0 +1,309 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightApply } from "@/src/preflights/preflight-apply"
import { decodePreset, isPresetCode } from "@/src/preset/preset"
import {
DEFAULT_PRESETS,
promptToOpenPresetBuilder,
resolveCreateUrl,
resolveInitUrl,
resolveRegistryBaseConfig,
} from "@/src/preset/presets"
import { SHADCN_URL } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { registryConfigSchema } from "@/src/registry/schema"
import { isUrl } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import { withFileBackup } from "@/src/utils/file-helper"
import { getBase } from "@/src/utils/get-config"
import {
getProjectComponents,
getProjectInfo,
} from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import prompts from "prompts"
import { z } from "zod"
export const applyOptionsSchema = z.object({
cwd: z.string(),
positionalPreset: z.string().optional(),
preset: z.string().optional(),
yes: z.boolean(),
silent: z.boolean(),
})
export const apply = new Command()
.name("apply")
.description("apply a preset to an existing project")
.argument("[preset]", "the preset to apply")
.option("--preset <preset>", "preset configuration to apply")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-s, --silent", "mute output.", false)
.action(async (positionalPreset, opts) => {
try {
const options = applyOptionsSchema.parse({
...opts,
cwd: path.resolve(opts.cwd),
positionalPreset,
})
const preset = resolveApplyPreset(options)
const preflight = await preFlightApply(options)
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
logger.break()
logger.error(
`The ${highlighter.info(
"apply"
)} command only works in an existing project.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
if (preflight.errors[ERRORS.MISSING_CONFIG]) {
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found at ${highlighter.info(
options.cwd
)}.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
const existingConfig = preflight.config
if (!existingConfig) {
process.exit(1)
}
const rtl = existingConfig.rtl ?? false
const template = await resolveApplyTemplate(options.cwd)
if (!preset) {
const createUrl = resolveCreateUrl({
command: "init",
template,
base: getBase(existingConfig.style),
rtl,
})
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then run ${highlighter.info(
"shadcn apply --preset <preset>"
)} with the preset code or preset URL from ui.shadcn.com.`,
prompt: !options.yes,
})
process.exit(0)
}
validatePreset(preset)
const reinstallComponents = await getProjectComponents(options.cwd)
if (!options.yes) {
logger.break()
logger.warn(
highlighter.warn(
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
)
)
logger.warn(
`Commit or stash your changes before continuing so you can easily go back.`
)
logger.break()
logger.log(" The following components will be re-installed:")
if (reinstallComponents.length) {
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(` - ${reinstallComponents.slice(i, i + 8).join(", ")}`)
}
} else {
logger.log(" - No installed UI components were detected.")
}
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Would you like to continue?",
initial: false,
})
if (!proceed) {
logger.break()
process.exit(1)
}
}
await loadEnvFiles(options.cwd)
const currentBase = getBase(existingConfig.style)
const initUrl = resolveApplyInitUrl(preset, currentBase, {
template,
rtl,
})
await withFileBackup(
path.resolve(options.cwd, "components.json"),
async () => {
const {
registryBaseConfig,
installStyleIndex,
url: cleanUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd, {
registries: existingConfig.registries as
| z.infer<typeof registryConfigSchema>
| undefined,
})
await runInit({
cwd: options.cwd,
yes: true,
force: false,
reinstall: true,
defaults: false,
silent: options.silent,
isNewProject: false,
cssVariables: true,
installStyleIndex,
registryBaseConfig,
existingConfig,
components: [cleanUrl, ...reinstallComponents],
})
},
{
onBackupFailure: () => {
logger.error(
`Could not back up ${highlighter.info(
"components.json"
)}. Aborting.`
)
},
}
)
logger.break()
logger.log("Preset applied successfully.")
logger.break()
} catch (error) {
logger.break()
handleError(error)
} finally {
clearRegistryContext()
}
})
function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
const positionalPreset = options.positionalPreset?.trim()
const flagPreset = options.preset?.trim()
if (positionalPreset && flagPreset && positionalPreset !== flagPreset) {
logger.error(
`Received two different preset values. Use either the positional preset or ${highlighter.info(
"--preset"
)}, or pass the same value to both.`
)
logger.break()
process.exit(1)
}
return flagPreset ?? positionalPreset
}
function validatePreset(preset: string) {
if (isUrl(preset) || isPresetCode(preset)) {
return
}
const knownPresetNames = Object.keys(DEFAULT_PRESETS)
if (!knownPresetNames.includes(preset)) {
logger.error(
`Invalid preset: ${highlighter.info(
preset
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
)
logger.break()
process.exit(1)
}
}
async function resolveApplyTemplate(cwd: string) {
const projectInfo = await getProjectInfo(cwd)
return getTemplateForFramework(projectInfo?.framework.name)
}
export function resolveApplyInitUrl(
preset: string,
currentBase: "radix" | "base",
options: { template?: string; rtl?: boolean } = {}
) {
if (isUrl(preset)) {
const url = new URL(preset)
if (url.pathname === "/init" && preset.startsWith(SHADCN_URL)) {
url.searchParams.set("track", "1")
}
url.searchParams.set("base", currentBase)
url.searchParams.set("rtl", String(options.rtl ?? false))
return url.toString()
}
if (isPresetCode(preset)) {
const decoded = decodePreset(preset)
if (!decoded) {
logger.error(`Invalid preset code: ${highlighter.info(preset)}`)
logger.break()
process.exit(1)
}
return resolveInitUrl(
{
...decoded,
base: currentBase,
rtl: options.rtl ?? false,
},
{ preset, template: options.template }
)
}
const resolvedPreset = DEFAULT_PRESETS[preset as keyof typeof DEFAULT_PRESETS]
return resolveInitUrl(
{
...resolvedPreset,
base: currentBase,
rtl: options.rtl ?? resolvedPreset.rtl,
},
{ template: options.template }
)
}
function quoteShellArg(value: string) {
return /[^A-Za-z0-9_./:-]/.test(value) ? JSON.stringify(value) : value
}
function getInitCommand(preset?: string) {
if (!preset) {
return "shadcn init"
}
return `shadcn init --preset ${quoteShellArg(preset)}`
}

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { add } from "@/src/commands/add"
import { apply } from "@/src/commands/apply"
import { build } from "@/src/commands/build"
import { diff } from "@/src/commands/diff"
import { docs } from "@/src/commands/docs"
@@ -29,6 +30,7 @@ async function main() {
program
.addCommand(init)
.addCommand(apply)
.addCommand(add)
.addCommand(diff)
.addCommand(docs)

View File

@@ -0,0 +1,70 @@
import path from "path"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
export async function preFlightApply(options: { cwd: string }) {
const errors: Record<string, boolean> = {}
if (
!fs.existsSync(options.cwd) ||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
) {
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
return {
errors,
config: null,
}
}
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("apply --preset <preset>", targets, {
cwdFlag: "-c",
})
process.exit(1)
}
}
errors[ERRORS.MISSING_CONFIG] = true
return {
errors,
config: null,
}
}
try {
const config = await getConfig(options.cwd)
return {
errors,
config: config!,
}
} catch {
logger.break()
logger.error(
`An invalid ${highlighter.info(
"components.json"
)} file was found at ${highlighter.info(
options.cwd
)}.\nBefore you can apply a preset, you must create a valid ${highlighter.info(
"components.json"
)} file by running the ${highlighter.info("init")} command.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
}

View File

@@ -130,6 +130,34 @@ export function resolveCreateUrl(
return url.toString()
}
export async function promptToOpenPresetBuilder(options: {
createUrl: string
followUp: string
prompt?: boolean
}) {
logger.break()
logger.log(
` Build your custom preset on ${highlighter.info(options.createUrl)}`
)
logger.log(` ${options.followUp}`)
logger.break()
if (options.prompt === false) {
return
}
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Open in browser?",
initial: true,
})
if (proceed) {
await open(options.createUrl)
}
}
export function resolveInitUrl(
preset: {
base: string
@@ -234,26 +262,13 @@ export async function promptForPreset(options: {
base: options.base,
...(options.template && { template: options.template }),
})
logger.break()
logger.log(` Build your custom preset on ${highlighter.info(createUrl)}`)
logger.log(
` Then ${highlighter.info(
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then ${highlighter.info(
"copy and run the command"
)} from ui.shadcn.com.`
)
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Open in browser?",
initial: true,
)} from ui.shadcn.com.`,
})
if (proceed) {
await open(createUrl)
}
process.exit(0)
}

View File

@@ -0,0 +1,70 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { afterEach, describe, expect, it, vi } from "vitest"
import { FILE_BACKUP_SUFFIX, withFileBackup } from "./file-helper"
const tempDirs: string[] = []
async function createTempFile() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-file-helper-"))
tempDirs.push(dir)
const filePath = path.join(dir, "components.json")
await fs.writeFile(filePath, '{"style":"before"}\n', "utf8")
return filePath
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.remove(dir)))
})
describe("withFileBackup", () => {
it("should restore the original file when the task throws", async () => {
const filePath = await createTempFile()
await expect(
withFileBackup(filePath, async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
throw new Error("boom")
})
).rejects.toThrow("boom")
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"before"}\n')
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
})
it("should remove the backup after a successful task", async () => {
const filePath = await createTempFile()
await withFileBackup(filePath, async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
})
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"after"}\n')
expect(await fs.pathExists(`${filePath}${FILE_BACKUP_SUFFIX}`)).toBe(false)
})
it("should abort when backup creation fails", async () => {
const filePath = await createTempFile()
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {})
const renameSyncSpy = vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw new Error("boom")
})
await expect(
withFileBackup(filePath, async () => {
await fs.writeFile(filePath, '{"style":"after"}\n', "utf8")
})
).rejects.toThrow(`Could not back up ${filePath}.`)
expect(await fs.readFile(filePath, "utf8")).toBe('{"style":"before"}\n')
renameSyncSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
})

View File

@@ -2,6 +2,10 @@ import fsExtra from "fs-extra"
export const FILE_BACKUP_SUFFIX = ".bak"
type WithFileBackupOptions = {
onBackupFailure?: (filePath: string) => void
}
export function createFileBackup(filePath: string): string | null {
if (!fsExtra.existsSync(filePath)) {
return null
@@ -45,8 +49,40 @@ export function deleteFileBackup(filePath: string): boolean {
try {
fsExtra.unlinkSync(backupPath)
return true
} catch (error) {
} catch {
// Best effort - don't log as this is just cleanup
return false
}
}
export async function withFileBackup<T>(
filePath: string,
task: () => Promise<T>,
options: WithFileBackupOptions = {}
) {
if (!fsExtra.existsSync(filePath)) {
return task()
}
const backupPath = createFileBackup(filePath)
if (!backupPath) {
options.onBackupFailure?.(filePath)
throw new Error(`Could not back up ${filePath}.`)
}
const restoreBackupOnExit = () => restoreFileBackup(filePath)
process.on("exit", restoreBackupOnExit)
try {
const result = await task()
process.removeListener("exit", restoreBackupOnExit)
deleteFileBackup(filePath)
return result
} catch (error) {
process.removeListener("exit", restoreBackupOnExit)
restoreFileBackup(filePath)
throw error
}
}

View File

@@ -100,8 +100,13 @@ export async function getMonorepoTargets(cwd: string) {
// Formats and logs the monorepo detection message.
export function formatMonorepoMessage(
command: string,
targets: { name: string; hasConfig: boolean }[]
targets: { name: string; hasConfig: boolean }[],
options?: {
cwdFlag?: string
}
) {
const cwdFlag = options?.cwdFlag ?? "-c"
logger.break()
logger.log(
`It looks like you are running ${highlighter.info(
@@ -110,13 +115,13 @@ export function formatMonorepoMessage(
)
logger.log(
`To use shadcn in a specific workspace, use the ${highlighter.info(
"-c"
cwdFlag
)} flag:`
)
logger.break()
for (const target of targets) {
logger.log(` shadcn ${command} -c ${target.name}`)
logger.log(` shadcn ${command} ${cwdFlag} ${target.name}`)
}
logger.break()

View File

@@ -0,0 +1,408 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import {
createFixtureTestDirectory,
getRegistryUrl,
npxShadcn,
} from "../utils/helpers"
async function createInitializedProject() {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults"])
await npxShadcn(fixturePath, ["add", "button"])
return fixturePath
}
async function createInitializedRtlProject() {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--defaults", "--rtl"])
await npxShadcn(fixturePath, ["add", "button"])
return fixturePath
}
async function createInitializedViteRtlProject() {
const fixturePath = await createFixtureTestDirectory("vite-app")
await npxShadcn(fixturePath, ["init", "--defaults", "--rtl"])
await npxShadcn(
fixturePath,
["add", "breadcrumb", "pagination", "sidebar", "-y"],
{
timeout: 120000,
}
)
return fixturePath
}
async function createInitializedRadixProject() {
const fixturePath = await createFixtureTestDirectory("next-app")
await npxShadcn(fixturePath, ["init", "--preset", "a0", "--base", "radix"])
await npxShadcn(fixturePath, ["add", "button"])
return fixturePath
}
describe("shadcn apply", () => {
it("should apply a preset with --preset <code>", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"a0",
"-y",
])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Preset applied successfully")
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
it("should apply a preset with positional <code>", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, ["apply", "a0", "-y"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Preset applied successfully")
})
it("should allow the same positional and flag preset values", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"a0",
"--preset",
"a0",
"-y",
])
expect(result.exitCode).toBe(0)
})
it("should reject different positional and flag preset values", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"a0",
"--preset",
"b0",
"-y",
])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Received two different preset values")
})
it("should offer the preset builder when no preset is provided", async () => {
const fixturePath = await createInitializedProject()
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
const createUrl = new URL(
`${getRegistryUrl().replace(/\/r\/?$/, "")}/create`
)
createUrl.searchParams.set("command", "init")
createUrl.searchParams.set("template", "next")
createUrl.searchParams.set(
"base",
componentsJson.style.startsWith("base-") ? "base" : "radix"
)
const result = await npxShadcn(fixturePath, ["apply"], {
input: "n\n",
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Build your custom preset on")
expect(result.stdout).toContain(createUrl.toString())
expect(result.stdout).toContain("shadcn apply --preset <preset>")
})
it("should print the preset builder url without prompting when no preset is provided with -y", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, ["apply", "-y"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("Build your custom preset on")
expect(result.stdout).not.toContain("Open in browser?")
})
it("should warn before applying and list detected components", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"], {
input: "y\n",
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain(
"Applying a new preset will overwrite existing UI components, fonts, and CSS variables."
)
expect(result.stdout).toContain(
"Commit or stash your changes before continuing so you can easily go back."
)
expect(result.stdout).toContain(
"The following components will be re-installed:"
)
expect(result.stdout).toContain("button")
})
it("should skip confirmation with -y", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"a0",
"-y",
])
expect(result.exitCode).toBe(0)
expect(result.stdout).not.toContain("Would you like to continue?")
})
it("should suggest init when components.json is missing", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("components.json")
expect(result.stdout).toContain("shadcn init --preset a0")
})
it("should not show undefined in init guidance when components.json is missing and no preset is provided", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
const result = await npxShadcn(fixturePath, ["apply"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("components.json")
expect(result.stdout).toContain("shadcn init")
expect(result.stdout).not.toContain("undefined")
})
it("should fail on invalid components.json", async () => {
const fixturePath = await createFixtureTestDirectory("next-app")
await fs.writeFile(path.join(fixturePath, "components.json"), "{", "utf8")
const result = await npxShadcn(fixturePath, ["apply", "--preset", "a0"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("An invalid components.json file was found")
})
it("should restore components.json when applying a preset fails", async () => {
const fixturePath = await createInitializedProject()
const componentsJsonPath = path.join(fixturePath, "components.json")
const original = await fs.readFile(componentsJsonPath, "utf8")
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
`${getRegistryUrl()}/does-not-exist.json`,
"-y",
])
expect(result.exitCode).toBe(1)
expect(await fs.readFile(componentsJsonPath, "utf8")).toBe(original)
expect(await fs.pathExists(`${componentsJsonPath}.bak`)).toBe(false)
})
it("should guide the user to a workspace when run from a monorepo root", async () => {
const rootDir = path.join(
os.tmpdir(),
`shadcn-apply-monorepo-${process.pid}-${Date.now()}`
)
await fs.ensureDir(path.join(rootDir, "apps/web"))
await fs.writeJson(path.join(rootDir, "package.json"), {
private: true,
workspaces: ["apps/*"],
})
await fs.writeFile(
path.join(rootDir, "pnpm-workspace.yaml"),
'packages:\n - "apps/*"\n',
"utf8"
)
await fs.writeJson(path.join(rootDir, "apps/web/package.json"), {
name: "web",
version: "0.0.0",
})
await fs.writeFile(
path.join(rootDir, "apps/web/next.config.ts"),
"",
"utf8"
)
const result = await npxShadcn(rootDir, ["apply", "--preset", "a0"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("monorepo root")
expect(result.stdout).toContain(
"shadcn apply --preset <preset> -c apps/web"
)
await fs.remove(rootDir)
})
it("should update the project config and reinstall detected components", async () => {
const fixturePath = await createInitializedProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"lyra",
"-y",
])
expect(result.exitCode).toBe(0)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("base-lyra")
expect(componentsJson.iconLibrary).toBe("phosphor")
expect(
await fs.pathExists(path.join(fixturePath, "components/ui/button.tsx"))
).toBe(true)
})
it("should preserve rtl when applying a named preset", async () => {
const fixturePath = await createInitializedRtlProject()
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"lyra",
"-y",
])
expect(result.exitCode).toBe(0)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.rtl).toBe(true)
})
it("should preserve the current base for raw preset urls", async () => {
const fixturePath = await createInitializedRadixProject()
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
presetUrl,
"-y",
])
expect(result.exitCode).toBe(0)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.style).toBe("radix-lyra")
})
it("should preserve rtl for raw preset urls", async () => {
const fixturePath = await createInitializedRtlProject()
const presetUrl = `${getRegistryUrl().replace(/\/r\/?$/, "")}/init?base=base&style=lyra&baseColor=neutral&theme=neutral&iconLibrary=phosphor&font=manrope&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
presetUrl,
"-y",
])
expect(result.exitCode).toBe(0)
const componentsJson = await fs.readJson(
path.join(fixturePath, "components.json")
)
expect(componentsJson.rtl).toBe(true)
expect(componentsJson.style).toBe("base-lyra")
})
it("should preserve non-preset config when applying a preset", async () => {
const fixturePath = await createInitializedProject()
const componentsJsonPath = path.join(fixturePath, "components.json")
const componentsJson = await fs.readJson(componentsJsonPath)
componentsJson.tailwind.prefix = "tw-"
await fs.writeJson(componentsJsonPath, componentsJson, { spaces: 2 })
const result = await npxShadcn(fixturePath, [
"apply",
"--preset",
"lyra",
"-y",
])
expect(result.exitCode).toBe(0)
const updatedConfig = await fs.readJson(componentsJsonPath)
expect(updatedConfig.tailwind.prefix).toBe("tw-")
expect(updatedConfig.style).toBe("base-lyra")
expect(updatedConfig.iconLibrary).toBe("phosphor")
})
it("should keep vite component output rtl-aware when applying a new preset", async () => {
const fixturePath = await createInitializedViteRtlProject()
const componentsJsonPath = path.join(fixturePath, "components.json")
const breadcrumbPath = path.join(
fixturePath,
"src/components/ui/breadcrumb.tsx"
)
const paginationPath = path.join(
fixturePath,
"src/components/ui/pagination.tsx"
)
const sidebarPath = path.join(fixturePath, "src/components/ui/sidebar.tsx")
const initialConfig = await fs.readJson(componentsJsonPath)
const initialBreadcrumb = await fs.readFile(breadcrumbPath, "utf8")
const initialPagination = await fs.readFile(paginationPath, "utf8")
const initialSidebar = await fs.readFile(sidebarPath, "utf8")
expect(initialConfig.style).toBe("base-nova")
expect(initialConfig.iconLibrary).toBe("lucide")
expect(initialConfig.rtl).toBe(true)
expect(initialBreadcrumb).toContain("rtl:rotate-180")
expect(initialPagination).toContain("ps-1.5!")
expect(initialPagination).toContain("pe-1.5!")
expect(initialPagination).toContain("rtl:rotate-180")
expect(initialSidebar).toContain("start-")
expect(initialSidebar).toContain("end-")
expect(initialSidebar).toContain("ms-")
expect(initialSidebar).toContain("pe-")
expect(initialSidebar).toContain("rtl:")
const result = await npxShadcn(
fixturePath,
["apply", "--preset", "lyra", "-y"],
{
timeout: 120000,
}
)
expect(result.exitCode).toBe(0)
const updatedConfig = await fs.readJson(componentsJsonPath)
const updatedBreadcrumb = await fs.readFile(breadcrumbPath, "utf8")
const updatedPagination = await fs.readFile(paginationPath, "utf8")
const updatedSidebar = await fs.readFile(sidebarPath, "utf8")
expect(updatedConfig.style).toBe("base-lyra")
expect(updatedConfig.iconLibrary).toBe("phosphor")
expect(updatedConfig.rtl).toBe(true)
expect(updatedBreadcrumb).toContain("rtl:rotate-180")
expect(updatedPagination).toContain("ps-1.5!")
expect(updatedPagination).toContain("pe-1.5!")
expect(updatedPagination).toContain("rtl:rotate-180")
expect(updatedSidebar).toContain("start-")
expect(updatedSidebar).toContain("end-")
expect(updatedSidebar).toContain("ms-")
expect(updatedSidebar).toContain("pe-")
expect(updatedSidebar).toContain("rtl:")
expect(updatedBreadcrumb).not.toBe(initialBreadcrumb)
expect(updatedPagination).not.toBe(initialPagination)
expect(updatedSidebar).not.toBe(initialSidebar)
})
})

View File

@@ -71,9 +71,11 @@ export async function npxShadcn(
args: string[],
{
debug = false,
input,
timeout,
}: {
debug?: boolean
input?: string
timeout?: number
} = {}
) {
@@ -82,6 +84,7 @@ export async function npxShadcn(
REGISTRY_URL: getRegistryUrl(),
SHADCN_TEMPLATE_DIR: TEMPLATES_DIR,
},
input,
timeout,
})

2
pnpm-lock.yaml generated
View File

@@ -284,7 +284,7 @@ importers:
specifier: ^0.0.1
version: 0.0.1
shadcn:
specifier: 4.1.2
specifier: 4.2.0
version: link:../../packages/shadcn
shiki:
specifier: ^1.10.1

View File

@@ -77,7 +77,7 @@ These rules are **always enforced**. Each links to a file with Incorrect/Correct
### CLI
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest apply --preset <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
## Key Patterns
@@ -173,11 +173,11 @@ npx shadcn@latest docs button dialog select
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
9. **Switching presets** — Ask the user first: **overwrite**, **merge**, or **skip**?
- **Overwrite**: `npx shadcn@latest apply --preset <code>`. Overwrites detected components, fonts, and CSS variables.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
## Updating Components
@@ -204,7 +204,11 @@ npx shadcn@latest init --name my-app --preset base-nova --template next --monore
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
# Apply a preset to an existing project.
npx shadcn@latest apply --preset a2r6bw
npx shadcn@latest apply a2r6bw
# Add components.
npx shadcn@latest add button card dialog
@@ -227,9 +231,9 @@ npx shadcn@latest docs button dialog select
npx shadcn@latest view @shadcn/button
```
**Named presets:** `base-nova`, `radix-nova`
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References

View File

@@ -8,7 +8,7 @@ Configuration is read from `components.json`.
## Contents
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
@@ -42,6 +42,24 @@ Initializes shadcn/ui in an existing project or creates a new project (when `--n
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `apply` — Apply a preset to an existing project
```bash
npx shadcn@latest apply [preset] [options]
```
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------ | ------- |
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--silent` | `-s` | Mute output | `false` |
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
@@ -240,18 +258,19 @@ All templates support monorepo scaffolding via the `--monorepo` flag. When passe
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset base-nova` or `--preset radix-nova`
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
1. **Named:** `--preset nova` or `--preset lyra`
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
## Switching Presets
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.

View File

@@ -65,14 +65,19 @@ import { ThemeProvider } from "next-themes"
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest init --preset a2r6bw --force
npx shadcn@latest apply --preset a2r6bw
# Switch to a named preset.
npx shadcn@latest init --preset radix-nova --force
npx shadcn@latest init --reinstall # update existing components to match
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset and overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
@@ -142,13 +147,15 @@ Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">Click</Button>
<Button variant="outline" size="sm">
Click
</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="max-w-md mx-auto">...</Card>
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant