Naalya Handbook

Styling & Theming

Tailwind v4 CSS-first setup, the design-token system, the dark-mode reality, and the cn() + CVA conventions.

Styling on the Naalya site rests on one decision that shapes everything else: there is no tailwind.config.js. This is Tailwind v4 in CSS-first mode, where colors, fonts, plugins, dark-mode strategy, and animations all live in a single stylesheet — app/app.css. Once you internalize that, the rest is just learning where each piece sits in that file.

On top of Tailwind, the project layers the shadcn/ui "new-york" convention: a two-tier CSS-variable token system, a cn() helper for composing classes, and class-variance-authority (CVA) for component variants. None of that is exotic — but the design-token wiring has a subtle "two places" rule, and theming has a genuine trap waiting for you.

The theming trap, up front

next-themes is installed but it is not how this app themes. Global theming is a custom Zustand store (also named useTheme), and the public marketing site is hard-locked to light mode on every render. Your dark: utilities are dormant on every public page. Read the theming section before you touch dark mode.

The entire stack boots from one import at the top of app/root.tsx — that single line pulls in the engine, every token, and every base style. There's nothing else to wire up.

app/root.tsx
import "./app.css";

The no-config setup

There is no tailwind.config.js, no tailwind.config.ts, and no postcss.config anywhere in the repo. The engine is registered through the Vite plugin and nothing more:

vite.config.ts
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [devtools(), tailwindcss(), reactRouter(), tsconfigPaths()],
  ssr: { noExternal: ["streamdown", "react-syntax-highlighter"] },
});

Everything a v3 config file used to hold now lives as at-rules at the top of app/app.css. Each line replaces a chunk of old JS config — loading the engine, registering plugins, declaring extra content sources, and defining the dark-mode strategy:

app/app.css
/** biome-ignore-all lint/suspicious/noUnknownAtRules: <Tailwind CSS> */
@import "tailwindcss";
@import "tw-animate-css";

@source "/node_modules/streamdown/dist/index.js";

@plugin "@tailwindcss/typography";

@custom-variant dark (&:is(.dark *));

In plain terms: @import "tailwindcss" replaces the old @tailwind base/components/utilities trio; @plugin "name" is the v4 way to register a plugin (a string path, not a require); and @custom-variant dark declares class-based dark mode — dark: utilities only fire when an ancestor carries .dark.

The empty config string is intentional

components.json (the shadcn config) has "config": "" on purpose — that empty string is shadcn declaring "this project is CSS-first, there is no JS config." If you go hunting for tailwind.config.js, you won't find one, and that's correct. Never create one. Tokens, plugins, and content sources all go in app/app.css.

Two lines in that block are load-bearing in a way that isn't obvious, and removing either breaks things silently.

Leave the @source and biome-ignore lines alone

@source "/node_modules/streamdown/dist/index.js" tells the v4 class scanner to keep the classes used inside the streamdown markdown renderer — drop it and the AI chat styling silently breaks. And the leading /** biome-ignore-all ... */ comment stops the linter from flagging the v4 at-rules (@theme, @plugin, @custom-variant, @source) as unknown. Keep both.


Design tokens: the two-tier rule

Here's the one concept that, once it clicks, makes the whole token system obvious. Colors and radii are defined in two layers, and you have to touch both for a token to work.

Layer A holds the raw values as plain CSS custom properties — light values on :root, dark values on .dark. Most are authored in OKLCH to keep the palette perceptually consistent:

app/app.css
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --primary: oklch(28.45% 0.131 262.28);
  --primary-500: oklch(0.2779 0.0833 284.41);
  --secondary: oklch(70.20% 0.145 234.17);
  --destructive: oklch(0.577 0.245 27.325);
  /* ...muted, border, card, popover, accent, ring, chart-1..5, sidebar* ... */
}

Layer B is a separate @theme inline { ... } block that maps each raw var into Tailwind's color namespace. This is the step that makes utilities exist — without the mapping, bg-primary simply isn't a class:

app/app.css
@theme inline {
  --radius-lg: var(--radius);
  --color-background: var(--background);
  --color-primary: var(--primary);
  --color-secondary: var(--secondary);
  --color-destructive: var(--destructive);
  /* ...one --color-* line per raw token... */
}

The inline keyword is the magic. Because the mapping is inline, each generated --color-* resolves to the live var(--primary) at runtime — so toggling .dark re-themes every utility instantly, with no recompile. A plain (non-inline) @theme block would bake the values at build time and dark mode would never switch.

Why there are two @theme blocks

The split is deliberate. The plain @theme { } block holds tokens that never change at runtime — the fonts (--font-sans, --font-poppins). The @theme inline { } block holds colors, radii, and animations, which must resolve live var(--x) so they re-theme when .dark flips. Put a font in inline and nothing breaks; put a color in plain @theme and dark mode does.

Adding a token, then, is always a two-step move — and the most common bug is forgetting the second half:

app/app.css
/* 1. Raw value — add to BOTH :root and .dark, in OKLCH */
:root { --brand: oklch(0.62 0.19 25); }
.dark { --brand: oklch(0.70 0.17 25); }

/* 2. Map it so utilities exist */
@theme inline { --color-brand: var(--brand); }

Save, and the Vite plugin recompiles bg-brand, text-brand, border-brand, ring-brand, and friends into existence. The same pattern extends to other namespaces: --radius-2xl: calc(var(--radius) + 8px) gives you rounded-2xl, and --animate-foo: foo 2s linear infinite gives you animate-foo.

A token in one place is half a token

A color is only complete when it exists as a raw value on both :root and .dark AND as a --color-* mapping in @theme inline. Miss the raw value and the utility resolves to nothing; miss the mapping and the utility doesn't exist at all.

One more thing the dark palette does that catches people: it swaps brand roles, it doesn't just darken. In .dark, --primary is reassigned to the light theme's secondary hue, and --secondary becomes the tertiary yellow. Don't assume "dark = same color, darker" — the roles intentionally rotate.


Composing classes with cn()

Every place you merge a base class string with a className prop or a conditional, you reach for cn() from @/lib/utils. It's the standard shadcn helper, and its whole job fits in three lines:

app/lib/utils.ts
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

It does two things at once. clsx flattens conditional, array, and object inputs into one string; twMerge then de-duplicates conflicting Tailwind classes with last-wins semantics — which is exactly what lets a caller's className override a component's default instead of producing two fighting classes:

example
// the caller's px-8 wins; the base px-4 is dropped by twMerge
cn("rounded-md px-4", isActive && "bg-primary", "px-8");
// -> "rounded-md bg-primary px-8"

That last-wins behavior is the entire reason cn() exists rather than plain string concatenation — it's what guarantees overrides are predictable. (The @/ alias resolves to ./app/ via tsconfig paths.)


Component variants with CVA

Fourteen UI primitives express their variants with class-variance-authority, and they all follow the same shape. app/components/ui/button.tsx is the canonical example — a cva() call defines a base string plus named variants, and the component pipes its props through it:

app/components/ui/button.tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-all disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        outline: "border border-primary bg-background text-primary hover:bg-primary/4",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        /* ...destructive, link... */
      },
      size: { default: "h-9 px-4 py-2", sm: "h-8 px-3", lg: "h-10 px-6", icon: "size-9" },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
);

Notice how design tokens flow straight into the variant strings — bg-primary, text-secondary-foreground, bg-accent. That's the payoff of the token system: variants describe roles, and the colors come from the theme. When you write a new variant-driven primitive, follow the same five-step convention the existing ones use:

  1. Define const <name>Variants = cva(baseString, { variants, defaultVariants }).
  2. Type the props with VariantProps<typeof <name>Variants>.
  3. Render className={cn(<name>Variants({ ...props, className }))} — passing className into the cva call so twMerge can override base and variant classes.
  4. Put data-slot="<name>" on the root element (the new-york convention, used for descendant styling).
  5. Export both the component and the *Variants function, so other code can borrow the classes — e.g. styling a <Link> to look like a button.

asChild renders as a different element

Most primitives accept asChild, which swaps the root for Radix's Slot. That lets a button render as a router <Link> while keeping every variant class — <Button asChild><Link to="/contact">Contact</Link></Button>. Reach for it instead of duplicating button styles onto a link.


Theming: the part that surprises everyone

This is the section the opening callout warned you about. Despite next-themes sitting in package.json, it is not the app's theming mechanism. Global theming is a custom Zustand store — confusingly also named useTheme — that persists to localStorage and toggles the .light / .dark class on <html>:

app/components/providers/theme.provider.tsx
export const useTheme = create<{ theme: Theme; setTheme: (t: Theme) => void }>()(
  persist(
    (set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
    { name: "naalya-theme", storage: createJSONStorage(() => localStorage) }
  )
);
// A ThemeProvider effect adds/removes "light"/"dark" on document.documentElement.

That class toggle is exactly what @custom-variant dark (&:is(.dark *)) keys off of — so this store, not next-themes, is what actually drives every dark: utility. next-themes is imported in exactly one file, app/components/ui/sonner.tsx, to theme the toast — and because no next-themes provider is mounted anywhere, even that falls back to a static "system".

Two useTheme hooks — import the right one

There are two useTheme exports in the repo. For anything to do with app theming, import from @/components/providers/theme.provider. The next-themes useTheme is wired to sonner.tsx only. Importing the wrong one is a silent no-op.

Now the consequence that trips up most people: the public site is hard-locked to light. The marketing layout forces the theme back to "light" on every render:

app/routes/_layout.tsx
const { setTheme, theme } = useTheme(); // the custom Zustand hook
useEffect(() => {
  if (theme !== "light") setTheme("light");
}, [theme, setTheme]);

So even though app.css ships a full .dark token set and the primitives carry ~39 dark: utilities, dark mode is dormant on every public page. The only surface with a working toggle is the Naalya-AI chat, which lives outside _layout.tsx.

Dark styles will not appear on marketing pages

Don't burn time wondering why your dark: classes do nothing on the public site — _layout.tsx resets the theme to "light" on every render. Dark styles only render on routes outside that layout. To enable site-wide dark mode you'd first have to remove or relax that useEffect.

When you do want a component to handle both themes, prefer semantic tokens over dark: overrides. Utilities like bg-background, text-foreground, bg-card, and border-border already flip between :root and .dark, so a component built from them re-themes for free — often with no dark: at all. Reach for the custom hook only when you need to read or flip the theme yourself:

example
import { useTheme } from "@/components/providers/theme.provider";

const { theme, setTheme } = useTheme();
setTheme(theme === "dark" ? "light" : "dark");

Typography, animations, and fonts

A few smaller systems round out the stylesheet. The @tailwindcss/typography plugin enables prose, used across 15+ routes to render Sanity Portable Text and markdown. Reserve it for long-form author content — wrap the rich-text body and use not-prose to escape it; for app chrome, use tokens and primitives directly:

app/routes/blog-post.tsx
<article className="prose prose-lg dark:prose-invert max-w-none">
  {/* rendered Portable Text */}
</article>

There are two animation systems. @import "tw-animate-css" supplies the enter/exit utilities (animate-in, fade-in-0, zoom-in-95, the data-[state=open]: variants) baked into every Radix primitive — you rarely write those by hand. Bespoke effects are defined as --animate-* tokens plus matching @keyframes inside @theme inline, which become animate-* utilities like animate-gradient and animate-aurora. To add one, define both the token and the keyframes.

A defined keyframe is not a usable utility

The blob-float-1/2/3 keyframes exist in app.css but have no --animate-blob-* token mapping, so animate-blob-1 does not exist. A keyframe alone isn't enough — it only becomes a utility once an --animate-* token references it. Add the mapping, or drive the keyframe from custom CSS.

For fonts, the token you'll actually see rendered is Poppins, not Inter. Inter is the configured --font-sans default (loaded via a <link> in root.tsx), but font-poppins is applied straight to html, body, so Poppins wins everywhere unless you ask for font-sans explicitly. Both font tokens live in the plain @theme block since they never change at runtime.


Conventions checklist

When you style anything in this project, these are the rules that keep you out of trouble:

  • No tailwind.config.js. All Tailwind config lives in app/app.css — plugins via @plugin, content via @source, tokens in the @theme blocks.
  • Add color tokens in both places: a raw value on :root and .dark, then a --color-*: var(--*) mapping in @theme inline. Author colors in OKLCH.
  • Color/radius/animation tokens go in @theme inline so they resolve live vars and re-theme; fonts go in the plain @theme.
  • Compose with cn() from @/lib/utils, passing className into the cva call so caller overrides win.
  • Variants use CVA: cva(base, { variants, defaultVariants }), type with VariantProps, render cn(variants({...})), add data-slot, export both the component and *Variants.
  • Theme reads/writes use useTheme from @/components/providers/theme.providernever next-themes.
  • Prefer semantic tokens (bg-background, text-foreground, border-border) so components re-theme automatically.

Where to go next

On this page