vite-tailwind-radix
Use when styling any web UI - style with Tailwind utility classes and design tokens, avoid custom CSS, and build on the existing Radix/shadcn primitives instead of hand-rolling
SKILL.mdcatalog/agents/frontend-developer/skills/vite-tailwind-radix/SKILL.md
GitHubTailwind, Radix, No Custom CSS
Overview
Styling is Tailwind-first with a shared token system, on top of Radix/shadcn primitives. Custom CSS and hand-rolled interactive widgets are what this skill exists to prevent — they fragment the design and re-introduce accessibility bugs the primitives already solved.
Core principle: Avoid custom CSS. Style with Tailwind utilities and the design tokens; reach for the existing shadcn primitive before building an interactive widget, and its built-in variant before a custom class.
Rules
- Tailwind v4 by default. New Vite projects use
@tailwindcss/viteand@import "tailwindcss", with tokens defined in@theme/:rootin the main CSS file. A repo already on Tailwind v3 keepstailwind.config— checkpackage.jsonand follow what the repo already uses, don't migrate it mid-task. - Tailwind utilities, not custom CSS. No new
.cssfiles, no<style>blocks, no bespoke class names. Compose utilities; usecn()for conditional variants. An inlinestyle={{}}object is allowed ONLY for a genuinely dynamic, computed value (a measured width, a transform) — tag it// ui-guard-allow: <reason>if the project runs ui-guard. - Built-in variants before custom styles.
<Button variant="outline">,size="sm"— notclassName="border border-input bg-transparent hover:bg-accent". - Semantic tokens, not raw values.
bg-primary,text-muted-foreground,border-border— neverbg-blue-500,text-gray-600, or a hex code. No arbitrary pixel values (w-[327px]) — use the scale. classNameis for layout, not restyling.w-full,max-w-md,self-end,col-span-2are fine (spacing between siblings comes from the parent'sgap, not margins on the child); overriding a component's own colour or typography throughclassNameis not — change the variant or the token instead.gap-*overspace-x-*/space-y-*.flex flex-col gap-4, notspace-y-4.size-*for square elements.size-10, notw-10 h-10.- No manual
dark:colour overrides — the semantic tokens already carry both themes. - No manual z-index on overlay primitives.
Dialog,Sheet,Drawer,Popover,DropdownMenu,Tooltipmanage their own stacking; never addz-50/z-[999]to them. The fixed z-scale —z-10raised content,z-20sticky header,z-30dropdown/popover,z-40overlay,z-50modal/toast — is for your own layered content, never arbitraryz-[…]. - Build on the primitives. Dialog, Dropdown, Tooltip, Popover, Tabs, etc. come from the Radix primitives wrapped in
components/ui— never hand-roll one that exists (you'll lose focus trapping, keyboard nav, and ARIA).Dialog/Sheet/Draweralways render aTitle(visually hidden withsr-onlyif the design doesn't show one). - Forms use the Field pattern.
Field/FieldGroup(or the project's ownFormFieldmolecule) — not a rawdivwithspace-y-*. Validation state isdata-invalidon theField,aria-invalidon the control. - Use the component for the job, not a styled div.
Emptyfor empty states,Skeletonfor loading (no customanimate-pulsediv),AlertDialogfor destructive confirmation,Spinnercomposed intoButtonfor a pending submit. - Match neighbors. Before styling a new screen, look at an adjacent one and reuse its patterns and token classes.
npm run buildmust stay green — a TypeScript error or failed build is a broken task, not a warning.
Where things live
- shadcn/Radix primitives are the atom-level vendor layer, generated into
components/ui— seecomponent-compositionfor the rest of the atomic levels and the import-direction rule. - A repo with no tokens or folder structure yet: see
web-design-system-foundationto set up@theme,cn(), and the base atoms before building the first screen.
When an inline style / arbitrary value IS ok
| Situation | Verdict |
|---|---|
| Static padding/color/size | Tailwind token class |
| Conditional variant | cn() with utility classes |
| Value computed at runtime (measured px, dynamic transform) | inline style={{}} — the only valid case, tag ui-guard-allow: if ui-guard runs |
| A one-off pixel that "isn't in the scale" | No — use the nearest scale token; extend the scale via tokens if truly needed |
grid-cols-[repeat(auto-fit,minmax(...))], max-w-[..ch], aspect-[..] |
Allowed arbitrary values (ui-guard's allow-list) |
Worked Example
// ❌ custom CSS + magic values + hand-rolled dropdown
<div className="my-custom-menu" style={{ padding: "13px", background: "#f5f5f5" }}>
{/* hand-built menu: no keyboard nav, no ARIA */}
</div>
// ✅ tokens + primitive + built-in variant
<DropdownMenu> {/* Radix primitive from components/ui */}
<DropdownMenuTrigger asChild><Button variant="ghost">Actions</Button></DropdownMenuTrigger>
<DropdownMenuContent className="p-3">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onExport}>Export</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
The primitive brings focus management, escape-to-close, and ARIA for free; the token classes and DropdownMenuGroup keep it visually and structurally identical to every other menu.
Common Mistakes
- A new
.cssfile or<style>block instead of utilities. - Arbitrary values (
w-[327px],text-[#3a3a3a]) or raw palette classes instead of scale tokens. - Hand-rolling a dialog/dropdown/tooltip/empty-state/skeleton that already exists in
components/ui. space-x-*/space-y-*instead ofgap-*; a manualz-50on an overlay primitive.style={{}}for static styling that a class would cover.
Red Flags
- Any new custom CSS class or stylesheet in the diff.
- An interactive widget built from raw
divs withonClickinstead of a primitive. - Magic pixel/color values or a manual
dark:override scattered across the component.