DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

4 Dark Mode UI Components Every SaaS App Needs (Tailwind CSS v4 + React)

You don't need a full design system on day one. These 4 components — Button, Input, Card, Badge — cover 90% of a SaaS dashboard UI. Here they are, built with Tailwind CSS v4 and React.

1. Button

A button with 5 variants, 3 sizes, and a loading state:

import { forwardRef, ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/utils"; // clsx + tailwind-merge

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "ghost" | "danger" | "outline";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "primary", size = "md", loading, children, disabled, ...props }, ref) => (
    <button
      ref={ref}
      disabled={disabled || loading}
      className={cn(
        "inline-flex items-center justify-center font-medium rounded-lg transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none",
        {
          "bg-white text-zinc-900 hover:bg-zinc-100": variant === "primary",
          "bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700": variant === "secondary",
          "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50": variant === "ghost",
          "bg-red-600 text-white hover:bg-red-500": variant === "danger",
          "border border-zinc-700 text-zinc-300 hover:bg-zinc-800": variant === "outline",
        },
        {
          "h-8 px-3 text-xs": size === "sm",
          "h-10 px-4 text-sm": size === "md",
          "h-12 px-6 text-base": size === "lg",
        },
        className,
      )}
      {...props}
    >
      {loading && (
        <svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
        </svg>
      )}
      {children}
    </button>
  ),
);
Enter fullscreen mode Exit fullscreen mode

2. Input

With label, error state, and proper focus rings:

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ className, label, error, id, ...props }, ref) => (
    <div className="space-y-1.5">
      {label && <label htmlFor={id} className="text-sm font-medium text-zinc-300">{label}</label>}
      <input
        ref={ref}
        id={id}
        className={cn(
          "flex h-10 w-full rounded-lg border bg-zinc-900 px-3 py-2 text-sm text-zinc-100",
          "placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50",
          error ? "border-red-500" : "border-zinc-700",
          className,
        )}
        {...props}
      />
      {error && <p className="text-xs text-red-400">{error}</p>}
    </div>
  ),
);
Enter fullscreen mode Exit fullscreen mode

3. Card

Composable card with header, title, and description:

export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
  return <div className={cn("rounded-xl border border-zinc-800 bg-zinc-900/50 p-6", className)} {...props} />;
}

export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
  return <h3 className={cn("text-lg font-semibold text-zinc-100", className)} {...props} />;
}

export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
  return <p className={cn("text-sm text-zinc-400", className)} {...props} />;
}
Enter fullscreen mode Exit fullscreen mode

4. Badge

5 color variants for status indicators:

export function Badge({ className, variant = "default", ...props }: BadgeProps) {
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
        {
          "bg-zinc-800 text-zinc-300": variant === "default",
          "bg-green-500/20 text-green-400": variant === "success",
          "bg-yellow-500/20 text-yellow-400": variant === "warning",
          "bg-red-500/20 text-red-400": variant === "danger",
          "bg-blue-500/20 text-blue-400": variant === "info",
        },
        className,
      )}
      {...props}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The cn() utility

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

clsx handles conditional classes. tailwind-merge resolves Tailwind conflicts. Together they let you compose className props without worrying about specificity.

Why these 4 are enough

  • Button — actions, forms, navigation
  • Input — forms, search, filters
  • Card — content containers, stats, settings sections
  • Badge — status indicators, labels, counts

With variants, these cover dashboards, settings pages, billing pages, and landing pages. Add more components when you actually need them.

Want the full set?

These components (plus auth, Stripe billing, AI chat, and a complete SaaS dashboard) come pre-built in LaunchKit.

GitHub | Get LaunchKit ($49)

Top comments (0)