Theming
Color tokens, fonts, light and dark mode with next-themes, the marketing surface, shadcn/ui components and motion tokens.
Launch Now styles everything with Tailwind CSS v4 and CSS variables. The app shell follows the user's light or dark preference, while public pages share a fixed warm-paper look. You change the whole palette by editing tokens in one file, app/globals.css.
Where the code lives
| Path | What it contains |
|---|---|
app/globals.css | Tailwind imports, the @theme inline mapping, light (:root) and dark (.dark) tokens, and the .marketing surface. |
lib/fonts/index.ts | Font definitions with next/font/google. |
components/theme-provider.tsx | ThemeProvider wrapping next-themes, plus the D keyboard shortcut. |
components/theme-toggle.tsx | ThemeDropdownItem, the Light / Dark / System submenu in the user dropdown. |
lib/providers.tsx | Mounts ThemeProvider at the top of the client provider tree. |
components.json | shadcn/ui configuration: style, aliases and extra registries. |
components/ui/ | shadcn/ui primitives, built on Base UI. |
lib/ease.ts | Shared easing curves and spring presets for motion. |
Color tokens
app/globals.css follows the shadcn/ui convention: semantic tokens are CSS variables, and @theme inline maps each one to a Tailwind color so you can write bg-primary or text-muted-foreground.
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-primary: var(--primary);
--color-background: var(--background);
/* ... */
--radius-lg: var(--radius);
}Light values live in :root and dark values in .dark. Colors use oklch():
:root {
--radius: 0.625rem;
--background: oklch(1 0 180);
--foreground: oklch(0.145 0 180);
--primary: oklch(0.577 0.215 27.319);
--primary-foreground: oklch(0.971 0.013 17.376);
/* ... */
}
.dark {
--background: oklch(0.145 0 180);
--foreground: oklch(0.985 0 180);
--primary: oklch(0.577 0.215 27.319);
/* ... */
}The token set:
| Group | Tokens |
|---|---|
| Surfaces | --background, --foreground, --card, --card-foreground, --popover, --popover-foreground |
| Actions | --primary, --primary-foreground, --secondary, --secondary-foreground, --accent, --accent-foreground, --destructive, --destructive-foreground |
| Neutrals | --muted, --muted-foreground, --border, --input, --ring |
| Charts | --chart-1 to --chart-5 |
| Sidebar | --sidebar, --sidebar-foreground, --sidebar-primary, --sidebar-primary-foreground, --sidebar-accent, --sidebar-accent-foreground, --sidebar-border, --sidebar-ring |
| Radius | --radius (0.625rem). --radius-sm to --radius-4xl are derived from it (×0.6 to ×2.6). |
Change the brand color
The default primary is a red (oklch(0.577 0.215 27.319)), used for both --primary and --ring in light and dark mode. The sidebar reuses it through --sidebar-primary: var(--primary). To rebrand, change --primary, --primary-foreground and --ring in both :root and .dark:
:root {
--primary: oklch(0.55 0.2 260);
--primary-foreground: oklch(0.98 0 0);
--ring: oklch(0.55 0.2 260);
}Any shadcn theme generator that outputs oklch CSS variables produces a block you can paste over :root and .dark.
Add a token
Declare the variable in :root and .dark, then map it in @theme inline to get a Tailwind utility:
@theme inline {
--color-success: var(--success);
}
:root {
--success: oklch(0.6 0.15 150);
}
.dark {
--success: oklch(0.7 0.15 150);
}You can now use bg-success and text-success.
Light and dark mode
Theme switching uses next-themes. ThemeProvider sets these defaults:
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
<ThemeHotkey />
{children}
</NextThemesProvider>attribute="class"addsdarkto<html>, which activates the.darktokens and thedark:variant (@custom-variant dark (&:is(.dark *))).- New visitors follow their operating system (
defaultTheme="system"). app/layout.tsxsetssuppressHydrationWarningon<html>because next-themes changes its class before hydration.
Users can switch theme in three places:
| Where | How |
|---|---|
| User dropdown | Theme submenu with Light, Dark and System (ThemeDropdownItem in components/user-dropdown.tsx). |
| Keyboard | Press D anywhere outside a text field to toggle light and dark (ThemeHotkey). The shortcut ignores key repeats and modifier keys. |
| Command palette | ⌘K / Ctrl+K opens the search command (components/layouts/sidebar/search-command.tsx), which has light and dark actions under Appearance. |
To read or set the theme in your own client component, use the useTheme hook from next-themes:
"use client"
import { useTheme } from "next-themes"
export function MyToggle() {
const { resolvedTheme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
Toggle
</button>
)
}To remove the D shortcut, delete <ThemeHotkey /> from components/theme-provider.tsx.
The marketing surface
Public pages don't follow the dark mode setting. They render inside an element with the .marketing class, which redefines every shadcn token with a light, warm-paper palette and sets color-scheme: light. Because the tokens are set on that element, they take precedence over the .dark values on <html>.
These routes use it:
app/page.tsx(landing page) and every page wrapped inMarketingShell(features/marketing/marketing-shell.tsx): pricing, blog index, changelog, status and legal pages.app/auth/layout.tsxandapp/docs/layout.tsx, which add.marketing-plainto drop the paper-grain overlay.
The .marketing scope also defines brand tokens exposed as Tailwind colors:
| Variable | Tailwind color | Default |
|---|---|---|
--brand-ink | brand-ink | #1a1614 |
--brand-flare | brand-flare | #f30 |
--brand-accent | brand-accent | #c4320a |
--brand-void | brand-void | #08090a |
--brand-paper | brand-paper | #f8f7f4 |
--marketing-coral | marketing-coral | #f26a4b |
--marketing-lime | marketing-lime | #ffe3d9 |
--marketing-wash | marketing-wash | #fbeee7 |
These variables only exist inside .marketing, so the matching utilities only work on public pages.
Helper classes in the same file:
| Class | Effect |
|---|---|
.marketing-layer | Lifts content above the paper grain. |
.marketing-plain | Removes the grain (auth and docs). |
.marketing-rule, .marketing-gutters | Hairline horizontal and vertical rules. |
.marketing-underline, .marketing-underline-draw | Highlighter underline for one word in a headline, with a draw-in animation. |
.marketing-rise, .marketing-caret | Entrance and blinking-caret animations. |
.marketing-nav-frost | Frosted nav background driven by the --nav-frost custom property. |
.marketing-cta | Raised shadow and press transition for CTA buttons (also applied to primary buttons inside .marketing). |
All animations are disabled under prefers-reduced-motion: reduce.
The docs site (Fumadocs) reads its own --color-fd-* variables. globals.css points them at the marketing tokens, so /docs matches the rest of the public site. app/docs/layout.tsx passes theme={{ enabled: false }} to Fumadocs' RootProvider so the root ThemeProvider stays in charge.
To rebrand public pages, edit the values in the .marketing block. body:has(.marketing) also hardcodes the page background (#f8f7f4), so update it with --background.
Fonts
Fonts load through next/font/google in lib/fonts/index.ts and are exposed as CSS variables on <html> by app/layout.tsx:
| Export | Font | CSS variable | Tailwind |
|---|---|---|---|
geist | Geist | --font-sans | font-sans (default body font) |
geistMono | Geist Mono | --font-mono | font-mono |
instrumentSerif | Instrument Serif 400 | --font-serif | font-serif |
instrumentSerifItalic | Instrument Serif 400 italic | --font-instrument-italic | font-elegant |
--font-heading is mapped to --font-sans in @theme inline.
To swap a font, change the import in lib/fonts/index.ts and keep the same variable name:
import { Inter } from "next/font/google"
const geist = Inter({ subsets: ["latin"], variable: "--font-sans" })Good to know: lib/fonts/ also contains geist.woff2, instrument-serif.woff2 and instrument-serif-italic.woff2. The current code doesn't import them. Use them with next/font/local if you prefer self-hosted files over Google Fonts.
shadcn/ui components
components.json configures the shadcn CLI:
{
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}The base-nova style builds components on Base UI (@base-ui/react) instead of Radix. Icons come from lucide-react.
Add a component
Run the shadcn CLI from the project root:
npx shadcn@latest add buttonThe component lands in components/ui/ and uses the tokens from app/globals.css.
components.json also registers extra registries, so you can install from them by namespace (@namespace/component-name):
| Namespace | Registry URL |
|---|---|
@loading-ui | https://loading-ui.com/r/{name}.json |
@evilcharts | https://evilcharts.com/r/{name}.json |
@blocks-so | https://blocks.so/r/{name}.json |
@shadcn-space | https://shadcnspace.com/r/{name}.json |
@spell | https://spell.sh/r/{name}.json |
@spectrumui | https://ui.spectrumhq.in/r/{name}.json |
Component folders
| Folder | Contents |
|---|---|
components/ui/ | shadcn/ui primitives (button, dialog, sidebar, table, chart and more) plus a few custom ones such as settings-card.tsx, stat.tsx and submit-button.tsx. |
components/evilcharts/ | ECharts-based charts from the EvilCharts registry: charts/echarts-area-chart.tsx, charts/echarts-bar-chart.tsx and shared pieces in ui/ (tooltip, legend, brush, dot). The dashboard uses them. Chart colors support separate light and dark values (.dark selector). |
components/motion/ | rolling-number.tsx, an animated digit counter built with motion, used by the pricing sections. It respects reduced motion. |
components/spectrumui/ | animateddrawer.tsx, a vaul drawer from Spectrum UI. Not used by any page and ignored by knip. |
components/shadcn-space/ | Empty tabs/ folder, a target for components installed from the @shadcn-space registry. |
components/layouts/ | App layout primitives (Page, PageHeader…), the sidebar and nav configs. |
Motion tokens
lib/ease.ts centralizes easing curves and spring presets for the motion library:
| Export | Value | Use for |
|---|---|---|
EASE_OUT | [0.16, 1, 0.3, 1] | Default ease-out. |
EASE_OUT_CSS | cubic-bezier(0.16, 1, 0.3, 1) | Same curve as a CSS string for inline transitions. |
EASE_IN_OUT | [0.77, 0, 0.175, 1] | Symmetric moves. |
EASE_DRAWER | [0.32, 0.72, 0, 1] | Drawers. |
SPRING_PRESS | stiffness 500, damping 30, mass 0.6 | Button press feedback. |
SPRING_SWAP | stiffness 460, damping 30, mass 0.55 | Label or icon swaps inside a control. |
SPRING_PANEL | stiffness 420, damping 40, mass 0.5 | Modal and sheet entrances. |
SPRING_LAYOUT | stiffness 360, damping 32, mass 0.6 | Shared-layout glides. |
SPRING_MOUSE | stiffness 200, damping 15, mass 0.3 | Cursor-follow effects. |
SPRING_GLIDE | stiffness 700, damping 50, mass 0.5 | Dragged handles with useSpring. |
import { SPRING_PRESS } from "@/lib/ease"
import { motion } from "motion/react"
export function PressableCard() {
return <motion.div whileTap={{ scale: 0.97 }} transition={SPRING_PRESS} />
}The marketing CSS animations in globals.css use the same cubic-bezier(0.16, 1, 0.3, 1) curve as EASE_OUT.