Customization
Retheme with OKLCH tokens in one CSS file, swap fonts and dark mode, extend shadcn primitives in packages/ui, and drive homepage branding from brand and marketing config.
Last updated on
6 min readOn This Page
How Customization Is Layered
Four layers, top to bottom:
- Tokens. CSS variables in
packages/ui/src/styles/globals.css(colors, radius, fonts). - Primitives. shadcn-style components in
packages/ui/src/components/that consume those tokens. - App components. Product UI in
apps/web/components/that composes the primitives. - Routes. Next.js pages that mount the app components.
Brand and marketing config sit beside the components. They supply strings, ordering, and assets without editing component source. Always edit the lowest layer that solves the problem.
Package Layout
Brand Tokens
Every color, radius, and font resolves to a CSS variable in packages/ui/src/styles/globals.css. Tailwind v4 reads them through @theme inline, so utilities like bg-primary and rounded-lg track whatever you set in :root and .dark.
Fastest full retheme: the shadcn/ui Create tool. Pick style, colors, fonts, and radius, then apply the preset to the kit.
Configure your theme
Open ui.shadcn.com/create. Choose style, base color, primary and accent, chart palette, fonts, icon library, and radius.
Copy the preset code
Copy the preset identifier the page generates.
Apply it to the kit
Run the shadcn CLI scoped to @syntaxkit/ui so it uses packages/ui/components.json:
pnpm --filter @syntaxkit/ui dlx shadcn@latest init --preset [CODE]The CLI rewrites the :root and .dark blocks in globals.css and updates font/icon entries in components.json.
Reload
Tailwind picks up the change on the next page reload. No rebuild and no tailwind.config.js.
Tailwind v4 reads tokens from CSS via @theme inline. The kit ships no tailwind.config.js. The top of packages/ui/src/styles/globals.css (@import "tailwindcss", @source ..., @theme inline { ... }) replaces the old JS config.
Fonts
The product app loads Geist and Geist Mono with Next font in apps/web/app/layout.tsx and exposes them as CSS variables on <body>:
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});@theme inline in globals.css aliases --font-sans and --font-mono to those variables, so font-sans and font-mono resolve correctly. To swap fonts, change the import in layout.tsx and keep the same variable names.
Light And Dark Mode
Dark mode uses next-themes, mounted in apps/web/app/layout.tsx:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>attribute="class" toggles a dark class on <html>, which activates the .dark block in globals.css. defaultTheme="system" follows the OS until the user picks a theme. disableTransitionOnChange avoids a flash on swap.
The shipped toggle is apps/web/components/theme-toggle.tsx. It uses useTheme from @syntaxkit/ui/components/theme-provider:
import { ThemeToggle } from "@/components/theme-toggle";
export function Header() {
return (
<header>
{/* ... */}
<ThemeToggle />
</header>
);
}Base Components
Primitives live in packages/ui/src/components/. They are shadcn-style: source in your repo, not a versioned package. Each one combines Radix UI behavior, class-variance-authority for typed variants, and cn for class merging. button.tsx is the reference:
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);Variants and sizes are typed through VariantProps<typeof buttonVariants>. To add a variant:
Edit the cva block
In packages/ui/src/components/button.tsx, add a key under variants.variant:
gradient:
"bg-gradient-to-r from-primary to-chart-2 text-primary-foreground hover:opacity-90",Use it
<Button variant="gradient">Upgrade</Button>TypeScript narrows the union from cva. Misspelled variants fail at compile time.
cn (packages/ui/src/lib/utils.ts) is clsx plus tailwind-merge. Caller classes win for the same utility: <Button className="rounded-none"> overrides the variant's rounded-md.
Composing App Components
App components live in apps/web/components/ and import from @syntaxkit/ui/components/<name>. Most day-to-day UI work happens here.
Logo (apps/web/components/logo.tsx). Reads text-primary for the colored half of the wordmark. Change it once to rebrand headers, footers, and auth screens.
import { cn } from "@syntaxkit/ui/lib/utils";
export const Logo = ({ className, ...props }: React.ComponentProps<"span">) => (
<span className={cn("font-bold tracking-tight", className)} {...props}>
Syntax<span className="text-primary">Kit</span>
</span>
);ThemeToggle (apps/web/components/theme-toggle.tsx). Composes <Button>, <DropdownMenu>, and useTheme. Use it as a template when you need a primitive plus a Radix menu and local state.
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon-sm">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
{theme === "light" && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
{theme === "dark" && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
{theme === "system" && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Same folder for product surfaces: marketing/*, dashboard/*, auth/*. New product components go here with the kebab-case filenames from Conventions.
Marketing And Site Config
Two TypeScript objects own most non-component branding. Edit either to change the homepage without touching components. The dev server hot-reloads both.
| Config | Path | Owns |
|---|---|---|
| Brand | packages/brand/src/index.ts | Name, wordmark split, description, app/docs/pricing URLs, social handles, legal entity, SEO defaults. Used by metadata, OpenGraph, Logo, footers, email, and legal pages. |
| Marketing | apps/web/config/marketing.ts | Homepage section visibility and order, hero badge href, feature lists, pricing tiers, testimonials, FAQ. Structural and asset choices only; translatable copy lives in messages/*.json. |
Dashboard sidebar groups and breadcrumb labels: apps/web/config/navigation.ts.
Adding A New shadcn Primitive
When you need a component the kit does not ship (Calendar, Carousel, AI Elements), add it into packages/ui:
pnpm --filter @syntaxkit/ui dlx shadcn@latest add <component>The CLI reads packages/ui/components.json for style, base color, aliases, and registries:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@syntaxkit/ui/components",
"utils": "@syntaxkit/ui/lib/utils",
"ui": "@syntaxkit/ui/components",
"lib": "@syntaxkit/ui/lib",
"hooks": "@syntaxkit/ui/hooks"
},
"registries": {
"@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json"
}
}New files land in packages/ui/src/components/. Import as @syntaxkit/ui/components/<name>. The AI Elements registry is already wired:
pnpm --filter @syntaxkit/ui dlx shadcn@latest add @ai-elements/conversationThat pulls chat primitives (Conversation, Message, PromptInput, Suggestion) into the same folder. The kit's AI chat surface uses them; see AI.
Where To Go Next
Project Structure
Where packages/ui sits relative to the apps that consume it.
Conventions
cn / cva, kebab-case files, and the form pattern that uses these primitives.
Also useful: Internationalization, AI, and Authentication.
Internationalization
Two locale strategies on next-intl. URL prefix for marketing and auth, NEXT_LOCALE cookie for the dashboard. Package layout, message catalogs, switchers, User.locale for emails, and how to add a locale.
Deployment
Deploy apps/web and apps/docs with Next.js standalone output. Run prisma migrate deploy before the new version takes traffic. Configs ship for Vercel, Fly.io, Render, and Docker Compose.
