Dark Mode UI Components for SaaS: Button, Input, Card, Badge (Tailwind CSS v4)
Every SaaS needs a solid component set. Dark mode is not optional anymore — users expect it, especially developer-facing products.
This post covers the four components you will use in every SaaS dashboard: Button, Input, Card, and Badge. All built with Tailwind CSS v4 and designed to support both light and dark mode with a single class toggle.
These are the exact components shipped in LaunchKit, the Next.js 16 SaaS starter kit.
Setup: Dark Mode in Tailwind v4
Tailwind v4 changes how dark mode works. Instead of the darkMode: 'class' config entry, you use a CSS-first approach.
In your global CSS:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Then on your root <html> element, toggle the dark class. In Next.js with next-themes:
// app/layout.tsx
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
}
Now all dark: variants will respond to the dark class on <html>.
Button Component
A button needs variants: primary, secondary, ghost, and destructive. Here is a clean implementation using class-variance-authority (cva) for type-safe variants.
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100',
secondary:
'bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700',
ghost:
'hover:bg-zinc-100 text-zinc-700 dark:hover:bg-zinc-800 dark:text-zinc-300',
destructive:
'bg-red-500 text-white hover:bg-red-600 dark:bg-red-700 dark:hover:bg-red-600',
outline:
'border border-zinc-200 bg-transparent hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800 dark:text-zinc-100',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-11 px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
Usage:
<Button>Save Changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="destructive" size="sm">Delete Account</Button>
<Button variant="outline">Learn More</Button>
Input Component
Inputs need to handle focus states, error states, and dark backgrounds cleanly.
// components/ui/input.tsx
import { cn } from '@/lib/utils';
import { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: string;
label?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, error, label, id, ...props }, ref) => {
return (
<div className="flex flex-col gap-1.5">
{label && (
<label
htmlFor={id}
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
{label}
</label>
)}
<input
id={id}
ref={ref}
className={cn(
'flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors',
'bg-white text-zinc-900 placeholder:text-zinc-400',
'border-zinc-200 focus:border-zinc-400 focus:outline-none focus:ring-1 focus:ring-zinc-400',
'dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500',
'dark:border-zinc-700 dark:focus:border-zinc-500 dark:focus:ring-zinc-500',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-500 focus:border-red-500 focus:ring-red-500',
className
)}
{...props}
/>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
Usage:
<Input label="Email" type="email" placeholder="you@example.com" />
<Input label="API Key" error="Invalid key format" />
Card Component
Cards are your dashboard's building blocks — stat boxes, feature sections, settings panels.
// components/ui/card.tsx
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'rounded-lg border bg-white text-zinc-900 shadow-sm',
'dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100',
className
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
);
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn('text-base font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn('text-sm text-zinc-500 dark:text-zinc-400', className)}
{...props}
/>
);
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex items-center p-6 pt-0 border-t border-zinc-100 dark:border-zinc-800 mt-auto',
className
)}
{...props}
/>
);
}
Usage:
<Card>
<CardHeader>
<CardTitle>Monthly Revenue</CardTitle>
<CardDescription>Compared to last month</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">$4,230</p>
</CardContent>
<CardFooter>
<span className="text-sm text-zinc-500">+12% from last month</span>
</CardFooter>
</Card>
Badge Component
Badges show status — subscription tiers, feature flags, user roles.
// components/ui/badge.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default:
'border-transparent bg-zinc-900 text-white dark:bg-white dark:text-zinc-900',
secondary:
'border-transparent bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
success:
'border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
warning:
'border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
destructive:
'border-transparent bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
outline:
'text-zinc-700 border-zinc-200 dark:text-zinc-300 dark:border-zinc-700',
pro:
'border-transparent bg-gradient-to-r from-violet-500 to-indigo-500 text-white',
},
},
defaultVariants: {
variant: 'default',
},
}
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
Usage:
<Badge>Active</Badge>
<Badge variant="pro">Pro</Badge>
<Badge variant="success">Paid</Badge>
<Badge variant="warning">Trial</Badge>
<Badge variant="destructive">Expired</Badge>
<Badge variant="outline">Free</Badge>
The cn Utility
All of these components use cn() — a simple utility that merges Tailwind classes safely:
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Install the deps: npm install clsx tailwind-merge class-variance-authority
All Together in a Dashboard
export default function DashboardPage() {
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<Badge variant="pro">Pro Plan</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle>Revenue</CardTitle>
<CardDescription>This month</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">$4,230</p>
</CardContent>
</Card>
{/* more stat cards */}
</div>
<Card>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input label="Display Name" defaultValue="Leo" />
<Input label="Email" type="email" defaultValue="leo@example.com" />
</CardContent>
<CardFooter className="gap-2">
<Button>Save Changes</Button>
<Button variant="ghost">Cancel</Button>
</CardFooter>
</Card>
</div>
);
}
Get the Full Component Library
These components (and more — Modal, Dropdown, Toast, Table, Tabs) ship with LaunchKit, the Next.js 16 SaaS starter kit. Everything is wired up: dark mode toggle, Tailwind v4, TypeScript, the full dashboard shell.
No Shadcn setup required — it is all included and ready to customize.
LaunchKit is $49 on Gumroad: https://yongshan5.gumroad.com/l/launchkit-nextjs-saas
Browse the code first: https://github.com/huangyongshan46-a11y/launchkit-saas-preview
Which component do you want to see next? Drop a request in the comments.
Top comments (0)