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 tooltipConfigure 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/imageand propersizes. - 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.
