Skip to content
Air-Cloud docs

Using shadcn/ui

shadcn/ui copies React components into your own source tree, built on Radix primitives and Tailwind. A site created with aircloud new is ready for it: the React template uses Tailwind by default, and @/ already resolves to src/ in both vite.config.js and jsconfig.json. This guide takes a site from shadcn init to a release that verifies clean, under the product's default Content-Security-Policy.

Set it up inside the site

Run the shadcn CLI in the site directory, app/site/, never at the product root:

cd app/site
npx shadcn@latest init

init writes app/site/components.json, next to package.json. The site admits any top-level .json file there as a tooling manifest that the build never reads, so components.json needs no exception. Two names are refused: tsconfig.json (the site is JavaScript, and jsconfig.json already declares its paths) and any .json file whose name starts with a dot.

Make sure components.json carries these values:

{
  "tsx": false,
  "rsc": false,
  "tailwind": { "css": "src/styles/application.css" },
  "aliases": {
    "components": "@/components",
    "ui": "@/components/ui",
    "utils": "@/lib/utils",
    "lib": "@/lib",
    "hooks": "@/hooks"
  }
}

Add components the same way, from app/site/:

npx shadcn@latest add dialog
aircloud check

A component's unit test mirrors its path. For app/site/src/components/ui/dialog.jsx, the test goes under test/site/components/, in the same ui/ subdirectory, as dialog.test.jsx.

Remove the CLI once you have scaffolded

shadcn init can leave the shadcn package in the site's dependencies: your stylesheet imports shadcn/tailwind.css from it. The running site doesn't need a scaffolding tool. Once the components you need are in place, eject it:

npx shadcn@latest eject

eject copies the content of shadcn/tailwind.css into your stylesheet in place of the import. It then removes shadcn from package.json with your package manager (npm uninstall shadcn for an npm site), which also rewrites package-lock.json. On the site this guide was written against, the lockfile went from 460 packages to 168. eject asks for confirmation (--yes skips it) and cannot be undone: later shadcn updates to that stylesheet no longer apply automatically.

If your stylesheet doesn't import shadcn/tailwind.css, eject reports "Nothing to eject" and changes nothing. In that case, if shadcn is still in package.json, run npm uninstall shadcn yourself.

You can keep adding components after ejecting. npx shadcn@latest add runs the CLI without making it a dependency.

The content policy needs no edit

A product site runs under the policy in config/content_security_policy.toml. Its default style_src is 'self', which admits no inline style. Radix and sonner create <style> elements from script: Radix for a Dialog's scroll-lock, sonner for its toast styles. The platform makes those styles legal with no change to the policy:

Some styles stay blocked, and that is deliberate:

React's style={{ … }} prop is fine: React sets those properties through the DOM, never as an attribute string.

When you choose components, prefer ones that ship their CSS as a file or create styles from script. Avoid any that write <style> markup or style-attribute strings. A library that ships an inline <script> needs 'unsafe-inline' in script_src and allow_unsafe_inline_scripts = true. That lets any injected script run, so prefer one that ships its script as a file.

Never add 'unsafe-inline' to style_src. A browser ignores 'unsafe-inline' in a directive that carries a nonce, so aircloud check refuses the combination (csp.policy_invalid). It also refuses 'none' in a nonce directive, a fixed 'nonce-…' source of your own, and a nonce directive that the policy doesn't declare. To drop style_src entirely, also set nonce_directives = [].

If you write your own HTML. The build stamps the nonce into the HTML it emits through Vite's html.cspNonce option. The generated app/site/vite.config.js sets it to a placeholder, and the platform replaces that placeholder with the page's nonce on every response. Hand-written HTML, including a static site's pages under app/site/public/, gets the same treatment. To nonce a <style> or <script> tag there, give it nonce="…" with the placeholder value from a generated vite.config.js. Never bake untrusted text, such as CMS content or user submissions, into such a file: anything in it that carries the placeholder receives the live nonce.

Test what the user does

The generated system tests import test from @aircloudhq/testing/system. That test guards every page and every interaction with the product's policy. A violation fails the test that caused it, whether it happens on page load or when a dialog opens or a toast appears. The spec needs no check of its own:

// test/system/dialog.spec.ts
import { expect, test } from "@aircloudhq/testing/system";

test.use({ baseURL: process.env.AIRCLOUD_BASE_URL });

test("the confirm dialog opens", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: "Delete" }).click();
  await expect(page.getByRole("dialog")).toBeVisible();
});

Write one such test for each component that injects styles when it opens: dialogs, popovers, menus, selects and toasts.

Translate component strings

i18n.inline_text applies to shadcn's components exactly as it does to yours. shadcn ships English literals: the Dialog's close button carries a hard-coded Close for screen readers, for example, which a Portuguese-speaking user would hear in English. Run aircloud check after every shadcn add, and move each literal it flags into a locale key read with useT():

// JSX text is flagged by i18n.inline_text
<span className="sr-only">Close</span>

// compliant
import { useT } from "@aircloudhq/web/react";

const t = useT();
<span className="sr-only">{t("components.dialog.close")}</span>

Components use explicit, full keys. Keep the dialog's keys in config/locales/components/dialog.en.yml and its siblings for each locale. Translations covers the lookup rules.

Fonts

The generated stylesheet uses the system font stack: there's no webfont to download and nothing for the policy to admit. If init added a font package or a font import to package.json or your stylesheet, remove any you don't use.

To use another typeface, self-host it. Put the .woff2 files in a fonts/ directory under app/site/public/ and declare them with @font-face in a stylesheet under app/site/src/styles/:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-display: swap;
}

Don't load fonts from a third-party CDN. The default policy's font_src is 'self', so the browser refuses them. Ship only the weights and character subsets you actually use.

Before each release

aircloud check runs every rule this guide relies on: the site layout, i18n.inline_text and the policy validation. The guarded system tests then exercise the components in a real browser against the release. Run aircloud check before aircloud release create.