Tailwind CSS and shadcn/ui
Warlock owns the integration between its Web project layout and these tools.
Tailwind compiles through PostCSS in development and production; shadcn’s CLI
copies components into src/web/ after Warlock prepares the aliases, tokens,
and shared helper it expects.
Install Tailwind CSS
Section titled “Install Tailwind CSS”warlock add tailwindYou do not need to run warlock add web first. The feature installer resolves
Web before Tailwind and then:
- adds
tailwindcssand@tailwindcss/postcssas development dependencies; - creates
src/web/app.csswith@import "tailwindcss";; - creates
postcss.config.mjswith the Tailwind PostCSS plugin; - imports that stylesheet from
src/web/root.tsx.
Tailwind v4 is CSS-first. There is no generated tailwind.config.js and no
content-glob list to maintain. Put tokens and plugins in src/web/app.css:
@import "tailwindcss";
@theme { --color-brand: oklch(0.62 0.19 259); --font-display: "Inter", sans-serif;}Those tokens produce utilities such as bg-brand, text-brand, and
font-display. Use them from any page or layout:
export default function HomePage() { return <h1 className="font-display text-4xl text-brand">Hello</h1>;}Prepare shadcn/ui
Section titled “Prepare shadcn/ui”warlock add shadcnThis one command also resolves Tailwind and Web when they are missing. It adds
the prerequisites that shadcn init normally owns, adjusted for Warlock’s
src/web/ layout:
components.json, withrsc: false, Tailwind v4 configuration, and aliases targetingweb/components,web/lib, andweb/hooks;src/web/lib/utils.ts, exporting thecn()helper;- shadcn’s neutral design tokens in
src/web/app.css; - the
web/*TypeScript path alias; clsx,tailwind-merge,class-variance-authority, andlucide-react.
Warlock prepares the project; shadcn’s own CLI still owns component generation. Add the components you want:
npx shadcn@latest add button cardUse a generated component
Section titled “Use a generated component”The configured aliases place UI components under src/web/components/ui/:
import { Button } from "web/components/ui/button";
export default function HomePage() { return ( <main className="p-8"> <Button>Ship it</Button> </main> );}Generated components are copied into your source tree. They are application code: edit and version them like any other component.
Optional overlay animations
Section titled “Optional overlay animations”Dialogs, menus, tooltips, and sheets work without an animation package; they
appear immediately. To enable shadcn’s enter and exit animations, install
tw-animate-css with your package manager and import it directly below
Tailwind at the top of the stylesheet:
@import "tailwindcss";@import "tw-animate-css";When styles do not appear
Section titled “When styles do not appear”Check the rendered page and the generated project files:
src/web/root.tsxdirectly imports./app.css.- The active PostCSS config registers
@tailwindcss/postcss. src/web/app.cssbegins with the Tailwind import.- shadcn design tokens such as
--color-primaryexist in that stylesheet. components.jsonpoints its CSS path atsrc/web/app.cssand its aliases atweb/*.
In development, the document should contain a render-blocking stylesheet URL
ending in ?direct. In production, CSS is emitted from Vite’s manifest for the
matched root, layout, and page chain.
Learn how development and production CSS delivery differ →
Shared form controls
Section titled “Shared form controls”The full-stack create-warlock --stack=web preset and the Tailwind + shadcn
selection include an app-owned design system. An explicit --features replaces
the preset; --features=tailwind,shadcn includes the controls. For an existing
Tailwind app:
npx warlock add design-systemThe command refuses before changing files or installing dependencies if
package.json does not declare Tailwind or src/web/app.css does not import it.
Run npx warlock add tailwind first. It prepares shadcn’s aliases and tokens,
adds pinned dependencies, and copies the same source as the scaffolder.
Existing files are preserved. --no-install records dependencies without
installing; --dry-run --json previews the operation without writing.
import { Form } from "@mongez/react-form";import EmailInput from "web/design-system/components/email-input";import SelectInput from "web/design-system/components/select-input";import SubmitButton from "web/design-system/components/submit-button";
export default function PreferencesForm() { return ( <Form> <EmailInput id="preferences-email" name="email" label="Email" hint="For receipts" /> <SelectInput id="preferences-language" name="language" label="Language" options={[ { value: "en", label: "English" }, { value: "ar", label: "Arabic" }, ]} /> <SubmitButton id="preferences-save" name="save" label="Save" /> </Form> );}Use Form directly from Mongez React Form, which supplies noValidate. Each
control registers with useFormControl; SubmitButton reads submission state
and is excluded from collected values. Props live in
src/web/design-system/types/, behavior in src/web/design-system/hooks/, and
each component has its own default-export file. Import by the web alias,
without barrels. The shadcn primitives remain editable in src/web/components/ui.
| Default export | Component file | Value |
|---|---|---|
| TextInput | text-input | string |
| EmailInput | email-input | string |
| PasswordInput | password-input | string |
| TextareaInput | textarea-input | string |
| SelectInput | select-input | string; empty "" |
| MultiSelectInput | multi-select-input | string array; empty [] |
| DatePickerInput | date-picker-input | YYYY-MM-DD; empty "" |
| TimePickerInput | time-picker-input | HH:mm; empty "" |
| DateTimePickerInput | date-time-picker-input | YYYY-MM-DDTHH:mm; empty "" |
| CheckboxInput | checkbox-input | checked value or false |
| RadioInput | radio-input | one selected string per group |
| SwitchInput | switch-input | checked value or false |
| FileInput | file-input | File or File[]; reset clears selection |
| SubmitButton | submit-button | excluded from collected values |
Every control requires an explicit stable id, name, and label. Optional
hint and error are linked with aria-describedby; errors are announced.
Use schema, rules, and validateOn for Mongez validation. Pass checked or
defaultChecked to boolean controls, and value/defaultValue to scalar and
array controls. Options use unique stable string values, string labels,
and optional disabled. RadioInput takes the same options shape. FileInput
accepts accept and multiple; browsers cannot restore an initial file, so
its reset baseline is empty. File uploads still require the appropriate server
transport and validation. TextareaInput accepts rows.
Searchable selections keep search separate from the selected form value. Enter
or Space opens the popup, arrows/Home/End navigate options, Enter selects,
Escape dismisses, and focus returns to the trigger. MultiSelectInput toggles
with Enter or Space when the search is empty. Clear writes the empty value.
The listbox exposes each committed selection independently from keyboard focus.
Date dialogs trap focus and use DayPicker’s calendar grid keyboard navigation.
Checkboxes, radios, and switches preserve Radix keyboard behavior and visible
checked/selected and focus states. Pass dir="rtl" to preserve direction in
portals and radio navigation. Override search, empty, clear, close, and time
labels with searchLabel, emptyLabel, clearLabel, closeLabel, and
timeLabel as applicable; translate placeholders and visible labels too.
Dates and local date-times are calendar/wall-clock strings, not UTC instants.
Resolve the timezone explicitly in your service before converting to an instant.
Choosing a date-time without a time uses midnight; clearing its time clears the
value. Never derive a calendar date through toISOString().
See the Web build-accessible-controls skill for the keyboard, focus, stable-id,
RTL, clear/reset and date-versus-instant contract. Primitive names and dependencies
follow the official shadcn registry and
Calendar source; Radix,
DayPicker, cmdk and styling dependencies are pinned in the optional template.