Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Build With SyntaxKit

Customization

Theming, branding, and extending the UI.

Last updated on

7 min read

The 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

Theme tokens flow into base components flow into app components flow into routes. App and marketing config layer in beside the components.

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

index.tsSingle source of truth for brand name, wordmark, URLs, social handles, legal entity
components.jsonshadcn CLI config: style, baseColor, aliases, registries
icons.tsRe-exports lucide-react so app code imports from @syntaxkit/ui/icons

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:

  • --radius controls the global border radius. Tailwind's rounded-sm / rounded-md / rounded-lg / rounded-xl all 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-sans and --font-mono reference the Geist variables wired in apps/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/conversation

pulls 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

Was this page helpful?

On this page