Launch NowDocs

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

PathWhat it contains
app/globals.cssTailwind imports, the @theme inline mapping, light (:root) and dark (.dark) tokens, and the .marketing surface.
lib/fonts/index.tsFont definitions with next/font/google.
components/theme-provider.tsxThemeProvider wrapping next-themes, plus the D keyboard shortcut.
components/theme-toggle.tsxThemeDropdownItem, the Light / Dark / System submenu in the user dropdown.
lib/providers.tsxMounts ThemeProvider at the top of the client provider tree.
components.jsonshadcn/ui configuration: style, aliases and extra registries.
components/ui/shadcn/ui primitives, built on Base UI.
lib/ease.tsShared 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.

app/globals.css
@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():

app/globals.css
: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:

GroupTokens
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:

app/globals.css
: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:

app/globals.css
@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:

components/theme-provider.tsx
<NextThemesProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  disableTransitionOnChange
  {...props}
>
  <ThemeHotkey />
  {children}
</NextThemesProvider>
  • attribute="class" adds dark to <html>, which activates the .dark tokens and the dark: variant (@custom-variant dark (&:is(.dark *))).
  • New visitors follow their operating system (defaultTheme="system").
  • app/layout.tsx sets suppressHydrationWarning on <html> because next-themes changes its class before hydration.

Users can switch theme in three places:

WhereHow
User dropdownTheme submenu with Light, Dark and System (ThemeDropdownItem in components/user-dropdown.tsx).
KeyboardPress 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 in MarketingShell (features/marketing/marketing-shell.tsx): pricing, blog index, changelog, status and legal pages.
  • app/auth/layout.tsx and app/docs/layout.tsx, which add .marketing-plain to drop the paper-grain overlay.

The .marketing scope also defines brand tokens exposed as Tailwind colors:

VariableTailwind colorDefault
--brand-inkbrand-ink#1a1614
--brand-flarebrand-flare#f30
--brand-accentbrand-accent#c4320a
--brand-voidbrand-void#08090a
--brand-paperbrand-paper#f8f7f4
--marketing-coralmarketing-coral#f26a4b
--marketing-limemarketing-lime#ffe3d9
--marketing-washmarketing-wash#fbeee7

These variables only exist inside .marketing, so the matching utilities only work on public pages.

Helper classes in the same file:

ClassEffect
.marketing-layerLifts content above the paper grain.
.marketing-plainRemoves the grain (auth and docs).
.marketing-rule, .marketing-guttersHairline horizontal and vertical rules.
.marketing-underline, .marketing-underline-drawHighlighter underline for one word in a headline, with a draw-in animation.
.marketing-rise, .marketing-caretEntrance and blinking-caret animations.
.marketing-nav-frostFrosted nav background driven by the --nav-frost custom property.
.marketing-ctaRaised 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:

ExportFontCSS variableTailwind
geistGeist--font-sansfont-sans (default body font)
geistMonoGeist Mono--font-monofont-mono
instrumentSerifInstrument Serif 400--font-seriffont-serif
instrumentSerifItalicInstrument Serif 400 italic--font-instrument-italicfont-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:

lib/fonts/index.ts
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:

components.json
{
  "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 button

The 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):

NamespaceRegistry URL
@loading-uihttps://loading-ui.com/r/{name}.json
@evilchartshttps://evilcharts.com/r/{name}.json
@blocks-sohttps://blocks.so/r/{name}.json
@shadcn-spacehttps://shadcnspace.com/r/{name}.json
@spellhttps://spell.sh/r/{name}.json
@spectrumuihttps://ui.spectrumhq.in/r/{name}.json

Component folders

FolderContents
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:

ExportValueUse for
EASE_OUT[0.16, 1, 0.3, 1]Default ease-out.
EASE_OUT_CSScubic-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_PRESSstiffness 500, damping 30, mass 0.6Button press feedback.
SPRING_SWAPstiffness 460, damping 30, mass 0.55Label or icon swaps inside a control.
SPRING_PANELstiffness 420, damping 40, mass 0.5Modal and sheet entrances.
SPRING_LAYOUTstiffness 360, damping 32, mass 0.6Shared-layout glides.
SPRING_MOUSEstiffness 200, damping 15, mass 0.3Cursor-follow effects.
SPRING_GLIDEstiffness 700, damping 50, mass 0.5Dragged 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.

Next steps