Customization
Theming, branding, and extending the UI.
Last updated on
7 min readThe UI in SyntaxKit is built on Tailwind CSS v4 and shadcn/ui primitives in the new-york style. Every color, radius, and font lives as a CSS variable in one file, so most rebrands are a token edit, not a component edit.
The primitives themselves (Button, Card, Input, Dialog, etc.) are copied into the workspace at packages/ui/src/components/, not pulled in as a third-party version. Adding a Button variant or a brand-new component does not require forking anything; the source is already next to your code.
How customization is layered
Tokens to primitives to app components to routes. Edit at the lowest layer.
Brand tokens
The highest-leverage retheme: one CSS file of OKLCH variables.
Base components
shadcn-style primitives in your repo, typed with cva variants.
Marketing and site config
The brand and marketing objects that own non-component branding.
How Customization Is Layered
Tokens flow into primitives, primitives flow into app components, and app components compose into the routes. Site and marketing config sit next to the components and feed strings, ordering, and assets without touching component source. Edit at the lowest layer that solves your problem.
The diagram source lives at apps/docs/diagrams/customization-layers.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
Brand Tokens
The single highest-leverage customization in the kit. Every color, every radius, every font in the product app traces back to a CSS variable in packages/ui/src/styles/globals.css. Tailwind v4 reads these variables directly via the @theme inline block, so utility classes like bg-primary and rounded-lg resolve to whatever you set in :root and .dark.
The fastest way to retheme is the official shadcn/ui Create tool. It's a visual playground for the entire token surface (style, base color, primary and accent colors, chart palette, heading and body fonts, icon library, radius, menu styles) with a live preview. When you're happy, copy the preset code and apply it to the kit.
Configure your theme
Open ui.shadcn.com/create and pick your style, base color (Slate, Gray, Zinc, Neutral, Stone, etc.), primary and accent colors, chart palette, heading and body fonts, icon library, and radius. The preview on the right updates as you go.
Copy the preset code
Once the preview matches what you want, copy the preset identifier the page generates.
Apply it to the kit
Run the shadcn CLI scoped to the @syntaxkit/ui package so the CLI picks up packages/ui/components.json:
pnpm --filter @syntaxkit/ui dlx shadcn@latest init --preset [CODE]The CLI rewrites the :root and .dark blocks in packages/ui/src/styles/globals.css with the OKLCH variables your preset picked, and updates the font and icon entries in components.json.
Reload
Tailwind v4 picks the change up on the next page reload. No rebuild, no tailwind.config.js to touch.
Tailwind v4 reads tokens straight from CSS via @theme inline, so the kit ships no tailwind.config.js. The first ten lines of packages/ui/src/styles/globals.css (@import "tailwindcss", @source ..., @theme inline { ... }) replace the entire JS config you used to write.
Hand-editing tokens
For one-off tweaks that don't justify a full preset (nudging just --primary, swapping --radius, tinting the sidebar) edit packages/ui/src/styles/globals.css directly. The defaults use OKLCH, a perceptually uniform color space, and ship a violet brand:
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.541 0.281 293.009);
--primary-foreground: oklch(0.969 0.016 293.756);
--secondary: oklch(0.967 0.001 286.375);
--muted: oklch(0.967 0.001 286.375);
--accent: oklch(0.967 0.001 286.375);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--ring: oklch(0.702 0.183 293.541);
/* ... plus chart, sidebar, and -foreground variants */
}The .dark block right below it overrides the same variables for dark mode; both blocks always carry the same keys so a class flip on <html> swaps the entire palette without flicker. Pick OKLCH triples via oklch.com; stay above L 0.4 for accessible contrast on the default white background and below L 0.65 in dark mode.
Other tokens worth knowing about:
--radiuscontrols the global border radius. Tailwind'srounded-sm/rounded-md/rounded-lg/rounded-xlall derive from it.- The
--sidebar-*set drives the dashboard sidebar palette independently from the rest of the app, so a tinted sidebar is a token edit. --font-sansand--font-monoreference the Geist variables wired inapps/web/app/layout.tsx. Swap fonts there, not here.
Fonts
The product app loads Geist and Geist Mono via Next font inside 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"],
});The @theme inline block in globals.css then aliases --font-sans: var(--font-geist-sans) and --font-mono: var(--font-geist-mono), so utilities like font-sans and font-mono resolve correctly. To swap fonts, change the import in apps/web/app/layout.tsx and keep the same variable names; the rest of the kit picks the change up.
Light And Dark Mode
Dark mode runs on next-themes, mounted in apps/web/app/layout.tsx:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>attribute="class" flips a dark class on <html> when the user picks dark, which activates the .dark block in globals.css and swaps every token in one paint. defaultTheme="system" respects the OS preference until the user opts in. disableTransitionOnChange prevents the brief animation flash on theme swap.
The shipped toggle lives at apps/web/components/theme-toggle.tsx and uses the useTheme hook re-exported from @syntaxkit/ui/components/theme-provider. Drop it anywhere in your layouts:
import { ThemeToggle } from "@/components/theme-toggle";
export function Header() {
return (
<header>
{/* ... */}
<ThemeToggle />
</header>
);
}Base Components
Primitives at packages/ui/src/components/ are shadcn-style: code that lives in your repo, not a versioned dependency. Each one combines Radix UI headless behavior, class-variance-authority for typed variants, and the cn helper for safe class merging. packages/ui/src/components/button.tsx is the canonical 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",
},
}
);Six visual variants and eight sizes are typed automatically via VariantProps<typeof buttonVariants>. To add your own variant:
Edit the cva block
Open packages/ui/src/components/button.tsx and add a key under variants.variant. For a brand gradient:
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 automatically because cva re-exports VariantProps. Misspelled variants are caught at compile time.
cn (from packages/ui/src/lib/utils.ts) is clsx plus tailwind-merge. The merge guarantees that a caller-side class wins over the cva default for the same utility: <Button className="rounded-none"> overrides the variant's rounded-md cleanly, no specificity wars.
Composing App Components
App-level components live in apps/web/components/ and import primitives from @syntaxkit/ui/components/<name>. They're the layer where buyers usually do most of their day-to-day work. Two short references:
Logo (apps/web/components/logo.tsx). A five-line component that reads text-primary for the colored half of the wordmark. Replacing it rebrands every header, footer, and auth surface in the kit.
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 the useTheme hook. A good template when you want to combine a primitive with a Radix-backed menu and your own 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>
);
}The same folder houses every product surface: apps/web/components/marketing/* for the homepage sections, apps/web/components/dashboard/* for the authenticated experience, apps/web/components/auth/* for sign-in and sign-up forms. New product components belong here, mirroring the kebab-case filename convention covered on Working With The Codebase.
Marketing And Site Config
Two TypeScript objects own most non-component branding. Edit either to customize the homepage without touching component code. No restart needed; the dev server picks up changes on save.
Brand config
packages/brand/src/index.ts: name, wordmark split, description, app/docs/pricing URLs, social handles, legal entity, and SEO defaults. Consumed by metadata, OpenGraph, the Logo, footers, email templates, and every privacy/terms/license page.
Marketing config
apps/web/config/marketing.ts: homepage section visibility and ordering, hero badge href, feature lists, pricing tiers, testimonials, and FAQ items. Structural and asset choices only; translatable copy lives in messages/*.json.
For deeper navigation customization (sidebar groups, breadcrumb labels), apps/web/config/navigation.ts is the third configuration file, owned by the dashboard shell.
Adding A New shadcn Primitive
When the existing primitives don't cover a use case (a Calendar, Carousel, or one of the AI Elements), pull a fresh component into packages/ui with the shadcn CLI:
pnpm --filter @syntaxkit/ui dlx shadcn@latest add <component>The CLI reads packages/ui/components.json for style: "new-york", baseColor: "zinc", the alias map, and any registered 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 components land in packages/ui/src/components/; import them via @syntaxkit/ui/components/<name>. The AI Elements registry is pre-wired, so:
pnpm --filter @syntaxkit/ui dlx shadcn@latest add @ai-elements/conversationpulls the chat-shaped primitives (Conversation, Message, PromptInput, Suggestion) into the same folder. The kit's existing AI chat surface uses these, see the AI page for a worked example.
Where To Go Next
Project Structure
Where packages/ui sits relative to the apps that consume it, plus the rest of the monorepo layout.
Working With The Codebase
The cn / cva conventions in context, plus the kebab-case file naming and the form pattern that uses the same primitives.
Internationalization
Where translatable copy lives so you don't bake strings into marketing.ts.
AI
A worked example of the AI Elements registry in production use.
Authentication
The auth surfaces (login, signup, 2FA) compose the same primitives, useful when buyers ask where to customize the login form.
