Skip to content
GitHubTwitterDiscord

Dark Mode Implementation

LaunchKit includes a complete dark mode implementation using next-themes and shadcn/ui components, providing seamless theme switching with system preference detection.

  • Seamless Theme Switching: Toggle between light, dark, and system themes
  • System Theme Detection: Automatically respects user’s system preference
  • No Flash on Load: Proper hydration handling prevents theme flash
  • Persistent Theme: User’s theme preference is saved in localStorage
  • Accessible: Full keyboard navigation and screen reader support

Dark mode is already configured in LaunchKit! The implementation includes:

  1. Theme Provider: Wraps the entire application with theme context
  2. Toggle Components: Ready-to-use theme switcher components
  3. CSS Variables: Predefined color schemes for light/dark modes
  4. Configuration: Centralized theme settings in config.ts

The required dependency is already included in LaunchKit:

# Already installed in LaunchKit
next-themes

If you need to install it manually:

bun add next-themes
# or
npm install next-themes

The ThemeProvider component wraps your entire application:

// components/theme-provider.tsx
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

The root layout includes the ThemeProvider with optimal configuration:

// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
import config from "@/config";

export default function RootLayout({ children }) {
  return (
    <html suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme={config.theme.defaultTheme}
          enableSystem={config.theme.enableSystem}
          disableTransitionOnChange={config.theme.disableTransitionOnChange}
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

LaunchKit provides two theme toggle components:

// components/ui/theme-toggle.tsx
// Cycles through: light → dark → system → light
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

export function ThemeToggle() {
  const { setTheme, theme } = useTheme();

  const handleToggle = () => {
    if (theme === "light") setTheme("dark");
    else if (theme === "dark") setTheme("system");
    else setTheme("light");
  };

  return (
    <Button variant="outline" size="icon" onClick={handleToggle}>
      <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>
  );
}
// components/ui/theme-toggle-dropdown.tsx
// Shows all options in a dropdown menu
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function ThemeToggleDropdown() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <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")}>
          <Sun className="mr-2 h-4 w-4" />
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          <Moon className="mr-2 h-4 w-4" />
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          <Monitor className="mr-2 h-4 w-4" />
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Theme settings are centralized in config.ts:

// config.ts
const config = {
  // ... other config
  theme: {
    defaultTheme: "system", // "light" | "dark" | "system"
    enableSystem: true,
    disableTransitionOnChange: true,
    themes: ["light", "dark", "system"],
  },
};

LaunchKit uses CSS custom properties for theming, defined in globals.css:

/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 0 0% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 0 0% 3.9%;
    --primary: 0 0% 9%;
    --primary-foreground: 0 0% 98%;
    --secondary: 0 0% 96.1%;
    --secondary-foreground: 0 0% 9%;
    --muted: 0 0% 96.1%;
    --muted-foreground: 0 0% 45.1%;
    --accent: 0 0% 96.1%;
    --accent-foreground: 0 0% 9%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 0 0% 89.8%;
    --input: 0 0% 89.8%;
    --ring: 0 0% 3.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 0 0% 3.9%;
    --foreground: 0 0% 98%;
    --card: 0 0% 3.9%;
    --card-foreground: 0 0% 98%;
    --popover: 0 0% 3.9%;
    --popover-foreground: 0 0% 98%;
    --primary: 0 0% 98%;
    --primary-foreground: 0 0% 9%;
    --secondary: 0 0% 14.9%;
    --secondary-foreground: 0 0% 98%;
    --muted: 0 0% 14.9%;
    --muted-foreground: 0 0% 63.9%;
    --accent: 0 0% 14.9%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 0 0% 98%;
    --border: 0 0% 14.9%;
    --input: 0 0% 14.9%;
    --ring: 0 0% 83.1%;
  }
}

Import and use the theme toggle component anywhere in your app:

import { ThemeToggle } from "@/components/ui/theme-toggle";
// or
import { ThemeToggleDropdown } from "@/components/ui/theme-toggle-dropdown";

function Navbar() {
  return (
    <nav className="flex items-center justify-between p-4">
      <div>LaunchKit</div>
      <div className="flex items-center gap-4">
        <ThemeToggle />
        {/* or */}
        <ThemeToggleDropdown />
      </div>
    </nav>
  );
}

Use the useTheme hook to access theme state:

"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

function ThemeAwareComponent() {
  const { theme, setTheme, systemTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  // Prevent hydration mismatch
  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null;

  return (
    <div>
      <p>Current theme: {theme}</p>
      <p>System theme: {systemTheme}</p>
      <button onClick={() => setTheme("dark")}>Switch to Dark</button>
    </div>
  );
}

Use Tailwind’s dark mode classes for conditional styling:

function Card() {
  return (
    <div className="bg-white dark:bg-gray-900 text-black dark:text-white border border-gray-200 dark:border-gray-800 rounded-lg p-6">
      <h2 className="text-xl font-bold mb-4">
        This adapts to the current theme
      </h2>
      <p className="text-gray-600 dark:text-gray-400">
        Content that changes based on theme
      </p>
    </div>
  );
}
  1. Use CSS Variables: Leverage the predefined CSS variables for consistent theming
  2. Test Both Themes: Always test your components in both light and dark modes
  3. Respect System Preference: Default to “system” theme for better UX
  4. Avoid Theme Flash: Use suppressHydrationWarning on the html element
  5. Accessible Icons: Ensure theme toggle icons have proper ARIA labels
  6. Handle Hydration: Use mounted state to prevent hydration mismatches

If you see a flash of the wrong theme on page load:

// ✅ Correct: Add suppressHydrationWarning
<html suppressHydrationWarning>
  <body>
    <ThemeProvider disableTransitionOnChange>{children}</ThemeProvider>
  </body>
</html>

If the theme doesn’t persist across page reloads:

  • Verify localStorage is available in your environment
  • Check that the ThemeProvider is properly wrapping your app
  • Ensure you’re not overriding the theme elsewhere

If styles don’t update when switching themes:

// tailwind.config.js - Ensure darkMode is configured
module.exports = {
  darkMode: ["class"], // ✅ Enable class-based dark mode
  // ... rest of config
};

The theme toggle works seamlessly on mobile devices:

  • Touch-friendly: Proper touch target sizes
  • Responsive: Adapts to different screen sizes
  • Accessible: Works with screen readers
  • Fast: No performance impact on mobile
// Mobile-optimized theme toggle
<div className="flex items-center gap-2 sm:gap-4">
  <span className="text-sm hidden sm:inline">Theme:</span>
  <ThemeToggle />
</div>

To add custom themes beyond light/dark:

  1. Update config.ts:
theme: {
  themes: ["light", "dark", "system", "purple", "blue"],
}
  1. Add CSS variables:
.purple {
  --primary: 270 95% 75%;
  --primary-foreground: 270 10% 15%;
  /* ... other variables */
}
  1. Update toggle component:
<DropdownMenuItem onClick={() => setTheme("purple")}>
  <Palette className="mr-2 h-4 w-4" />
  Purple
</DropdownMenuItem>

Customize the theme toggle using shadcn/ui variants:

<Button
  variant="ghost" // Change variant: ghost, outline, secondary
  size="sm" // Change size: sm, default, lg
  className="rounded-full" // Add custom classes
>
  {/* Toggle content */}
</Button>

Switch images based on theme:

import { useTheme } from "next-themes";
import Image from "next/image";

function Logo() {
  const { theme, systemTheme } = useTheme();
  const currentTheme = theme === "system" ? systemTheme : theme;

  return (
    <Image
      src={currentTheme === "dark" ? "/logo-dark.png" : "/logo-light.png"}
      alt="LaunchKit"
      width={120}
      height={40}
    />
  );
}

Create theme-aware animations:

import { motion } from "framer-motion";
import { useTheme } from "next-themes";

function AnimatedBackground() {
  const { theme } = useTheme();

  return (
    <motion.div
      className="absolute inset-0"
      animate={{
        background:
          theme === "dark"
            ? "linear-gradient(45deg, #1a1a1a, #2d2d2d)"
            : "linear-gradient(45deg, #ffffff, #f8f9fa)",
      }}
      transition={{ duration: 0.5 }}
    />
  );
}

Your dark mode implementation is ready! LaunchKit provides everything you need for a professional theme switching experience that your users will love.