The way styles are written here, the tokens to reference, the primitives to build from, and when to add a new one. Read the rule before your first interface change.
Tailwind v4 utility classes written against semantic tokens: app/styles/tailwind.css declares colour, radius, type-scale and animation custom properties (light values on :root, dark on .dark) and maps them into utilities through @theme inline, so markup says bg-primary, text-muted-foreground, text-h1 and rounded-md rather than a value. Components merge classes with cn() from app/utils/misc.tsx (clsx plus tailwind-merge) and express variants with class-variance-authority, the way buttonVariants does. The second way not to introduce is colour literals or Tailwind palette utilities: the violet logo tiles on the marketing index and DropdownMenuCheckboxItem's stroke="black" check icon are the existing exceptions, not the pattern to copy.
Before writing markup, look in app/components/ui (shadcn-derived primitives) and app/components/forms.tsx (Conform field wrappers). Route files reach for Button or StatusButton for actions — variant, size, asChild for links, status for submits — and Field, TextareaField, CheckboxField or OTPField for form inputs. Raw <button> elements are the exception rather than a second system: the theme switch toggle, the note editor's remove-image control and hidden default submit, and the logout submit that DropdownMenuItem asChild turns into a menu row. When a primitive is almost right, add a cva variant to it, the way Button gained its wide and pill sizes, rather than overriding className at each call site.
Create a primitive when the same interface pattern is needed on a second screen and nothing in app/components/ui or forms.tsx covers it. Pull the shadcn registry version with the CLI first (components.json points its ui alias at #app/components/ui) and adapt it to the tokens, and put new Conform compositions in forms.tsx. A piece used by one screen stays in its route folder, the way ImageChooser lives in app/routes/users/$username/notes/+shared/note-editor.tsx.
A genuinely new primitive belongs inapp/components/ui
6 groups · sampled, not exhaustive — each file below is the full list
Tailwind utilities generated from the @theme inline mapping: bg-primary text-primary-foreground for actions, text-muted-foreground for secondary copy, border-input aria-[invalid]:border-input-invalid on fields, with opacity modifiers such as hover:bg-primary/80.
backgroundforegroundprimarymuted-foregroundaccentdestructiveforeground-destructiveinput-invalid39 primitives · exhaustive — one that is not here does not exist
Named scale utilities from the --text-* tokens: page headings use text-h1 (login.tsx, users/index.tsx) and text-h2 (the note page title), secondary copy text-body-sm, while the ui primitives still size themselves with Tailwind's default text-sm.
text-megatext-h1text-h2text-h6text-body-lgtext-body-smtext-body-2xstext-buttonOne --radius base with --radius-sm/md/lg/xl derived from it by calc(), consumed as rounded-sm, rounded-md and rounded-lg — inputs, buttons, tooltips and menus all use rounded-md — so changing --radius reshapes every corner at once.
radiusradius-smradius-mdradius-lgradius-xlNamed animations exposed as animate-* utilities: animate-slide-top, animate-slide-left and animate-roll-reveal on the marketing index, animate-caret-blink for the fake caret in InputOTPSlot. Tooltip and menu enter/exit use tw-animate-css classes (animate-in fade-in-0 zoom-in-95) instead.
animate-roll-revealanimate-slide-leftanimate-slide-topanimate-caret-blinkGaps between page sections come from the Spacer component's named sizes — <Spacer size="xs" /> renders an empty div whose height class comes from the size map in spacer.tsx.
4xs3xs2xsxssmmdlg4xlIcons are sized by name through the Icon component — <Icon name="pencil-1" size="md" /> — where each size also sets the gap to label children, and size="font" scales with the surrounding text.
fontxssmmdlgxlIconEvery icon in the app: renders a named symbol from the SVG sprite (IconName lists the valid names) at a named size, and when given children lays the label out beside the icon with a matching gap.
InputThe styled single-line text input, whose border switches to input-invalid when aria-invalid is set. Field in forms.tsx wraps it with a Label and an ErrorList for Conform forms.
LabelThe styled form label that the field wrappers in forms.tsx pair with their controls; use it directly only for a control those wrappers do not cover.
CheckboxThe Radix checkbox styled with border-primary and a bg-primary checked state; CheckboxField wraps it for Conform, which is how the remember-me and terms checkboxes are built.
TextareaThe multi-line text input with the same border, focus ring and invalid styling as Input; TextareaField wraps it for Conform forms such as the note editor's content field.
TooltipThe Radix tooltip root: compose Tooltip around a TooltipTrigger and a TooltipContent inside a TooltipProvider, as StatusButton does to show its message.
TooltipTriggerThe element that opens the tooltip on hover and focus; StatusButton puts its status icon here so the message appears over it.
TooltipContentThe floating tooltip panel with popover colours, a rounded-md border and tw-animate-css enter/exit animation; the explanatory text goes here.
TooltipProviderSupplies the shared open-delay behaviour a Tooltip needs; StatusButton renders one around its own tooltip, so wrap any new Tooltip in it too.
InputOTPThe one-time-code input (input-otp) behind every verification code entry; OTPField in forms.tsx wraps it with a label and errors for the code-entry screens.
InputOTPGroupGroups adjacent InputOTPSlot cells into one visually joined, bordered row; OTPField uses it to lay out the code cells.
InputOTPSlotOne character cell of the code input, showing the typed character, the active ring and the animate-caret-blink fake caret on the focused slot.
InputOTPSeparatorThe divider rendered between InputOTPGroup rows, so a long code reads in chunks instead of one unbroken row of cells.
DropdownMenuThe Radix dropdown root for action menus; the header's UserDropdown is built from it, and a new menu composes the DropdownMenu* parts rather than a hand-rolled popover.
DropdownMenuTriggerThe element that opens the menu; pass asChild so an existing Button or Link becomes the trigger instead of an extra wrapper element.
DropdownMenuContentThe menu panel: popover colours, a rounded-md border, a shadow and tw-animate-css enter/exit animation keyed to the side it opens on.
DropdownMenuItemOne selectable action row with the accent focus highlight; pass asChild to turn a Link or a submit button into a menu row.
DropdownMenuCheckboxItemA menu row that toggles a boolean with a check indicator; its check icon is drawn with a literal stroke="black", so it stays black on the dark theme.
DropdownMenuRadioItemA menu row representing one mutually exclusive choice inside a DropdownMenuRadioGroup, with an indicator on the selected option.
DropdownMenuLabelA non-interactive heading row inside a menu, for naming a group of items without making the heading selectable.
DropdownMenuSeparatorThe thin horizontal divider between groups of menu items; use it instead of a bordered div so spacing matches the menu.
DropdownMenuShortcutA right-aligned span for the keyboard-shortcut hint at the end of a menu row, styled so it recedes behind the label.
DropdownMenuGroupWraps related menu items so assistive technology announces them as a group; pair it with DropdownMenuLabel for a visible heading.
DropdownMenuPortalRenders the menu content into document.body so it escapes the overflow and stacking context of the trigger's container.
DropdownMenuSubThe root of a nested submenu inside a menu, pairing a DropdownMenuSubTrigger with its DropdownMenuSubContent.
DropdownMenuSubTriggerThe menu row that opens a nested submenu on hover or arrow key, styled like a regular item with an open-state highlight.
DropdownMenuSubContentThe panel of a nested submenu, with the same popover colours, border, shadow and animations as DropdownMenuContent.
DropdownMenuRadioGroupHolds the value for a set of DropdownMenuRadioItem rows so exactly one option in the menu is selected at a time.
EpicToasterThe app-wide toast outlet built on sonner and styled with bg-background, text-foreground and border-border; rendered once at the root: an action raises a toast with redirectWithToast from app/utils/toast.server.ts, and useToast(data.toast) in app/root.tsx shows it here.
ErrorListRenders Conform's error array for a field or a whole form in the destructive text colour, under the id the control's aria-describedby points at; every field wrapper uses it, and forms render it directly for form-level errors.
FieldThe default Conform text field — Label, Input wired to aria-invalid and aria-describedby, and ErrorList — taking labelProps, inputProps and errors; use it for every single-line input in a route form.
TextareaFieldThe multi-line counterpart of Field (Label, Textarea and ErrorList with the same props-and-errors shape), used for long text such as a note's content.
CheckboxFieldA Checkbox with its label and errors for Conform booleans, keeping the Radix checkbox's state in sync with the value the form submits; remember-me on login and the terms checkbox in onboarding use it.
OTPFieldThe Conform wrapper around InputOTP with a label and errors; the /verify page (email codes and the 2FA login check) and /settings/profile/two-factor/verify (confirming a new authenticator app) use it.
SpacerAdds a named amount of vertical space between page sections (<Spacer size="4xs" /> through "4xl") as an empty block, so pages share one vertical rhythm.
GeneralErrorBoundaryThe shared body of every route ErrorBoundary: routes render it with statusHandlers per HTTP status (the note edit route maps 404 to a not-found message) and it falls back to a generic message for anything else.
floatingToolbarClassNameA class string rather than a component: the pinned bottom action bar shared by the note page and the note editor, so a new screen with Edit, Delete or Save actions applies it instead of recreating the bar.