Skip to content

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.

Terminal window
warlock add tailwind

You do not need to run warlock add web first. The feature installer resolves Web before Tailwind and then:

  1. adds tailwindcss and @tailwindcss/postcss as development dependencies;
  2. creates src/web/app.css with @import "tailwindcss";;
  3. creates postcss.config.mjs with the Tailwind PostCSS plugin;
  4. 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:

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>;
}
Terminal window
warlock add shadcn

This 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, with rsc: false, Tailwind v4 configuration, and aliases targeting web/components, web/lib, and web/hooks;
  • src/web/lib/utils.ts, exporting the cn() helper;
  • shadcn’s neutral design tokens in src/web/app.css;
  • the web/* TypeScript path alias;
  • clsx, tailwind-merge, class-variance-authority, and lucide-react.

Warlock prepares the project; shadcn’s own CLI still owns component generation. Add the components you want:

Terminal window
npx shadcn@latest add button card

The configured aliases place UI components under src/web/components/ui/:

src/web/index.page.tsx
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.

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:

src/web/app.css
@import "tailwindcss";
@import "tw-animate-css";

Check the rendered page and the generated project files:

  1. src/web/root.tsx directly imports ./app.css.
  2. The active PostCSS config registers @tailwindcss/postcss.
  3. src/web/app.css begins with the Tailwind import.
  4. shadcn design tokens such as --color-primary exist in that stylesheet.
  5. components.json points its CSS path at src/web/app.css and its aliases at web/*.

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 →

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:

Terminal window
npx warlock add design-system

The 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 exportComponent fileValue
TextInputtext-inputstring
EmailInputemail-inputstring
PasswordInputpassword-inputstring
TextareaInputtextarea-inputstring
SelectInputselect-inputstring; empty ""
MultiSelectInputmulti-select-inputstring array; empty []
DatePickerInputdate-picker-inputYYYY-MM-DD; empty ""
TimePickerInputtime-picker-inputHH:mm; empty ""
DateTimePickerInputdate-time-picker-inputYYYY-MM-DDTHH:mm; empty ""
CheckboxInputcheckbox-inputchecked value or false
RadioInputradio-inputone selected string per group
SwitchInputswitch-inputchecked value or false
FileInputfile-inputFile or File[]; reset clears selection
SubmitButtonsubmit-buttonexcluded 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.