Command Palette

Search for a command to run...

How To Make an Interactive Website (Next.js + shadcn/ui)

How To Make an Interactive Website (Next.js + shadcn/ui)

A step‑by‑step, production‑minded guide to building an interactive website with Next.js, Tailwind, and shadcn/ui—covering IA, motion, forms, scroll effects, testing, and performance.

4 min read·Web Design

This guide takes you from blank repo to interactive site with a production stance: tokens first, ownable components, purposeful motion, and measurable performance.

Related reading:
Web Design Best Practices · UI Libraries · UI Animation


1) Plan the interactions

  • Define top 3 tasks users must complete.
  • Identify moments for feedback: nav, form submission, errors, success.
  • Decide which interactions need URLs (modals/drawers via parallel routes).

2) Bootstrap the project (pnpm)

pnpm create next-app@latest ruixen-site --ts --eslint --app
cd ruixen-site
 
# Tailwind
pnpm add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
 
# shadcn/ui (own the source)
pnpm dlx shadcn@latest init
pnpm dlx shadcn add button card dialog input tabs tooltip

Configure Tailwind content to include app and components.


3) Tokens & themes (light/dark)

/* app/globals.css */
:root {
  --bg: #ffffff;
  --fg: #0b0b0b;
  --brand: oklch(56% 0.2 262);
  --radius: 12px;
}
:root[data-theme="dark"] {
  --bg: #0b0b0b;
  --fg: #f5f5f5;
  --brand: oklch(70% 0.18 262);
}
html {
  color-scheme: light dark;
}
body {
  background: var(--bg);
  color: var(--fg);
}
// components/theme-toggle.tsx
"use client";
import * as React from "react";
export function ThemeToggle() {
  const [d, setD] = React.useState(false);
  React.useEffect(() => setD(true), []);
  if (!d) return null;
  return (
    <button
      onClick={() => {
        const root = document.documentElement;
        const dark = root.getAttribute("data-theme") === "dark";
        root.setAttribute("data-theme", dark ? "light" : "dark");
      }}
      className="rounded-md border px-3 py-1.5"
      aria-label="Toggle theme"
    >
      Theme
    </button>
  );
}

4) Navigation with feedback

// components/nav.tsx
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "./theme-toggle";
 
export function Nav() {
  return (
    <header className="sticky top-0 z-40 border-b bg-white/70 backdrop-blur supports-[backdrop-filter]:bg-white/50 dark:bg-black/30">
      <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
        <Link href="/" className="font-semibold">
          Ruixen
        </Link>
        <nav className="flex items-center gap-2">
          <Link href="/work" className="px-3 py-2">
            Work
          </Link>
          <Link href="/blog" className="px-3 py-2">
            Blog
          </Link>
          <Button asChild>
            <Link href="/contact">Contact</Link>
          </Button>
          <ThemeToggle />
        </nav>
      </div>
    </header>
  );
}

5) Micro‑interactions (tabs, tooltip, dialog)

Use shadcn/ui components (built on Radix) for a11y‑correct behavior you style yourself.

// app/demo/page.tsx (excerpt)
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
 
export default function Demo() {
  return (
    <div className="mx-auto max-w-4xl space-y-10 p-6">
      <Tabs defaultValue="a">
        <TabsList>
          <TabsTrigger value="a">Alpha</TabsTrigger>
          <TabsTrigger value="b">Beta</TabsTrigger>
        </TabsList>
        <TabsContent value="a">Content A</TabsContent>
        <TabsContent value="b">Content B</TabsContent>
      </Tabs>
 
      <TooltipProvider>
        <Tooltip>
          <TooltipTrigger className="underline">Hover me</TooltipTrigger>
          <TooltipContent>Helpful hint</TooltipContent>
        </Tooltip>
      </TooltipProvider>
 
      <Dialog>
        <DialogTrigger className="rounded-md border px-3 py-2">
          Open modal
        </DialogTrigger>
        <DialogContent>Dialog content</DialogContent>
      </Dialog>
    </div>
  );
}

6) Scroll progress & subtle motion

"use client";
import * as React from "react";
 
export function ScrollProgress() {
  const [w, setW] = React.useState(0);
  React.useEffect(() => {
    const onScroll = () => {
      const scrolled = window.scrollY;
      const height = document.body.scrollHeight - window.innerHeight;
      setW((scrolled / height) * 100);
    };
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);
  return (
    <div
      className="fixed inset-x-0 top-0 h-1 bg-brand"
      style={{ width: w + "%" }}
    />
  );
}

Mount <ScrollProgress /> in your root layout.


7) Forms with inline validation

"use client";
import * as React from "react";
 
export function SignupForm() {
  const [email, setEmail] = React.useState("");
  const [error, setError] = React.useState<string | null>(null);
 
  function submit(e: React.FormEvent) {
    e.preventDefault();
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
      setError("Enter a valid email.");
      return;
    }
    setError(null);
    // submit to server action or API
  }
 
  return (
    <form onSubmit={submit} className="space-y-2">
      <label className="block text-sm">Email</label>
      <input
        className="w-full rounded-md border px-3 py-2"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? "email-err" : undefined}
      />
      {error && (
        <p id="email-err" className="text-sm text-red-600">
          {error}
        </p>
      )}
      <button className="rounded-md bg-brand px-4 py-2 text-white">
        Sign up
      </button>
    </form>
  );
}

8) Performance & a11y guardrails

  • Serve optimized images with next/image and proper sizes.
  • Keep client bundle lean with Server Components.
  • Honor prefers-reduced-motion; keep focus outlines visible.
  • One primary UI library; use Radix primitives where needed.

9) Testing & monitoring

  • jest-axe for a11y.
  • Playwright for end‑to‑end flows (set reduced motion).
  • Plausible/Analytics for behavior; Sentry for errors.

10) Deploy

  • Vercel is the fastest route (SSR, images, edge).
  • For EC2 + Nginx, run a systemd service for the Next.js server, reverse proxy through Nginx, and terminate TLS (or use Cloudflare Full/Strict).
  • Use PM2 or systemd to keep the Node process alive.

Checklist

  • Tokens & dark mode wired
  • Navigation with feedback
  • Tabs/tooltip/dialog a11y‑correct
  • Scroll progress & subtle motion in place
  • Forms with inline validation
  • Performance budget & tests added

Next steps: Connect CMS content, add route‑level modals (parallel routes), and expand your component library with ownable shadcn/ui parts.

Read more like this

How To Make an Interactive Website (Next.js + shadcn/ui) | Ruixen UI