Next Starter Logo
Tutorials

Customizing the Theme

How to customize the theme in a Next.js app: change brand colors, corner radius, fonts, and dark mode through the Tailwind CSS v4 variables in globals.css.

The whole look of the app comes from a set of CSS variables (also called design tokens) in app/globals.css. A CSS variable is a named value, like --primary: blue, that the rest of the styles reuse. Tailwind CSS v4 turns each variable into a utility class, and every shadcn/ui component is built from those classes. Change one variable and every button, card, input, and sidebar item that uses it updates at once. You edit colors in one file instead of in each component.

How globals.css is structured

The file has three blocks you care about:

BlockWhat it does
@theme inlineConnects each CSS variable to a Tailwind token, e.g. --color-primary: var(--primary). It also defines the --radius-* size scale and --font-sans. Edit this only when you add a brand-new token.
:rootThe values used in light mode, one line per variable.
.darkThe values used in dark mode. These override :root whenever the dark class is on the <html> tag.

Dark mode is switched on by Tailwind v4's @custom-variant dark (&:is(.dark *)). That one line ties the .dark block and all dark: utility classes to the same dark class, so they turn on together.

Colors are written in the oklch(lightness chroma hue) format. lightness runs 0 to 1 (dark to light), chroma is how colorful it is (0 to about 0.37), and hue is the color-wheel angle from 0 to 360. Pick values visually at oklch.com and paste them in.

Change brand colors

Change a color in both :root and .dark, or it will only update in one mode. The --primary token is your main brand color. It's the fill on buttons and badges and the color of text links:

app/globals.css
:root {
  --primary: oklch(0.5657 0.183562 254.571); /* blue */
  --primary-hover: oklch(0.4727 0.14737 253.3486);
  --primary-foreground: oklch(0.985 0 0); /* text on primary */
}

--sidebar-accent is set to the same blue as --primary in both modes, but it's a separate token. If you change --primary, update --sidebar-accent to match, otherwise the active sidebar item keeps the old color.

Most colors come in a pair: the base color and a *-foreground color for text or icons placed on top of it. --primary is the button background and --primary-foreground is the label text. Keep enough contrast between the two so text stays readable.

The full set you can override:

  • --background / --foreground: page background and default text
  • --card, --popover: surfaces that sit above the page
  • --primary / --primary-hover: brand color and its hover shade
  • --secondary: an accent (an orange in this template)
  • --muted, --accent: subtle backgrounds for less important UI
  • --destructive: errors and delete actions (red)
  • --border, --input, --ring: outlines, field borders, and focus rings
  • the --sidebar-* group: the dashboard sidebar
  • --chart-1 through --chart-5: chart series colors
  • --charcoal, --success, --warning: custom extras added on top of the shadcn defaults

Most of these have a matching *-foreground for text or icons on top, for example --success-foreground. The outline tokens (--border, --input, --ring) and the chart colors are single values with no foreground.

One thing to watch: --secondary is toned down in .dark (oklch(0.6 0.18 43.18)) so it doesn't glow against the dark background. Want it equally vivid in both modes? Set the same value in :root and .dark.

Change the corner roundness

One variable controls how rounded corners are across the app. The sm / md / lg / xl steps are calculated from it in @theme inline, so you only edit this one line:

app/globals.css
:root {
  --radius: 0.625rem; /* about 10px. Lower for sharper corners, higher for rounder */
}

Change fonts

The default font is Inter, loaded in app/layout.tsx with Next.js's next/font/google helper. It's exposed as the CSS variable --font-inter, applied on the <html> tag, and @theme inline maps that variable to --font-sans.

To use a different Google font, change the import and the function call. Keep the variable name --font-inter so the existing mapping still works:

app/layout.tsx
import { Geist } from "next/font/google";

const font = Geist({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter", // keep this name; @theme inline points at it
});

Then make sure <html> still gets className={font.variable}.

For a font file you host yourself, use next/font/local with the same variable. To add a separate heading font, register a new token in @theme inline (for example --font-heading: var(--font-cal-sans), sans-serif), load that font in layout.tsx, and apply it in markup with the font-heading class.

Dark mode

Dark mode is handled by ThemeProvider (components/theme-provider.tsx), a thin wrapper around the next-themes library. It's mounted once in app/layout.tsx:

app/layout.tsx
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>

What these options do:

  • attribute="class" adds or removes the dark class on <html>, which is what turns the .dark color block on or off.
  • defaultTheme="system" with enableSystem follows the visitor's operating-system setting until they pick a theme.
  • disableTransitionOnChange skips the color fade when switching, so the page doesn't flash.

shadcn components switch automatically because they read the same CSS variables. You don't touch the components.

The ThemeToggle (components/theme-toggle.tsx) is the visible light/dark switch. It calls setTheme("light") or setTheme("dark") from next-themes. Its variant prop takes "dropdown", "overlay", or "footer", which change its layout and padding for the sidebar dropdown menu, an overlay menu, and the footer.

To lock the app to one theme, remove enableSystem and set a fixed default:

<ThemeProvider attribute="class" defaultTheme="light">

Why you never edit the components

shadcn/ui components are styled only with the token classes, never with hard-coded colors. Button uses bg-primary text-primary-foreground, Card uses bg-card, Input uses border-input, and Badge's secondary variant uses bg-secondary. Change the matching variable in globals.css and every one of them restyles, with no component edits. To check which tokens a component uses, open its source in components/ui/.

Add a new design token

To add your own color, define it in all three blocks. This is exactly how --success and --warning were added to the template:

app/globals.css
@theme inline {
  --color-info: var(--info);
  --color-info-foreground: var(--info-foreground);
}

:root {
  --info: oklch(0.55 0.18 230);
  --info-foreground: oklch(0.985 0 0);
}

.dark {
  --info: oklch(0.6 0.18 230);
  --info-foreground: oklch(0.985 0 0);
}

You can now use bg-info and text-info-foreground anywhere in your markup, and they'll respond to light and dark mode like every built-in token.

On this page