@zukhruf/shadcn
v0.7.0
Published
Every shadcn/ui component for Base UI with the nova preset, as one package: import the components from one entry and the styles from one stylesheet.
Maintainers
Readme
@zukhruf/shadcn
Each shadcn/ui component, as one package. The components use Base UI as their headless library and nova as their preset. An app imports the components from one entry and the styles from one stylesheet. The app sets its theme with CSS variables.
The words in these documents have one meaning each. See the glossary in CONTEXT.md.
The problem
shadcn/ui is not a package. Its CLI copies the source of each component into the repo of the app. Thus each app keeps its own copy, and the copies drift apart. A fix in one copy does not go into the other copies. For example, two apps each wrote the same fix for a tall dialog, and other apps still do not have it. Also, shadcn add --overwrite writes over a copy, and it removes the changes that an app made by hand.
This package keeps one copy. A fix goes into the package one time, and each app gets it with the next version.
Install
npm i @zukhruf/shadcnThe peer dependencies are react 19, react-dom 19 and tailwindcss 4. The other dependencies, for example @base-ui/react and lucide-react, install with the package.
Use it
Import the stylesheet in the Tailwind CSS file of the app, after Tailwind:
@import 'tailwindcss';
@import '@zukhruf/shadcn/styles.css';Import the components from the entry:
import { Button, Dialog, DialogContent } from '@zukhruf/shadcn';Obey two rules:
- Use a plain
@import. Do not write@import '@zukhruf/shadcn/styles.css' layer(components);. Alayer()puts all of the stylesheet into a layer, also its@utilityand@sourcerules. Tailwind CSS refuses these rules in a layer. The build stops with the error "@utilitycannot be nested." - Do not add an
@sourcefor the package. The stylesheet registers its owndistfolder. Tailwind CSS then finds the classes of the components. See ADR 0003.
Theme
The theme is the values of a set of CSS variables, for example --primary, --background and --radius. To change the theme, set the variables after the import:
@import 'tailwindcss';
@import '@zukhruf/shadcn/styles.css';
:root {
--primary: oklch(0.55 0.2 260);
--radius: 0.5rem;
}
.dark {
--primary: oklch(0.7 0.15 260);
}- The package sets the default theme in
@layer base. A rule outside of a layer wins over it. A rule in@layer basethat comes after the import also wins over it. Thus a host theme in a layer, for example the ChatGPT tokens of OpenAI, can set the variables. A rule in@layer themeloses, because Tailwind CSS declaresthemebeforebase. See ADR 0005. - The
.darkclass switches to the dark theme. Put it on an element, usually<html>. The element and all elements in it then use the.darkvariables.
The preset is fixed. The class strings of each component contain the preset, for example the rounded corners and the spacing of the dialog. Thus an app cannot change the preset. An app changes only the theme. See ADR 0004.
The open state of the sidebar
SidebarProvider keeps its open state in a cookie. SIDEBAR_COOKIE_NAME is the name of that cookie. Use it to read the open state of the sidebar on the server, and give the value to defaultOpen:
import { SIDEBAR_COOKIE_NAME, SidebarProvider } from '@zukhruf/shadcn';
const defaultOpen = cookies.get(SIDEBAR_COOKIE_NAME)?.value !== 'false';
<SidebarProvider defaultOpen={defaultOpen}>{children}</SidebarProvider>;Composition
Base UI uses the render prop where Radix UI uses asChild. The render prop sets the element that the component renders.
Radix UI:
<DialogTrigger asChild>
<Button variant="outline">Open</Button>
</DialogTrigger>Base UI:
<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>What the stylesheet adds to stock shadcn/ui
The stylesheet has three patches:
- A pointer cursor on enabled buttons. In Tailwind CSS 4, a button has the default cursor of the browser, not the pointer. The upgrade guide of Tailwind CSS gives a rule that sets the pointer again. The patch is that rule, in
@layer base. - A cap on the height of a dialog. A dialog is never taller than the window minus
2rem, and it scrolls when its content is taller. Thus the buttons at the bottom of a tall dialog stay on the screen. The rule ismax-height: calc(100dvh - 2rem); overflow-y: autoon[data-slot="dialog-content"]. The patch is in@layer components. - The width of the mobile sidebar. On a small screen, the sidebar is in a
Sheet. The classdata-[side=left]:w-3/4of theSheetwins over the classw-(--sidebar-width)of the sidebar. Thus the sidebar was 3/4 of the window: 292.5px, not 288px, in a window of 390px. The patch setswidth: var(--sidebar-width)on[data-slot="sidebar"][data-mobile="true"][data-side].
The cursor and the cap are in layers before utilities. Thus a utility class of the app, for example cursor-wait or max-h-none, still changes them.
The width patch is in @layer utilities, because only a rule in that layer wins over a utility class. Its selector has three attributes, so it wins over the two parts of data-[side=left]:w-3/4. The sidebar does not give its className to the mobile Sheet, so a utility class of the app cannot get to that element. To change the mobile width, write a rule outside of a layer.
Links that look like buttons
A control that goes to a different page must stay a link. Do not write <Button render={<Link />} nativeButton={false}>. This makes an <a> with role="button". A screen reader then calls the link a button, and the Space key activates it.
Put the classes of the button on the link:
import { cn } from 'cn';
import { buttonVariants } from '@zukhruf/shadcn';
<Link
href="/settings"
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }))}
>
Settings
</Link>;Use cn around buttonVariants. Button merges the classes that conflict through cn, which uses tailwind-merge. Without cn, the classes that conflict stay on the link, and the link looks different from a button. In one app, the link had a radius of 10px, not 8px, and it was 2px wider than the button.
Use the components well
Use a component of the package first. Before you write a new component, look for one in this package. Use it when it does the job.
Keep your own components in the app. A component that you make from the components of this package belongs to the app. Do not add it to this package. The sync writes over the generated source, and the package holds only the components of shadcn/ui.
Remove the outline of a
Cardwithring-0.Carddraws its outline withring-1, not with a border. Thusborder-0does not remove the outline.Use the standard names of the theme variables. Set
--primary,--backgroundand the other variables of the theme. Do not add a brand prefix, for example--acme-primary. The components read only the standard names.Put a
DropdownMenuLabelin aDropdownMenuGroup.DropdownMenuLabelusesMenu.GroupLabelof Base UI, and that component reads the context of a group. A label outside a group throws Base UI error #31, and the full menu stops. Radix UI accepted a label outside a group, thus an app that moves from Radix UI must find these labels. text2sql found one when it moved.<DropdownMenuContent> <DropdownMenuGroup> <DropdownMenuLabel>Account</DropdownMenuLabel> <DropdownMenuItem>Profile</DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent>Show an error at the place where it occurs. Show a form error or a field error next to the field or the action that failed, for example with
FieldError. Show the error of a background task or of a mutation as a toast. UsetoastandToasterof this package, which use the toast of Base UI.
Your own components
An app that writes its own components often needs cn, the function that joins class names. Import it from the cn package:
import { cn } from 'cn';This package does not export cn. Each symbol has one home (zukhruf ADR 0001, rule 6).
One entry
The package has one entry, @zukhruf/shadcn. It exports all components, all hooks and the hand-written modules, for example SIDEBAR_COOKIE_NAME. There are no subpaths such as @zukhruf/shadcn/button.
- A development server and plain Node.js load all components and their dependencies.
- A production bundle has only the components that the app uses. The
sideEffectsfield ofpackage.jsonlets the bundler remove the others. - The bundler resolves each import of the entry before it removes code. Thus the app needs the peers of all dependencies, also when it does not use the component. The package lists
react-is, a peer ofrecharts, as its own dependency. Then an app builds also when npm does not install peers, for example withlegacy-peer-deps=true. - The stylesheet gives the classes of all components, also of the components that the app does not use. An app that uses only
ButtonandDialoggot 212 kB of CSS (31 kB with gzip) on 2026-10-15.
See ADR 0002.
Documentation
Decisions: the architecture decision records tell why the design is as it is.
- The shadcn CLI writes the source, and the commit pins it
- One entry, and private imports between the components
- The stylesheet registers its own source and keeps its patches in layers
- The components use Base UI and the nova preset
- The default theme is in
@layer base
Development
This package is part of the zukhruf workspace. Run the commands from the workspace root.
npm install
npx nx run shadcn:sync # writes the generated source again with the shadcn CLI
npx nx run shadcn:test # builds, then runs the tests
npx nx run shadcn:typecheck # formats, lints, then type checks
npx nx run shadcn:build # compiles src/ to dist/src/
components/ generated: one file for each component
hooks/ generated: the hooks that the components use
lib/ generated: the helpers that the components use
index.ts generated: the entry
hand-written/ written by hand: the exports that the generated source does not give
theme.css generated: the CSS variables and the dark variant; imports shadcn/tailwind.css
styles.css written by hand: imports theme.css, registers dist, holds the patches
components.json the configuration of the shadcn CLIThe generated source
The shadcn CLI writes src/components, src/hooks, src/lib, src/index.ts and theme.css. Do not edit these files. The next sync writes over them, and the edit is lost. See ADR 0001.
- The lint rules and the TypeScript flags that the stock code fails are off. These exceptions apply only to the generated source.
- To change what a component does, add a patch to
styles.css, in a layer.
Sync
nx run shadcn:sync writes the generated source again. It uses [email protected] and the live registry of shadcn/ui. The registry has no versions, so a sync on a different day can write different source. Read the diff before a commit.
The sync does these steps, in this order:
- It resets
theme.cssto one line:@import "tw-animate-css";. - It runs
shadcn add index, thenshadcn add --all. - It puts the
:rootrule and the.darkrule oftheme.cssinto@layer base. The CLI writes them outside of a layer. - It writes
src/index.ts: oneexport *line for each component, each hook and each hand-written module. - It formats the generated source with Prettier.
Known facts about the sync:
theme.csskeeps the line@import "shadcn/tailwind.css";. That file holds the variants and the animations of the components, for exampledata-open. The sync does not copy it. Theshadcnpackage is a dependency of this package, so npm installs the file with it, and a new version ofshadcngets to each app through npm.srcdoes not importdate-fns, butdate-fnsstays a dependency. Thecalendaritem of the registry declares it.- One sync in four failed one time, and the cause is not known. A second run passed.
