DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

Dark Mode UI Components for SaaS: Button, Input, Card, Badge (Tailwind CSS v4)

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 *));
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<Button>Save Changes</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="destructive" size="sm">Delete Account</Button>
<Button variant="outline">Learn More</Button>
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Usage:

<Input label="Email" type="email" placeholder="you@example.com" />
<Input label="API Key" error="Invalid key format" />
Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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} />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)