How to build and maintain shadcn UI + Tailwind v4 web packages in the OwlMeans framework. Auto-invoked when editing components.json, tailwind.config.*, globals.css, or files under components/ui/. Use when creating a new shadcn-based web package or adding shadcn components.
Scanned 9/22/2026
Install to Claude Code
npx -y skills add owlmeans/common --skill shadcn-web --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Shadcn Web?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/owlmeans-shadcn-web)More formats (shields.io, HTML) on the badges page.
---
name: shadcn-web
description: How to build and maintain shadcn UI + Tailwind v4 web packages in the OwlMeans framework. Auto-invoked when editing components.json, tailwind.config.*, globals.css, or files under components/ui/. Use when creating a new shadcn-based web package or adding shadcn components.
user-invocable: false
metadata:
scope: general
---
# shadcn UI + Tailwind v4 web packages — OwlMeans pattern
Shadcn-based web packages are the **current Web layer**. `@owlmeans/web-panel` is the reference one, and `@owlmeans/mui-panel` is the legacy MUI family it supersedes. Both sit at the same architecture layer (Web) and wrap the same headless `@owlmeans/client-panel` logic; the shadcn family swaps MUI + Emotion for shadcn UI + Tailwind CSS v4, and a new package follows `web-panel`'s shape.
See `reference.md` in this skill folder for full code examples (components.json, tsconfig, globals.css @theme tokens, peerDeps, cn(), MUI→shadcn mapping table).
## Package-boundary rule
Four invariants govern every shadcn-based OwlMeans package:
1. **No shadcn registries.** Primitives (Button, Input, Card, …) are **hand-copied** into `src/@/components/ui/` and committed. The `registries` field in `components.json` is always empty/absent.
2. **Package-local relative imports.** Components import their private shadcn primitives and utilities with relative specifiers. The emitted `build/` tree must contain no absolute `@/…` import: that alias belongs to the consumer and makes a clean installation depend on files it does not own.
3. **Private copy, public facade.** Each package keeps its own copy under `src/@/`, emits it under `build/@/`, and does not expose it through `exports`. Consumers import the package's public `cn` function rather than any private helper.
4. **The consumer must add an `@source` for the package's `src`.** Tailwind's oxide scanner reads
the CSS root plus `@source` directives only, and it excludes `node_modules` — so a class that
exists **only** inside the package's own components never reaches the app's stylesheet, and the
feature renders unstyled with nothing in the app's own sources to blame. Every app consuming
`@owlmeans/web-panel` (or any other shadcn OwlMeans package) adds a line to its Tailwind entry:
```css
@import "tailwindcss";
@source "../../../node_modules/@owlmeans/web-panel/src";
```
The relative depth follows the app's layout. Source ships in the package tarball and remains
tracked in a linked workspace. This is a **general consumer rule**, not a scaffolding detail.
## Package skeleton (mirrors `web-panel`)
```
<your-package>/
├── src/
│ ├── @/ # private primitives — NOT a public export
│ │ ├── components/ui/ # hand-copied shadcn primitives
│ │ │ ├── button.tsx
│ │ │ └── input.tsx
│ │ ├── lib/
│ │ │ └── utils.ts # cn() utility
│ │ └── globals.css # Tailwind v4 entry (app provides its own in prod)
│ ├── components/ # OwlMeans framework components (wrap client-panel)
│ │ ├── form/
│ │ │ ├── component.tsx
│ │ │ ├── text/component.tsx
│ │ │ ├── button/component.tsx
│ │ │ ├── types.ts
│ │ │ └── index.ts
│ │ ├── layout/component.tsx
│ │ ├── panel-app/component.tsx
│ │ └── index.ts # local component barrel only
│ ├── context.ts # makeContext wrapping web-client's makeContext
│ ├── main.tsx # render() — no theme arg (theme is pure CSS)
│ ├── types.ts
│ ├── entrypoints.ts # the base declaration list an app composes over
│ ├── utils.ts # the package-owned `cn`, exported publicly
│ └── index.ts # package barrel; also re-exports @owlmeans/client-panel
├── tests/
│ ├── harness/
│ │ ├── index.html # copy from @owlmeans/test-ui harness/index.html
│ │ └── mount.tsx # per-package: imports globals.css, registers components
│ ├── context.ts # boots Vite with @tailwindcss/vite + @ alias
│ └── *.spec.ts
├── components.json
├── package.json
└── tsconfig.json
```
## tsconfig setup
```json
{
"extends": [
"@owlmeans/dep-config/tsconfig.base.json",
"@owlmeans/dep-config/tsconfig.react.json"
],
"compilerOptions": {
"paths": { "@/*": ["./src/@/*"] },
"rootDir": "./src/",
"outDir": "./build/"
},
"exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
}
```
`paths` supports shadcn tooling while copying primitives, but package implementation imports are
rewritten to relative specifiers before build. Check emitted JavaScript too: no `from '@/…'` or
`from "@/…"` may remain.
## components.json
```json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/@/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
```
`tailwind.config: ""` is required for Tailwind v4 (no separate config file). Do not add a `registries` field.
## Tailwind v4 wiring
`src/@/globals.css` — the Tailwind entry used in dev/test only:
```css
@import "tailwindcss";
@source "../..";
@theme {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-primary: oklch(0.205 0 0);
--color-primary-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.577 0.245 27.325);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
```
`@source "../.."` scans the package root. In the Vite dev/test harness, `@tailwindcss/vite` processes this CSS. The **production app owns its own `globals.css`** with its own `@theme` tokens — the package's CSS is only for tests.
## Adding a primitive without a registry
1. Find the component source on the [shadcn GitHub](https://github.com/shadcn-ui/ui) (e.g. `registry/new-york/ui/<name>.tsx`) or via `npx shadcn@latest add <name> --cwd <your-package-dir>` in a throwaway branch.
2. Copy the `.tsx` source into `src/@/components/ui/<name>.tsx`.
3. Repoint all imports to relative paths into `src/@/lib` and `src/@/components/ui`.
4. Add any `@radix-ui/*` packages the file imports as **peerDependencies** in `package.json`.
5. Add a comment at the top: `// shadcn <name> — sourced from shadcn@<version> <date>`.
6. Run `bun install` and `bun run build` to verify.
7. Document the package's peer dependencies. Consumers never vendor its private primitives;
`@owlmeans/web-panel` currently needs the Radix peers declared in its manifest.
Prefer a light custom component over a heavyweight block when only part of it is needed. The
two-level navigation shell deliberately renders its second level with the existing `Button` rather
than pulling in the shadcn `sidebar` block: that block drags in `sheet`, `tooltip`, `skeleton` and
`use-mobile`, plus eight `--color-sidebar-*` tokens every consumer theme would then have to define.
## Wrapping `@owlmeans/client-panel`
Shadcn form/field components are the rendering layer over the framework-agnostic headless logic
from `@owlmeans/client-panel`: `FormContext`, `schemaToFormDefault`, `useClientFormContext`,
`useFormI18n` and `useFormError` come from there, while the field itself drives `react-hook-form`'s
`Controller` and resolves its own label from the form namespace. `@owlmeans/client-panel` also
publishes ready headless controllers — `ClientForm`, `InputCtrl`, `ActionCtrl` — but the reference
package does **not** route through them; an application may still use them directly to lay a form
out its own way. The same headless logic is rendered by the MUI family in `@owlmeans/mui-panel`:
```tsx
// MUI version (mui-panel) — for reference
import { TextField } from '@mui/material'
// ...
<TextField {...field} label={label} error={fieldState.error != null} />
// shadcn package implementation (from src/components/form/text/component.tsx)
import { Input } from '../../../@/components/ui/input.js'
import { Label } from '../../../@/components/ui/label.js'
// ...
<div className="flex flex-col gap-1.5">
<Label htmlFor={field.name}>{label}</Label>
<Input id={field.name} {...field} aria-invalid={fieldState.error != null} />
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</div>
```
Both families share the same machinery: `FormProvider`, `useFormContext` and `Controller` from
`react-hook-form`, the AJV validation resolver, and `useFormI18n` from `@owlmeans/client-panel`.
Only the rendered JSX differs. (`@owlmeans/client-i18n` publishes `useI18n`, `useI18nLib`,
`useI18nApp`, `useLanguage`, `composePrefix` and `I18nContext` — the *form*-scoped `t` is
`client-panel`'s.)
`panel-app/component.tsx` needs no MUI `ThemeProvider` and no `CssBaseline` — it renders children, Tailwind's Preflight replaces the baseline, and the theme comes from the app's own `globals.css`.
## package.json exports
```json
{
"exports": {
".": {
"import": "./build/index.js",
"types": "./build/index.d.ts"
},
"./auth": {
"import": "./build/auth/index.js",
"types": "./build/auth/index.d.ts"
}
}
}
```
Do **not** add `./@/*` or `./src/@/*` to exports.
## Dev/test harness (Vite + @tailwindcss/vite)
See `[[testing-ui]]` for the full Playwright harness pattern. For shadcn packages the `tests/context.ts` Vite config adds the Tailwind plugin and the `@` alias:
```ts
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'node:path'
const server = await createServer({
configFile: false,
root: resolve(here, './harness'),
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': resolve(here, '../src/@') },
},
server: { port: 0 },
})
```
`tests/harness/mount.tsx` imports the package globals.css so components render with Tailwind styles:
```tsx
import '../../src/@/globals.css'
// ... dynamic component mounting ...
```
## Cross-references
- `[[client-panel]]` — headless form/layout logic being wrapped
- `[[web-panel]]` — the reference shadcn package: the structural and API model to copy
- `[[mui-panel]]` — the legacy MUI family this one supersedes; the source side of the mapping table
- `[[testing-ui]]` — Playwright harness for component acceptance tests
- `[[shadcn-versions]]` — bumping tailwind/shadcn external deps across packages
- `[[tsconfig]]` — OwlMeans TypeScript config conventions
- `[[bun]]` — build, install, workspace filter scripts
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!